Skip to main content

lean_ctx/
config_io.rs

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
16/// Writes TOML config while preserving comments, formatting, key ordering, and
17/// any keys present on disk but absent from `new_content` (user customizations,
18/// unknown/future keys). Values from `new_content` are merged onto the existing
19/// document. Falls back to a plain atomic write when there is nothing to merge
20/// or the existing file cannot be parsed.
21pub 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
31/// Loads a TOML file into an editable document, preserving comments and
32/// formatting. Returns an empty document when the file is missing or invalid.
33pub 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
40/// Persists an edited document via the atomic-with-backup path.
41pub fn write_toml_document(path: &Path, doc: &toml_edit::DocumentMut) -> Result<(), String> {
42    write_atomic_with_backup(path, &doc.to_string())
43}
44
45/// Like `write_toml_preserving`, but keeps the config minimal: keys whose value
46/// equals the type's default AND are not already present on disk are skipped,
47/// so a hand-written config is not bloated with every default key. Existing
48/// keys are always updated (preserving comments), and non-default values are
49/// always written. `default_content` is `toml::to_string_pretty(&T::default())`.
50pub 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            // Refuse to overwrite a non-empty file we cannot parse. `new_content`
58            // and `default_content` come from our own serializer (always valid),
59            // so a merge failure means the on-disk config is corrupt — clobbering
60            // it with defaults would silently wipe customizations (#443). We
61            // propagate the error and leave the file untouched instead.
62            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        // No existing file: write a fresh minimal document (drop defaults).
70        _ => 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
76/// Merges `incoming` TOML values onto the `existing` document, retaining the
77/// existing document's comments, whitespace, and unknown keys.
78fn 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
108/// Recursively merges `source` keys into `target`, updating values in place so
109/// surrounding comments (key decor) survive, recursing into nested tables, and
110/// preserving inline value decor (trailing comments) on updated leaves.
111///
112/// When `defaults` is `Some`, a key that is absent from `target` and whose value
113/// equals the corresponding default is skipped (minimal-config mode).
114fn 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                // New table in minimal mode: build it from non-default leaves
147                // only and skip it entirely if nothing meaningful remains.
148                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
167/// Compares a serialized item against its default, ignoring decor. Both sides
168/// originate from the same serializer, so their normalized string form matches
169/// exactly when the underlying values are equal.
170fn 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
177/// Remove stale timestamped `.bak` files left by the old backup scheme.
178/// Called once at startup to clean up the accumulated backups.
179pub 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    // `is_symlink_or_reparse`: on Windows this also rejects NTFS junctions,
260    // which `FileType::is_symlink` misses (GL#442).
261    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        // Comments survive.
307        assert!(merged.contains("# My custom config — do not delete!"));
308        assert!(merged.contains("# inline note"));
309        assert!(merged.contains("# Section about the proxy"));
310        // Unknown / user keys survive.
311        assert!(merged.contains("custom_user_key = \"keep-me\""));
312        // Values are updated.
313        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        // On-disk: only ultra_compact is explicitly set, with a comment.
321        let existing = "# my config\nultra_compact = true\n";
322        // Incoming: full serialization (all fields present).
323        let incoming = "ultra_compact = false\ncheckpoint_interval = 15\ntheme = \"default\"\n";
324        // Defaults: what an untouched config would serialize to.
325        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        // Existing key updated + comment preserved.
330        assert!(merged.contains("# my config"));
331        assert!(merged.contains("ultra_compact = false"));
332        // Default-valued keys that were never on disk are NOT added (stay minimal).
333        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        // Non-default value is written, default value is skipped.
346        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        // Everything equals default and nothing exists on disk → empty output.
359        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        // Backup created.
393        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        // #443: a corrupt config must never be silently replaced with defaults.
412        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}