Skip to main content

codeswarm_adapters/
settings.rs

1//! Small, atomic JSON settings store.
2//!
3//! Settings are user-edited state rather than a database.  Updates preserve
4//! unknown keys, replace malformed intermediate objects in the same way as
5//! the legacy settings layer, and use a same-directory create/sync/rename so
6//! an interrupted write cannot leave a half-written config file.
7
8use std::fs::{self, File, OpenOptions};
9use std::io::{self, Write};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use serde_json::{Map, Value};
14
15static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
16
17/// Read a settings object. Missing files are treated as empty settings.
18pub fn read_object(path: impl AsRef<Path>) -> io::Result<Map<String, Value>> {
19    let path = path.as_ref();
20    let raw = match fs::read_to_string(path) {
21        Ok(raw) => raw,
22        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Map::new()),
23        Err(error) => return Err(error),
24    };
25    let value = serde_json::from_str::<Value>(&raw).map_err(|error| {
26        io::Error::new(
27            io::ErrorKind::InvalidData,
28            format!("settings file is not valid JSON: {error}"),
29        )
30    })?;
31    value.as_object().cloned().ok_or_else(|| {
32        io::Error::new(
33            io::ErrorKind::InvalidData,
34            "settings file must contain a JSON object",
35        )
36    })
37}
38
39/// Update a settings object and commit it atomically.
40pub fn update<F>(path: impl AsRef<Path>, edit: F) -> io::Result<()>
41where
42    F: FnOnce(&mut Map<String, Value>),
43{
44    let path = path.as_ref();
45    let settings = read_object(path)?;
46    let mut updated = settings;
47    edit(&mut updated);
48    atomic_write(path, &Value::Object(updated))
49}
50
51/// Write one JSON value via a private same-directory temporary file.
52pub fn atomic_write(path: &Path, value: &Value) -> io::Result<()> {
53    let Some(parent) = path.parent() else {
54        return Err(io::Error::new(
55            io::ErrorKind::InvalidInput,
56            "settings path has no parent directory",
57        ));
58    };
59    fs::create_dir_all(parent)?;
60    set_private_directory(parent)?;
61
62    let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
63    let temporary = PathBuf::from(format!(
64        ".{}.tmp.{}.{}",
65        path.file_name()
66            .and_then(|name| name.to_str())
67            .unwrap_or("settings"),
68        std::process::id(),
69        counter
70    ));
71    let temporary = parent.join(temporary);
72    let result = (|| {
73        let mut options = OpenOptions::new();
74        options.create_new(true).write(true);
75        let mut file = options.open(&temporary)?;
76        set_private_file(&file)?;
77        let encoded = serde_json::to_vec_pretty(value).map_err(io::Error::other)?;
78        file.write_all(&encoded)?;
79        file.write_all(b"\n")?;
80        file.sync_all()?;
81
82        // Replacing a user's existing settings keeps its explicit mode. New
83        // files were created 0600 above.
84        #[cfg(unix)]
85        if let Ok(metadata) = fs::metadata(path) {
86            use std::os::unix::fs::PermissionsExt;
87            file.set_permissions(fs::Permissions::from_mode(
88                metadata.permissions().mode() & 0o777,
89            ))?;
90            file.sync_all()?;
91        }
92        drop(file);
93        fs::rename(&temporary, path)?;
94        sync_directory(parent)?;
95        Ok(())
96    })();
97    if result.is_err() {
98        let _ = fs::remove_file(&temporary);
99    }
100    result
101}
102
103fn set_private_directory(path: &Path) -> io::Result<()> {
104    #[cfg(unix)]
105    {
106        use std::os::unix::fs::PermissionsExt;
107        fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
108    }
109    Ok(())
110}
111
112fn set_private_file(file: &File) -> io::Result<()> {
113    #[cfg(unix)]
114    {
115        use std::os::unix::fs::PermissionsExt;
116        file.set_permissions(fs::Permissions::from_mode(0o600))?;
117    }
118    Ok(())
119}
120
121fn sync_directory(path: &Path) -> io::Result<()> {
122    #[cfg(unix)]
123    {
124        File::open(path)?.sync_all()?;
125    }
126    Ok(())
127}
128
129#[cfg(test)]
130mod tests {
131    use std::fs;
132    use std::os::unix::fs::PermissionsExt;
133
134    use super::{read_object, update};
135
136    #[test]
137    fn update_preserves_unknown_values_and_repairs_intermediate_objects() {
138        let directory = tempfile_directory();
139        let path = directory.join("codeswarm.json");
140        fs::write(
141            &path,
142            r#"{"other":{"keep":true},"ui":"legacy","broken":42}"#,
143        )
144        .expect("write");
145        update(&path, |settings| {
146            let ui = settings
147                .entry("ui")
148                .or_insert_with(|| serde_json::json!({}));
149            if !ui.is_object() {
150                *ui = serde_json::json!({});
151            }
152            ui["follow_output"] = serde_json::Value::Bool(true);
153        })
154        .expect("update");
155        let value = read_object(&path).expect("read");
156        assert_eq!(value["other"]["keep"], true);
157        assert_eq!(value["ui"]["follow_output"], true);
158        assert_eq!(value["broken"], 42);
159        // The fixture starts with the platform's normal 0644 mode; atomic
160        // replacement must preserve an existing file's explicit mode.
161        assert_eq!(
162            fs::metadata(&path).expect("metadata").permissions().mode() & 0o777,
163            0o644
164        );
165        assert!(
166            fs::read_dir(&directory)
167                .expect("directory")
168                .all(|entry| entry.expect("entry").file_name() != ".codeswarm.json.tmp")
169        );
170        fs::remove_dir_all(directory).expect("cleanup");
171    }
172
173    #[test]
174    fn malformed_settings_are_not_overwritten() {
175        let directory = tempfile_directory();
176        let path = directory.join("codeswarm.json");
177        fs::write(&path, "not json").expect("write");
178        assert!(update(&path, |_| {}).is_err());
179        assert_eq!(fs::read_to_string(&path).expect("read"), "not json");
180        fs::remove_dir_all(directory).expect("cleanup");
181    }
182
183    fn tempfile_directory() -> std::path::PathBuf {
184        let unique = std::time::SystemTime::now()
185            .duration_since(std::time::UNIX_EPOCH)
186            .expect("clock")
187            .as_nanos();
188        let path = std::env::temp_dir().join(format!(
189            "codeswarm-settings-{}-{unique}",
190            std::process::id()
191        ));
192        fs::create_dir_all(&path).expect("directory");
193        path
194    }
195}