forge_agent/userdata/
settings.rs1use std::io::Write as _;
20use std::path::{Path, PathBuf};
21
22use serde_json::Value;
23
24#[derive(Debug, Clone, Default)]
29pub struct SettingsDocuments {
30 pub user: Option<Value>,
32 pub project_local: Option<Value>,
34 pub preferences: Option<Value>,
36}
37
38pub 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
56pub 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 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 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
108fn resolve_symlink(path: &Path) -> std::io::Result<PathBuf> {
114 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(¤t) {
120 Ok(md) if md.file_type().is_symlink() => {
121 let link = std::fs::read_link(¤t)?;
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 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
159pub 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 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 #[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 #[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 #[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 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}