forge_sdk/paths.rs
1//! Path helpers around the `claude` CLI's on-disk config layout.
2//!
3//! forge-sdk does not fall back to `~/.claude` on its own.
4//! Resolution lives in `forge-agent` / `forge-workspace` - those
5//! layers resolve each account's `config_dir` from `forge.toml` and
6//! thread the resulting
7//! `PathBuf` into every accessor that needs it. forge-sdk only
8//! exposes a `<config_dir> + "projects"` join helper for callers that
9//! already hold a config_dir.
10//!
11//! Separately, [`app_support_dir`] resolves forge's own machine-local
12//! data directory (`forge/` under the platform app-support base),
13//! shared by the logs and the single-instance lock.
14
15use std::collections::hash_map::DefaultHasher;
16use std::hash::{Hash, Hasher};
17use std::path::{Path, PathBuf};
18
19use sha2::{Digest, Sha256};
20
21use crate::Error;
22
23/// Resolve forge's machine-local application-support directory
24/// (`~/Library/Application Support/forge` on macOS,
25/// `$XDG_DATA_HOME/forge` on Linux). Distinct from the `claude`
26/// config dir: this holds forge's OWN per-machine state - the
27/// diagnostics logs and the single-instance lock both live under
28/// here, and it is never synced across machines.
29///
30/// # Errors
31///
32/// [`Error::Connection`] when none of `dirs::data_local_dir()`,
33/// `dirs::cache_dir()`, or `dirs::home_dir()` resolve. Per the
34/// project's Hard Rule #14 there is no `current_dir()` fallback - the
35/// path must not vary with the launch directory.
36pub fn app_support_dir() -> Result<PathBuf, Error> {
37 if let Some(dir) = dirs::data_local_dir() {
38 return Ok(dir.join("forge"));
39 }
40 if let Some(dir) = dirs::cache_dir() {
41 return Ok(dir.join("forge"));
42 }
43 if let Some(home) = dirs::home_dir() {
44 return Ok(home.join(".forge"));
45 }
46 Err(Error::Connection {
47 reason: "no resolvable data/cache/home dir for forge app-support path".into(),
48 })
49}
50
51/// Stable hex digest of `config_dir` that keys forge's machine-local
52/// per-config-dir files (the single-instance lock, the state cache).
53/// Canonicalised first (best-effort) so symlinked / trailing-slash
54/// variants map to one digest.
55pub fn config_dir_hash(config_dir: &Path) -> String {
56 let canonical = config_dir.canonicalize().unwrap_or_else(|_| config_dir.to_path_buf());
57 let mut hasher = DefaultHasher::new();
58 canonical.hash(&mut hasher);
59 format!("{:016x}", hasher.finish())
60}
61
62/// Stable hex key for a config dir's durable store, independent of the Rust
63/// release.
64///
65/// [`config_dir_hash`] hashes through `DefaultHasher`, whose algorithm std
66/// does not promise across releases; a nightly bump would silently open a new
67/// empty store and orphan every cron and subscription. The durable store
68/// therefore keys on a SHA-256 of the canonicalised path. **The lock keeps
69/// `config_dir_hash` on purpose**: a changed digest there would let a new
70/// binary miss a running old process's lock.
71pub fn config_dir_store_key(config_dir: &Path) -> String {
72 let canonical = config_dir.canonicalize().unwrap_or_else(|_| config_dir.to_path_buf());
73 let digest = Sha256::digest(canonical.as_os_str().as_encoded_bytes());
74 hex::encode(&digest[..8])
75}
76
77/// Path to a config_dir's `projects/` subdirectory. Caller passes
78/// the resolved `config_dir` explicitly; this helper just performs
79/// the join so the layout convention lives in one place.
80pub fn projects_dir_for(config_dir: &Path) -> PathBuf {
81 config_dir.join("projects")
82}
83
84#[cfg(test)]
85mod tests {
86
87 use super::*;
88
89 #[test]
90 fn projects_dir_for_appends_projects_subdir() {
91 assert_eq!(projects_dir_for(Path::new("/tmp/cfg")), PathBuf::from("/tmp/cfg/projects"),);
92 }
93
94 #[test]
95 fn app_support_dir_has_forge_leaf() {
96 let dir = app_support_dir().expect("resolves on a normal dev machine");
97 assert_eq!(dir.file_name().and_then(|s| s.to_str()), Some("forge"));
98 }
99
100 #[test]
101 fn config_dir_hash_is_deterministic_and_hex() {
102 let path = Path::new("/tmp/forge-config-dir-hash-fixture");
103 let first = config_dir_hash(path);
104 assert_eq!(first, config_dir_hash(path), "same config dir hashes identically");
105 assert_eq!(first.len(), 16, "digest is 16 hex chars");
106 assert!(first.chars().all(|c| c.is_ascii_hexdigit()), "digest is hex: {first}");
107 }
108
109 #[test]
110 fn config_dir_hash_differs_across_config_dirs() {
111 assert_ne!(
112 config_dir_hash(Path::new("/tmp/forge-config-dir-hash-a")),
113 config_dir_hash(Path::new("/tmp/forge-config-dir-hash-b")),
114 );
115 }
116
117 /// The store key is stable across a Rust release, so it is a digest of the
118 /// path rather than a `DefaultHasher` value: the same bytes every time,
119 /// fixed width, and a different key per config dir.
120 #[test]
121 fn the_store_key_is_a_stable_hex_digest_of_the_path() {
122 let one = Path::new("/tmp/forge-store-key-a");
123 let two = Path::new("/tmp/forge-store-key-b");
124 assert_eq!(config_dir_store_key(one), config_dir_store_key(one), "same dir, same key");
125 assert_ne!(config_dir_store_key(one), config_dir_store_key(two), "a key per config dir");
126 let key = config_dir_store_key(one);
127 assert_eq!(key.len(), 16, "16 hex chars, the width the lock uses: {key}");
128 assert!(key.chars().all(|c| c.is_ascii_hexdigit()), "the key is hex: {key}");
129 // **Not the lock's digest.** Swapping the store key back to
130 // `config_dir_hash` is the regression that orphans every row on a
131 // Rust release, and it is invisible unless the two are compared.
132 assert_ne!(
133 key,
134 config_dir_hash(one),
135 "the store key is its own digest, not the lock's DefaultHasher value",
136 );
137 }
138}