1use std::path::{Path, PathBuf};
2
3fn backup_path_for(path: &Path) -> Option<PathBuf> {
4 let filename = path.file_name()?.to_string_lossy();
5 Some(path.with_file_name(format!("{filename}.bak")))
6}
7
8pub fn snapshot_mtime(path: &Path) -> Option<std::time::SystemTime> {
9 std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
10}
11
12pub fn write_atomic_with_backup(path: &Path, content: &str) -> Result<(), String> {
13 write_atomic_with_backup_checked(path, content, None)
14}
15
16pub fn write_toml_preserving(path: &Path, new_content: &str) -> Result<(), String> {
22 let merged = match std::fs::read_to_string(path) {
23 Ok(existing) if !existing.trim().is_empty() => {
24 merge_toml(&existing, new_content).unwrap_or_else(|_| new_content.to_string())
25 }
26 _ => new_content.to_string(),
27 };
28 write_atomic_with_backup(path, &merged)
29}
30
31pub fn load_toml_document(path: &Path) -> toml_edit::DocumentMut {
34 std::fs::read_to_string(path)
35 .ok()
36 .and_then(|c| c.parse::<toml_edit::DocumentMut>().ok())
37 .unwrap_or_default()
38}
39
40pub fn write_toml_document(path: &Path, doc: &toml_edit::DocumentMut) -> Result<(), String> {
42 write_atomic_with_backup(path, &doc.to_string())
43}
44
45pub fn write_toml_preserving_minimal(
51 path: &Path,
52 new_content: &str,
53 default_content: &str,
54) -> Result<(), String> {
55 let merged = match std::fs::read_to_string(path) {
56 Ok(existing) if !existing.trim().is_empty() => {
57 merge_toml_inner(&existing, new_content, Some(default_content))
58 .unwrap_or_else(|_| new_content.to_string())
59 }
60 _ => merge_toml_inner("", new_content, Some(default_content))
62 .unwrap_or_else(|_| new_content.to_string()),
63 };
64 write_atomic_with_backup(path, &merged)
65}
66
67fn merge_toml(existing: &str, incoming: &str) -> Result<String, String> {
70 merge_toml_inner(existing, incoming, None)
71}
72
73fn merge_toml_inner(
74 existing: &str,
75 incoming: &str,
76 defaults: Option<&str>,
77) -> Result<String, String> {
78 let mut existing_doc = existing
79 .parse::<toml_edit::DocumentMut>()
80 .map_err(|e| e.to_string())?;
81 let incoming_doc = incoming
82 .parse::<toml_edit::DocumentMut>()
83 .map_err(|e| e.to_string())?;
84 let default_doc = match defaults {
85 Some(d) => Some(
86 d.parse::<toml_edit::DocumentMut>()
87 .map_err(|e| e.to_string())?,
88 ),
89 None => None,
90 };
91 merge_table(
92 existing_doc.as_table_mut(),
93 incoming_doc.as_table(),
94 default_doc.as_ref().map(toml_edit::DocumentMut::as_table),
95 );
96 Ok(existing_doc.to_string())
97}
98
99fn merge_table(
106 target: &mut toml_edit::Table,
107 source: &toml_edit::Table,
108 defaults: Option<&toml_edit::Table>,
109) {
110 use toml_edit::Item;
111 for (key, source_item) in source {
112 let default_item = defaults.and_then(|d| d.get(key));
113 match (source_item, target.get_mut(key)) {
114 (Item::Table(source_tbl), Some(Item::Table(target_tbl))) => {
115 merge_table(
116 target_tbl,
117 source_tbl,
118 default_item.and_then(Item::as_table),
119 );
120 }
121 (Item::Value(source_val), Some(Item::Value(target_val))) => {
122 let prefix = target_val.decor().prefix().cloned();
123 let suffix = target_val.decor().suffix().cloned();
124 let mut new_val = source_val.clone();
125 if let Some(p) = prefix {
126 new_val.decor_mut().set_prefix(p);
127 }
128 if let Some(s) = suffix {
129 new_val.decor_mut().set_suffix(s);
130 }
131 *target_val = new_val;
132 }
133 (_, Some(target_item)) => {
134 *target_item = source_item.clone();
135 }
136 (Item::Table(source_tbl), None) if defaults.is_some() => {
137 let mut fresh = toml_edit::Table::new();
140 merge_table(
141 &mut fresh,
142 source_tbl,
143 default_item.and_then(Item::as_table),
144 );
145 if !fresh.is_empty() {
146 target.insert(key, Item::Table(fresh));
147 }
148 }
149 (_, None) => {
150 if defaults.is_none() || !item_equals_default(source_item, default_item) {
151 target.insert(key, source_item.clone());
152 }
153 }
154 }
155 }
156}
157
158fn item_equals_default(item: &toml_edit::Item, default: Option<&toml_edit::Item>) -> bool {
162 match default {
163 Some(d) => item.to_string().trim() == d.to_string().trim(),
164 None => false,
165 }
166}
167
168pub fn cleanup_legacy_backups(data_dir: &Path) {
171 let Ok(entries) = std::fs::read_dir(data_dir) else {
172 return;
173 };
174 for entry in entries.flatten() {
175 let name = entry.file_name();
176 let name = name.to_string_lossy();
177 if name.contains(".lean-ctx.") && name.ends_with(".bak") {
178 let _ = std::fs::remove_file(entry.path());
179 }
180 }
181}
182
183pub fn write_atomic_with_backup_checked(
184 path: &Path,
185 content: &str,
186 expected_mtime: Option<std::time::SystemTime>,
187) -> Result<(), String> {
188 if path.exists() {
189 if let Some(expected) = expected_mtime {
190 let current = snapshot_mtime(path);
191 if current != Some(expected) {
192 return Err(format!(
193 "file was modified externally since last read: {}",
194 path.display()
195 ));
196 }
197 }
198 if let Some(bak) = backup_path_for(path) {
199 let _ = std::fs::copy(path, &bak);
200 }
201 }
202
203 write_atomic(path, content)
204}
205
206pub fn write_atomic(path: &Path, content: &str) -> Result<(), String> {
207 reject_symlink(path)?;
208
209 if let Some(parent) = path.parent() {
210 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
211 }
212
213 let parent = path
214 .parent()
215 .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
216 let filename = path
217 .file_name()
218 .ok_or_else(|| "invalid path (no filename)".to_string())?
219 .to_string_lossy();
220
221 let pid = std::process::id();
222 let nanos = std::time::SystemTime::now()
223 .duration_since(std::time::UNIX_EPOCH)
224 .map_or(0, |d| d.as_nanos());
225
226 let tmp = parent.join(format!(".{filename}.lean-ctx.tmp.{pid}.{nanos}"));
227 std::fs::write(&tmp, content).map_err(|e| e.to_string())?;
228
229 #[cfg(windows)]
230 {
231 if path.exists() {
232 let _ = std::fs::remove_file(path);
233 }
234 }
235
236 std::fs::rename(&tmp, path).map_err(|e| {
237 format!(
238 "atomic write failed: {} (tmp: {})",
239 e,
240 tmp.to_string_lossy()
241 )
242 })?;
243
244 restrict_file_permissions(path);
245
246 Ok(())
247}
248
249fn reject_symlink(path: &Path) -> Result<(), String> {
250 if path.exists()
251 && path
252 .symlink_metadata()
253 .is_ok_and(|m| m.file_type().is_symlink())
254 {
255 return Err(format!(
256 "refusing to write through symlink: {}",
257 path.display()
258 ));
259 }
260 Ok(())
261}
262
263#[cfg(unix)]
264fn restrict_file_permissions(path: &Path) {
265 use std::os::unix::fs::PermissionsExt;
266 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
267}
268
269#[cfg(not(unix))]
270fn restrict_file_permissions(_path: &Path) {}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn merge_preserves_comments_and_unknown_keys() {
278 let existing = "\
279# My custom config — do not delete!
280ultra_compact = true # inline note
281
282# Section about the proxy
283[proxy]
284enabled = false
285custom_user_key = \"keep-me\"
286";
287 let incoming = "\
288ultra_compact = false
289
290[proxy]
291enabled = true
292";
293 let merged = merge_toml(existing, incoming).unwrap();
294
295 assert!(merged.contains("# My custom config — do not delete!"));
297 assert!(merged.contains("# inline note"));
298 assert!(merged.contains("# Section about the proxy"));
299 assert!(merged.contains("custom_user_key = \"keep-me\""));
301 assert!(merged.contains("ultra_compact = false"));
303 assert!(merged.contains("enabled = true"));
304 assert!(!merged.contains("enabled = false"));
305 }
306
307 #[test]
308 fn minimal_mode_skips_unset_defaults_but_keeps_existing() {
309 let existing = "# my config\nultra_compact = true\n";
311 let incoming = "ultra_compact = false\ncheckpoint_interval = 15\ntheme = \"default\"\n";
313 let defaults = "ultra_compact = false\ncheckpoint_interval = 15\ntheme = \"default\"\n";
315
316 let merged = merge_toml_inner(existing, incoming, Some(defaults)).unwrap();
317
318 assert!(merged.contains("# my config"));
320 assert!(merged.contains("ultra_compact = false"));
321 assert!(!merged.contains("checkpoint_interval"));
323 assert!(!merged.contains("theme"));
324 }
325
326 #[test]
327 fn minimal_mode_writes_non_default_values() {
328 let existing = "";
329 let incoming = "ultra_compact = false\ncheckpoint_interval = 42\n";
330 let defaults = "ultra_compact = false\ncheckpoint_interval = 15\n";
331
332 let merged = merge_toml_inner(existing, incoming, Some(defaults)).unwrap();
333
334 assert!(merged.contains("checkpoint_interval = 42"));
336 assert!(!merged.contains("ultra_compact"));
337 }
338
339 #[test]
340 fn minimal_mode_drops_empty_default_tables() {
341 let existing = "";
342 let incoming = "[proxy]\nenabled = false\n\n[lsp]\n";
343 let defaults = "[proxy]\nenabled = false\n\n[lsp]\n";
344
345 let merged = merge_toml_inner(existing, incoming, Some(defaults)).unwrap();
346
347 assert!(!merged.contains("[lsp]"));
349 assert!(!merged.contains("[proxy]"));
350 }
351
352 #[test]
353 fn merge_adds_new_keys_and_sections() {
354 let existing = "ultra_compact = true\n";
355 let incoming = "ultra_compact = true\nnew_key = 42\n\n[updates]\nauto_update = true\n";
356 let merged = merge_toml(existing, incoming).unwrap();
357 assert!(merged.contains("new_key = 42"));
358 assert!(merged.contains("[updates]"));
359 assert!(merged.contains("auto_update = true"));
360 }
361
362 fn unique_tmp(tag: &str) -> std::path::PathBuf {
363 let nanos = std::time::SystemTime::now()
364 .duration_since(std::time::UNIX_EPOCH)
365 .map_or(0, |d| d.as_nanos());
366 std::env::temp_dir().join(format!("lc_{tag}_{}_{nanos}", std::process::id()))
367 }
368
369 #[test]
370 fn write_toml_preserving_backs_up_and_keeps_comments() {
371 let tmp = unique_tmp("cfg_test");
372 let _ = std::fs::create_dir_all(&tmp);
373 let path = tmp.join("config.toml");
374 std::fs::write(&path, "# keep\nultra_compact = true\n").unwrap();
375
376 write_toml_preserving(&path, "ultra_compact = false\n").unwrap();
377
378 let result = std::fs::read_to_string(&path).unwrap();
379 assert!(result.contains("# keep"));
380 assert!(result.contains("ultra_compact = false"));
381 assert!(path.with_file_name("config.toml.bak").exists());
383
384 let _ = std::fs::remove_dir_all(&tmp);
385 }
386
387 #[test]
388 fn write_toml_preserving_handles_missing_file() {
389 let tmp = unique_tmp("cfg_new");
390 let _ = std::fs::remove_dir_all(&tmp);
391 let path = tmp.join("config.toml");
392 write_toml_preserving(&path, "ultra_compact = true\n").unwrap();
393 let result = std::fs::read_to_string(&path).unwrap();
394 assert!(result.contains("ultra_compact = true"));
395 let _ = std::fs::remove_dir_all(&tmp);
396 }
397}