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)).map_err(|e| {
63 format!(
64 "refusing to overwrite an unparseable config at {}: {e}",
65 path.display()
66 )
67 })?
68 }
69 _ => merge_toml_inner("", new_content, Some(default_content))
71 .unwrap_or_else(|_| new_content.to_string()),
72 };
73 write_atomic_with_backup(path, &merged)
74}
75
76fn merge_toml(existing: &str, incoming: &str) -> Result<String, String> {
79 merge_toml_inner(existing, incoming, None)
80}
81
82fn merge_toml_inner(
83 existing: &str,
84 incoming: &str,
85 defaults: Option<&str>,
86) -> Result<String, String> {
87 let mut existing_doc = existing
88 .parse::<toml_edit::DocumentMut>()
89 .map_err(|e| e.to_string())?;
90 let incoming_doc = incoming
91 .parse::<toml_edit::DocumentMut>()
92 .map_err(|e| e.to_string())?;
93 let default_doc = match defaults {
94 Some(d) => Some(
95 d.parse::<toml_edit::DocumentMut>()
96 .map_err(|e| e.to_string())?,
97 ),
98 None => None,
99 };
100 merge_table(
101 existing_doc.as_table_mut(),
102 incoming_doc.as_table(),
103 default_doc.as_ref().map(toml_edit::DocumentMut::as_table),
104 );
105 Ok(existing_doc.to_string())
106}
107
108fn merge_table(
115 target: &mut toml_edit::Table,
116 source: &toml_edit::Table,
117 defaults: Option<&toml_edit::Table>,
118) {
119 use toml_edit::Item;
120 for (key, source_item) in source {
121 let default_item = defaults.and_then(|d| d.get(key));
122 match (source_item, target.get_mut(key)) {
123 (Item::Table(source_tbl), Some(Item::Table(target_tbl))) => {
124 merge_table(
125 target_tbl,
126 source_tbl,
127 default_item.and_then(Item::as_table),
128 );
129 }
130 (Item::Value(source_val), Some(Item::Value(target_val))) => {
131 let prefix = target_val.decor().prefix().cloned();
132 let suffix = target_val.decor().suffix().cloned();
133 let mut new_val = source_val.clone();
134 if let Some(p) = prefix {
135 new_val.decor_mut().set_prefix(p);
136 }
137 if let Some(s) = suffix {
138 new_val.decor_mut().set_suffix(s);
139 }
140 *target_val = new_val;
141 }
142 (_, Some(target_item)) => {
143 *target_item = source_item.clone();
144 }
145 (Item::Table(source_tbl), None) if defaults.is_some() => {
146 let mut fresh = toml_edit::Table::new();
149 merge_table(
150 &mut fresh,
151 source_tbl,
152 default_item.and_then(Item::as_table),
153 );
154 if !fresh.is_empty() {
155 target.insert(key, Item::Table(fresh));
156 }
157 }
158 (_, None) => {
159 if defaults.is_none() || !item_equals_default(source_item, default_item) {
160 target.insert(key, source_item.clone());
161 }
162 }
163 }
164 }
165}
166
167fn item_equals_default(item: &toml_edit::Item, default: Option<&toml_edit::Item>) -> bool {
171 match default {
172 Some(d) => item.to_string().trim() == d.to_string().trim(),
173 None => false,
174 }
175}
176
177pub fn cleanup_legacy_backups(data_dir: &Path) {
180 let Ok(entries) = std::fs::read_dir(data_dir) else {
181 return;
182 };
183 for entry in entries.flatten() {
184 let name = entry.file_name();
185 let name = name.to_string_lossy();
186 if name.contains(".lean-ctx.") && name.ends_with(".bak") {
187 let _ = std::fs::remove_file(entry.path());
188 }
189 }
190}
191
192pub fn write_atomic_with_backup_checked(
193 path: &Path,
194 content: &str,
195 expected_mtime: Option<std::time::SystemTime>,
196) -> Result<(), String> {
197 if path.exists() {
198 if let Some(expected) = expected_mtime {
199 let current = snapshot_mtime(path);
200 if current != Some(expected) {
201 return Err(format!(
202 "file was modified externally since last read: {}",
203 path.display()
204 ));
205 }
206 }
207 if let Some(bak) = backup_path_for(path) {
208 let _ = std::fs::copy(path, &bak);
209 }
210 }
211
212 write_atomic(path, content)
213}
214
215pub fn write_atomic(path: &Path, content: &str) -> Result<(), String> {
216 reject_symlink(path)?;
217
218 if let Some(parent) = path.parent() {
219 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
220 }
221
222 let parent = path
223 .parent()
224 .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
225 let filename = path
226 .file_name()
227 .ok_or_else(|| "invalid path (no filename)".to_string())?
228 .to_string_lossy();
229
230 let pid = std::process::id();
231 let nanos = std::time::SystemTime::now()
232 .duration_since(std::time::UNIX_EPOCH)
233 .map_or(0, |d| d.as_nanos());
234
235 let tmp = parent.join(format!(".{filename}.lean-ctx.tmp.{pid}.{nanos}"));
236 std::fs::write(&tmp, content).map_err(|e| e.to_string())?;
237
238 #[cfg(windows)]
239 {
240 if path.exists() {
241 let _ = std::fs::remove_file(path);
242 }
243 }
244
245 std::fs::rename(&tmp, path).map_err(|e| {
246 format!(
247 "atomic write failed: {} (tmp: {})",
248 e,
249 tmp.to_string_lossy()
250 )
251 })?;
252
253 restrict_file_permissions(path);
254
255 Ok(())
256}
257
258fn reject_symlink(path: &Path) -> Result<(), String> {
259 if path.exists()
262 && path
263 .symlink_metadata()
264 .is_ok_and(|m| crate::core::pathutil::is_symlink_or_reparse(&m))
265 {
266 return Err(format!(
267 "refusing to write through symlink: {}",
268 path.display()
269 ));
270 }
271 Ok(())
272}
273
274#[cfg(unix)]
275fn restrict_file_permissions(path: &Path) {
276 use std::os::unix::fs::PermissionsExt;
277 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
278}
279
280#[cfg(not(unix))]
281fn restrict_file_permissions(_path: &Path) {}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn merge_preserves_comments_and_unknown_keys() {
289 let existing = "\
290# My custom config — do not delete!
291ultra_compact = true # inline note
292
293# Section about the proxy
294[proxy]
295enabled = false
296custom_user_key = \"keep-me\"
297";
298 let incoming = "\
299ultra_compact = false
300
301[proxy]
302enabled = true
303";
304 let merged = merge_toml(existing, incoming).unwrap();
305
306 assert!(merged.contains("# My custom config — do not delete!"));
308 assert!(merged.contains("# inline note"));
309 assert!(merged.contains("# Section about the proxy"));
310 assert!(merged.contains("custom_user_key = \"keep-me\""));
312 assert!(merged.contains("ultra_compact = false"));
314 assert!(merged.contains("enabled = true"));
315 assert!(!merged.contains("enabled = false"));
316 }
317
318 #[test]
319 fn minimal_mode_skips_unset_defaults_but_keeps_existing() {
320 let existing = "# my config\nultra_compact = true\n";
322 let incoming = "ultra_compact = false\ncheckpoint_interval = 15\ntheme = \"default\"\n";
324 let defaults = "ultra_compact = false\ncheckpoint_interval = 15\ntheme = \"default\"\n";
326
327 let merged = merge_toml_inner(existing, incoming, Some(defaults)).unwrap();
328
329 assert!(merged.contains("# my config"));
331 assert!(merged.contains("ultra_compact = false"));
332 assert!(!merged.contains("checkpoint_interval"));
334 assert!(!merged.contains("theme"));
335 }
336
337 #[test]
338 fn minimal_mode_writes_non_default_values() {
339 let existing = "";
340 let incoming = "ultra_compact = false\ncheckpoint_interval = 42\n";
341 let defaults = "ultra_compact = false\ncheckpoint_interval = 15\n";
342
343 let merged = merge_toml_inner(existing, incoming, Some(defaults)).unwrap();
344
345 assert!(merged.contains("checkpoint_interval = 42"));
347 assert!(!merged.contains("ultra_compact"));
348 }
349
350 #[test]
351 fn minimal_mode_drops_empty_default_tables() {
352 let existing = "";
353 let incoming = "[proxy]\nenabled = false\n\n[lsp]\n";
354 let defaults = "[proxy]\nenabled = false\n\n[lsp]\n";
355
356 let merged = merge_toml_inner(existing, incoming, Some(defaults)).unwrap();
357
358 assert!(!merged.contains("[lsp]"));
360 assert!(!merged.contains("[proxy]"));
361 }
362
363 #[test]
364 fn merge_adds_new_keys_and_sections() {
365 let existing = "ultra_compact = true\n";
366 let incoming = "ultra_compact = true\nnew_key = 42\n\n[updates]\nauto_update = true\n";
367 let merged = merge_toml(existing, incoming).unwrap();
368 assert!(merged.contains("new_key = 42"));
369 assert!(merged.contains("[updates]"));
370 assert!(merged.contains("auto_update = true"));
371 }
372
373 fn unique_tmp(tag: &str) -> std::path::PathBuf {
374 let nanos = std::time::SystemTime::now()
375 .duration_since(std::time::UNIX_EPOCH)
376 .map_or(0, |d| d.as_nanos());
377 std::env::temp_dir().join(format!("lc_{tag}_{}_{nanos}", std::process::id()))
378 }
379
380 #[test]
381 fn write_toml_preserving_backs_up_and_keeps_comments() {
382 let tmp = unique_tmp("cfg_test");
383 let _ = std::fs::create_dir_all(&tmp);
384 let path = tmp.join("config.toml");
385 std::fs::write(&path, "# keep\nultra_compact = true\n").unwrap();
386
387 write_toml_preserving(&path, "ultra_compact = false\n").unwrap();
388
389 let result = std::fs::read_to_string(&path).unwrap();
390 assert!(result.contains("# keep"));
391 assert!(result.contains("ultra_compact = false"));
392 assert!(path.with_file_name("config.toml.bak").exists());
394
395 let _ = std::fs::remove_dir_all(&tmp);
396 }
397
398 #[test]
399 fn write_toml_preserving_handles_missing_file() {
400 let tmp = unique_tmp("cfg_new");
401 let _ = std::fs::remove_dir_all(&tmp);
402 let path = tmp.join("config.toml");
403 write_toml_preserving(&path, "ultra_compact = true\n").unwrap();
404 let result = std::fs::read_to_string(&path).unwrap();
405 assert!(result.contains("ultra_compact = true"));
406 let _ = std::fs::remove_dir_all(&tmp);
407 }
408
409 #[test]
410 fn minimal_mode_refuses_to_clobber_unparseable_existing() {
411 let tmp = unique_tmp("cfg_corrupt");
413 let _ = std::fs::create_dir_all(&tmp);
414 let path = tmp.join("config.toml");
415 let corrupt = "broken = = =\n";
416 std::fs::write(&path, corrupt).unwrap();
417
418 let result = write_toml_preserving_minimal(
419 &path,
420 "ultra_compact = false\n",
421 "ultra_compact = false\n",
422 );
423
424 assert!(
425 result.is_err(),
426 "must refuse to overwrite an unparseable config"
427 );
428 assert_eq!(
429 std::fs::read_to_string(&path).unwrap(),
430 corrupt,
431 "the corrupt file must be left exactly as-is"
432 );
433
434 let _ = std::fs::remove_dir_all(&tmp);
435 }
436}