Skip to main content

forge_agent/userdata/
settings.rs

1//! Settings document accessors - read the three Claude Code
2//! configuration files. Reads return raw `serde_json::Value`
3//! documents; consumers own the merge / precedence semantics.
4//!
5//! Resolution rules match the `claude` CLI as of 2.1.117:
6//!
7//! - **User settings** at `<config_dir>/settings.json`. `<config_dir>`
8//!   is the path the caller passes - typically the per-spawn account
9//!   binding stored on the `ForgeSdkBridge`.
10//! - **Project-local settings** at
11//!   `<cwd>/.claude/settings.local.json`. Tied to the project's
12//!   working directory; `<config_dir>` does not affect this path.
13//! - **User preferences** at `$HOME/.claude.json` (note the leading
14//!   dot - this is a *file* at the home root, not a directory under
15//!   it). Per-user preferences (notification channel, gitignore
16//!   respect, terminal-progress-bar, etc.); `<config_dir>` does not
17//!   affect this either.
18
19use std::io::Write as _;
20use std::path::{Path, PathBuf};
21
22use serde_json::Value;
23
24/// Three raw settings documents, each `None` when the underlying
25/// file is absent or unreadable. Malformed JSON also yields `None` -
26/// consumers that care about distinguishing missing vs. corrupt
27/// should re-read directly.
28#[derive(Debug, Clone, Default)]
29pub struct SettingsDocuments {
30    /// `<config_dir>/settings.json` - user-scope settings.
31    pub user: Option<Value>,
32    /// `<cwd>/.claude/settings.local.json` - project-local overrides.
33    pub project_local: Option<Value>,
34    /// `$HOME/.claude.json` - per-user preferences.
35    pub preferences: Option<Value>,
36}
37
38/// Read the three Claude Code settings documents from disk.
39///
40/// `config_dir` is the user-scope config directory the caller has
41/// bound this read to (typically the per-spawn account binding).
42/// `cwd` is the project root used to locate
43/// `<cwd>/.claude/settings.local.json` - sourced from `forge.toml` or
44/// the agent's reported cwd, never `std::env::current_dir()`. It is
45/// `None` when no project root resolves, which reads no project-local
46/// document rather than joining an empty root into a relative path.
47pub fn settings_documents(config_dir: &Path, cwd: Option<&Path>) -> SettingsDocuments {
48    SettingsDocuments {
49        user: read_json_file(&config_dir.join("settings.json")),
50        project_local: cwd
51            .and_then(|cwd| read_json_file(&cwd.join(".claude").join("settings.local.json"))),
52        preferences: user_preferences(),
53    }
54}
55
56/// Write one settings document, leaving the file it replaces whole.
57///
58/// A symlink at `path` is followed rather than replaced: the rename onto
59/// a link would break a profile setup that points one config dir's
60/// `settings.json` at another's.
61pub fn save_document(path: &Path, document: &Value) -> Result<(), String> {
62    let resolved = resolve_symlink(path)
63        .map_err(|err| format!("Failed to resolve settings symlink: {err}"))?;
64    let path = resolved.as_path();
65    let parent = path.parent().ok_or_else(|| "Settings path has no parent directory".to_owned())?;
66    if !parent.is_dir() {
67        // A link whose target directory is gone. Writing still repairs
68        // the canonical file, but building a tree for a stale link
69        // should not happen silently.
70        tracing::warn!(
71            target: "forge_agent::userdata::settings",
72            path = %path.display(),
73            "settings symlink resolved to a path whose parent does not exist; creating it"
74        );
75    }
76    std::fs::create_dir_all(parent)
77        .map_err(|err| format!("Failed to create settings directory: {err}"))?;
78
79    let normalized = match document {
80        Value::Object(object) => Value::Object(object.clone()),
81        _ => Value::Object(serde_json::Map::new()),
82    };
83    let temp_path = unique_temp_path(parent, path.file_name().and_then(std::ffi::OsStr::to_str));
84    let result = write_then_rename(&temp_path, path, &normalized);
85    if result.is_err() {
86        // Best-effort: a failed rename would otherwise leave
87        // `.settings.json.<nanos>.tmp` in the config dir forever.
88        // Propagate the original error, not the cleanup's.
89        if let Err(cleanup) = std::fs::remove_file(&temp_path) {
90            tracing::debug!(
91                target: "forge_agent::userdata::settings",
92                error = %cleanup,
93                "failed to clean up settings temp file; original error follows"
94            );
95        }
96    }
97    result
98}
99
100fn unique_temp_path(parent: &Path, filename_hint: Option<&str>) -> PathBuf {
101    let stamp = std::time::SystemTime::now()
102        .duration_since(std::time::UNIX_EPOCH)
103        .map_or(0, |duration| duration.as_nanos());
104    let filename = filename_hint.unwrap_or("settings.json");
105    parent.join(format!(".{filename}.{stamp}.tmp"))
106}
107
108/// Walk a symlink chain to the file it ultimately names, resolving each
109/// relative target against its own link's parent. Not `canonicalize`,
110/// which fails on a dangling link: a link whose target is missing should
111/// still resolve, so the write recreates the canonical file rather than
112/// clobbering the link.
113fn resolve_symlink(path: &Path) -> std::io::Result<PathBuf> {
114    // Chains are one hop in practice; the cap is only a cycle guard.
115    const MAX_HOPS: usize = 32;
116
117    let mut current = path.to_path_buf();
118    for _ in 0..MAX_HOPS {
119        match std::fs::symlink_metadata(&current) {
120            Ok(md) if md.file_type().is_symlink() => {
121                let link = std::fs::read_link(&current)?;
122                current = if link.is_absolute() {
123                    link
124                } else {
125                    current.parent().map_or_else(|| link.clone(), |parent| parent.join(&link))
126                };
127            }
128            _ => return Ok(current),
129        }
130    }
131    Err(std::io::Error::new(
132        std::io::ErrorKind::InvalidInput,
133        format!("settings symlink chain exceeded {MAX_HOPS} hops: {}", path.display()),
134    ))
135}
136
137fn write_then_rename(temp_path: &Path, path: &Path, document: &Value) -> Result<(), String> {
138    let mut temp = std::fs::OpenOptions::new()
139        .write(true)
140        .create_new(true)
141        .open(temp_path)
142        .map_err(|err| format!("Failed to create settings temp file: {err}"))?;
143    serde_json::to_writer_pretty(&mut temp, document)
144        .map_err(|err| format!("Failed to serialize settings: {err}"))?;
145    temp.write_all(b"\n").map_err(|err| format!("Failed to finalize settings file: {err}"))?;
146    temp.flush().map_err(|err| format!("Failed to flush settings file: {err}"))?;
147    temp.sync_all().map_err(|err| format!("Failed to sync settings file: {err}"))?;
148    drop(temp);
149    // Carry the existing file's mode over. settings.json is 0600 for a
150    // reason and a fresh temp file would otherwise widen it to 0644.
151    if let Ok(existing) = std::fs::metadata(path) {
152        std::fs::set_permissions(temp_path, existing.permissions())
153            .map_err(|err| format!("Failed to apply settings file mode: {err}"))?;
154    }
155    std::fs::rename(temp_path, path)
156        .map_err(|err| format!("Failed to move settings file into place: {err}"))
157}
158
159/// The per-user preferences document alone, for a reader that wants
160/// nothing else out of it. `None` when the file is absent or unreadable.
161pub fn user_preferences() -> Option<Value> {
162    home_dir().and_then(|home| read_json_file(&home.join(".claude.json")))
163}
164
165fn home_dir() -> Option<PathBuf> {
166    std::env::var_os("HOME").filter(|s| !s.is_empty()).map(PathBuf::from)
167}
168
169fn read_json_file(path: &Path) -> Option<Value> {
170    let contents = match std::fs::read_to_string(path) {
171        Ok(s) => s,
172        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
173        Err(e) => {
174            tracing::warn!(
175                target: "forge_agent::userdata::settings",
176                path = %path.display(),
177                error = %e,
178                "failed to read settings file"
179            );
180            return None;
181        }
182    };
183    match serde_json::from_str(&contents) {
184        Ok(v) => Some(v),
185        Err(e) => {
186            tracing::warn!(
187                target: "forge_agent::userdata::settings",
188                path = %path.display(),
189                error = %e,
190                "failed to parse settings file as JSON"
191            );
192            None
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199
200    use std::io::Write;
201
202    use super::*;
203
204    #[test]
205    fn missing_file_returns_none() {
206        let path = std::path::Path::new("/tmp/forge_sdk_test_nonexistent_settings.json");
207        assert!(read_json_file(path).is_none());
208    }
209
210    #[test]
211    fn malformed_json_returns_none() {
212        let mut tmp = tempfile::NamedTempFile::new().expect("tempfile");
213        write!(tmp, "{{ not valid").expect("write");
214        assert!(read_json_file(tmp.path()).is_none());
215    }
216
217    #[test]
218    fn parses_object() {
219        let mut tmp = tempfile::NamedTempFile::new().expect("tempfile");
220        write!(tmp, r#"{{"editorMode":"vim","userID":"abc"}}"#).expect("write");
221        let value = read_json_file(tmp.path()).expect("parsed");
222        assert_eq!(value.get("editorMode"), Some(&serde_json::json!("vim")));
223        assert_eq!(value.get("userID"), Some(&serde_json::json!("abc")));
224    }
225
226    #[test]
227    fn settings_documents_has_default() {
228        // Smoke-check the Default impl works - useful when callers
229        // want a "no settings yet" placeholder.
230        let docs = SettingsDocuments::default();
231        assert!(docs.user.is_none());
232        assert!(docs.project_local.is_none());
233        assert!(docs.preferences.is_none());
234    }
235
236    /// A `None` root yields no project-local document, and the config
237    /// dir is not consulted for one. Neither half is pinned by this
238    /// test: the rule-14 half could only be caught by a fixture at the
239    /// cwd-relative path, which is a write into the source tree, and
240    /// the config-dir half lost the fixture that caught it when that
241    /// one went.
242    #[test]
243    fn settings_documents_reads_no_project_local_without_a_root() {
244        let dir = tempfile::tempdir().expect("tempdir");
245
246        let docs = settings_documents(dir.path(), None);
247
248        assert!(docs.project_local.is_none(), "no root, no project-local document");
249    }
250
251    /// Regression: a symlink at the write target must be preserved.
252    /// `std::fs::rename(temp, symlink_path)` replaces the symlink
253    /// itself, clobbering profile setups such as
254    /// `/tmp/forge-test-stargate/settings.json -> ~/.claude/settings.json`.
255    #[test]
256    fn save_document_preserves_a_symlink_at_the_write_target() {
257        let dir = tempfile::tempdir().expect("tempdir");
258        let canonical_dir = dir.path().join("canonical");
259        let profile_dir = dir.path().join("profile");
260        std::fs::create_dir_all(&canonical_dir).expect("mkdir canonical");
261        std::fs::create_dir_all(&profile_dir).expect("mkdir profile");
262
263        let canonical = canonical_dir.join("settings.json");
264        let profile = profile_dir.join("settings.json");
265        std::fs::write(&canonical, b"{}\n").expect("seed canonical");
266        std::os::unix::fs::symlink(&canonical, &profile).expect("symlink");
267
268        save_document(&profile, &serde_json::json!({ "effortLevel": "max" })).expect("save");
269
270        let md = std::fs::symlink_metadata(&profile).expect("symlink_metadata");
271        assert!(md.file_type().is_symlink(), "profile path got clobbered into a real file");
272
273        let written: Value =
274            serde_json::from_str(&std::fs::read_to_string(&canonical).expect("read canonical"))
275                .expect("parse");
276        assert_eq!(written.get("effortLevel"), Some(&serde_json::json!("max")));
277    }
278
279    #[test]
280    fn save_document_resolves_a_relative_symlink_against_its_own_parent() {
281        let dir = tempfile::tempdir().expect("tempdir");
282        let canonical = dir.path().join("settings.json");
283        let profile_dir = dir.path().join("profile");
284        std::fs::create_dir_all(&profile_dir).expect("mkdir profile");
285        let profile = profile_dir.join("settings.json");
286        std::fs::write(&canonical, b"{}\n").expect("seed canonical");
287        std::os::unix::fs::symlink(std::path::Path::new("..").join("settings.json"), &profile)
288            .expect("symlink");
289
290        save_document(&profile, &serde_json::json!({ "model": "opus" })).expect("save");
291
292        let md = std::fs::symlink_metadata(&profile).expect("symlink_metadata");
293        assert!(md.file_type().is_symlink(), "profile path got clobbered into a real file");
294
295        let written: Value =
296            serde_json::from_str(&std::fs::read_to_string(&canonical).expect("read canonical"))
297                .expect("parse");
298        assert_eq!(written.get("model"), Some(&serde_json::json!("opus")));
299    }
300
301    /// A profile link can point at another link. Resolving only one hop
302    /// writes the intermediate and leaves the canonical file stale.
303    #[test]
304    fn save_document_walks_a_symlink_chain_to_the_canonical_file() {
305        let dir = tempfile::tempdir().expect("tempdir");
306        let canonical = dir.path().join("real.json");
307        let mid = dir.path().join("mid.json");
308        let top = dir.path().join("top.json");
309        std::fs::write(&canonical, b"{}\n").expect("seed canonical");
310        std::os::unix::fs::symlink(&canonical, &mid).expect("symlink mid");
311        std::os::unix::fs::symlink(&mid, &top).expect("symlink top");
312
313        save_document(&top, &serde_json::json!({ "model": "opus" })).expect("save");
314
315        for (label, path) in [("top", &top), ("mid", &mid)] {
316            let md = std::fs::symlink_metadata(path).expect("symlink_metadata");
317            assert!(md.file_type().is_symlink(), "{label} got clobbered into a real file");
318        }
319        let written: Value =
320            serde_json::from_str(&std::fs::read_to_string(&canonical).expect("read canonical"))
321                .expect("parse");
322        assert_eq!(written.get("model"), Some(&serde_json::json!("opus")));
323    }
324
325    #[test]
326    fn save_document_preserves_the_existing_file_mode() {
327        use std::os::unix::fs::PermissionsExt;
328
329        let dir = tempfile::tempdir().expect("tempdir");
330        let path = dir.path().join("settings.json");
331        std::fs::write(&path, b"{}\n").expect("seed");
332        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod");
333
334        save_document(&path, &serde_json::json!({ "model": "opus" })).expect("save");
335
336        let mode = std::fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
337        assert_eq!(mode, 0o600, "a restricted settings file must not become world-readable");
338    }
339
340    #[test]
341    fn save_document_leaves_no_temp_file_behind_when_the_rename_fails() {
342        let dir = tempfile::tempdir().expect("tempdir");
343        // A directory at the target path makes rename fail after the temp
344        // has been written and synced.
345        let path = dir.path().join("settings.json");
346        std::fs::create_dir(&path).expect("mkdir at target");
347
348        assert!(save_document(&path, &serde_json::json!({ "model": "opus" })).is_err());
349
350        let strays: Vec<_> = std::fs::read_dir(dir.path())
351            .expect("read_dir")
352            .filter_map(Result::ok)
353            .map(|entry| entry.file_name().to_string_lossy().into_owned())
354            .filter(|name| std::path::Path::new(name).extension().is_some_and(|ext| ext == "tmp"))
355            .collect();
356        assert!(strays.is_empty(), "temp files left behind: {strays:?}");
357    }
358}