codeswarm_adapters/
settings.rs1use 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
17pub 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
39pub 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
51pub 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 #[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 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}