Skip to main content

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}