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    create_private_directory(parent)?;
60
61    let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
62    let temporary = PathBuf::from(format!(
63        ".{}.tmp.{}.{}",
64        path.file_name()
65            .and_then(|name| name.to_str())
66            .unwrap_or("settings"),
67        std::process::id(),
68        counter
69    ));
70    let temporary = parent.join(temporary);
71    let result = (|| {
72        let mut options = OpenOptions::new();
73        options.create_new(true).write(true);
74        #[cfg(unix)]
75        {
76            use std::os::unix::fs::OpenOptionsExt;
77            options.mode(0o600);
78        }
79        let mut file = options.open(&temporary)?;
80        set_private_file(&file)?;
81        let encoded = serde_json::to_vec_pretty(value).map_err(io::Error::other)?;
82        file.write_all(&encoded)?;
83        file.write_all(b"\n")?;
84        file.sync_all()?;
85
86        // Replacing a user's existing settings keeps its explicit mode. New
87        // files were created 0600 above.
88        #[cfg(unix)]
89        if let Ok(metadata) = fs::metadata(path) {
90            use std::os::unix::fs::PermissionsExt;
91            file.set_permissions(fs::Permissions::from_mode(
92                metadata.permissions().mode() & 0o777,
93            ))?;
94            file.sync_all()?;
95        }
96        drop(file);
97        fs::rename(&temporary, path)?;
98        sync_directory(parent)?;
99        Ok(())
100    })();
101    if result.is_err() {
102        let _ = fs::remove_file(&temporary);
103    }
104    result
105}
106
107fn create_private_directory(path: &Path) -> io::Result<()> {
108    #[cfg(unix)]
109    {
110        use std::os::unix::fs::DirBuilderExt;
111        fs::DirBuilder::new()
112            .recursive(true)
113            .mode(0o700)
114            .create(path)?;
115    }
116    #[cfg(not(unix))]
117    fs::create_dir_all(path)?;
118    Ok(())
119}
120
121fn set_private_file(file: &File) -> io::Result<()> {
122    #[cfg(unix)]
123    {
124        use std::os::unix::fs::PermissionsExt;
125        file.set_permissions(fs::Permissions::from_mode(0o600))?;
126    }
127    Ok(())
128}
129
130fn sync_directory(path: &Path) -> io::Result<()> {
131    #[cfg(unix)]
132    {
133        File::open(path)?.sync_all()?;
134    }
135    Ok(())
136}
137
138#[cfg(test)]
139mod tests {
140    use std::fs;
141    use std::os::unix::fs::PermissionsExt;
142
143    use super::{read_object, update};
144
145    #[test]
146    fn update_preserves_unknown_values_and_repairs_intermediate_objects() {
147        let directory = tempfile_directory();
148        let path = directory.join("codeswarm.json");
149        fs::write(
150            &path,
151            r#"{"other":{"keep":true},"ui":"legacy","broken":42}"#,
152        )
153        .expect("write");
154        update(&path, |settings| {
155            let ui = settings
156                .entry("ui")
157                .or_insert_with(|| serde_json::json!({}));
158            if !ui.is_object() {
159                *ui = serde_json::json!({});
160            }
161            ui["follow_output"] = serde_json::Value::Bool(true);
162        })
163        .expect("update");
164        let value = read_object(&path).expect("read");
165        assert_eq!(value["other"]["keep"], true);
166        assert_eq!(value["ui"]["follow_output"], true);
167        assert_eq!(value["broken"], 42);
168        // The fixture starts with the platform's normal 0644 mode; atomic
169        // replacement must preserve an existing file's explicit mode.
170        assert_eq!(
171            fs::metadata(&path).expect("metadata").permissions().mode() & 0o777,
172            0o644
173        );
174        assert!(
175            fs::read_dir(&directory)
176                .expect("directory")
177                .all(|entry| entry.expect("entry").file_name() != ".codeswarm.json.tmp")
178        );
179        fs::remove_dir_all(directory).expect("cleanup");
180    }
181
182    #[test]
183    fn settings_preserve_existing_parent_permissions_and_create_private_directories() {
184        let directory = tempfile_directory();
185        fs::set_permissions(&directory, fs::Permissions::from_mode(0o755)).unwrap();
186        update(directory.join("existing.json"), |_| {}).unwrap();
187        assert_eq!(
188            fs::metadata(&directory).unwrap().permissions().mode() & 0o777,
189            0o755
190        );
191        let nested = directory.join("new");
192        let path = nested.join("settings.json");
193        update(&path, |_| {}).unwrap();
194        assert_eq!(
195            fs::metadata(nested).unwrap().permissions().mode() & 0o777,
196            0o700
197        );
198        assert_eq!(
199            fs::metadata(path).unwrap().permissions().mode() & 0o777,
200            0o600
201        );
202        fs::remove_dir_all(directory).unwrap();
203    }
204
205    #[test]
206    fn malformed_settings_are_not_overwritten() {
207        let directory = tempfile_directory();
208        let path = directory.join("codeswarm.json");
209        fs::write(&path, "not json").expect("write");
210        assert!(update(&path, |_| {}).is_err());
211        assert_eq!(fs::read_to_string(&path).expect("read"), "not json");
212        fs::remove_dir_all(directory).expect("cleanup");
213    }
214
215    fn tempfile_directory() -> std::path::PathBuf {
216        let unique = std::time::SystemTime::now()
217            .duration_since(std::time::UNIX_EPOCH)
218            .expect("clock")
219            .as_nanos();
220        let path = std::env::temp_dir().join(format!(
221            "codeswarm-settings-{}-{unique}",
222            std::process::id()
223        ));
224        fs::create_dir_all(&path).expect("directory");
225        path
226    }
227}