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            merge_toml_inner(&existing, new_content, Some(default_content))
58                .unwrap_or_else(|_| new_content.to_string())
59        }
60        // No existing file: write a fresh minimal document (drop defaults).
61        _ => 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
67/// Merges `incoming` TOML values onto the `existing` document, retaining the
68/// existing document's comments, whitespace, and unknown keys.
69fn 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
99/// Recursively merges `source` keys into `target`, updating values in place so
100/// surrounding comments (key decor) survive, recursing into nested tables, and
101/// preserving inline value decor (trailing comments) on updated leaves.
102///
103/// When `defaults` is `Some`, a key that is absent from `target` and whose value
104/// equals the corresponding default is skipped (minimal-config mode).
105fn 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                // New table in minimal mode: build it from non-default leaves
138                // only and skip it entirely if nothing meaningful remains.
139                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
158/// Compares a serialized item against its default, ignoring decor. Both sides
159/// originate from the same serializer, so their normalized string form matches
160/// exactly when the underlying values are equal.
161fn 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
168/// Remove stale timestamped `.bak` files left by the old backup scheme.
169/// Called once at startup to clean up the accumulated backups.
170pub 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    // `is_symlink_or_reparse`: on Windows this also rejects NTFS junctions,
251    // which `FileType::is_symlink` misses (GL#442).
252    if path.exists()
253        && path
254            .symlink_metadata()
255            .is_ok_and(|m| crate::core::pathutil::is_symlink_or_reparse(&m))
256    {
257        return Err(format!(
258            "refusing to write through symlink: {}",
259            path.display()
260        ));
261    }
262    Ok(())
263}
264
265#[cfg(unix)]
266fn restrict_file_permissions(path: &Path) {
267    use std::os::unix::fs::PermissionsExt;
268    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
269}
270
271#[cfg(not(unix))]
272fn restrict_file_permissions(_path: &Path) {}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn merge_preserves_comments_and_unknown_keys() {
280        let existing = "\
281# My custom config — do not delete!
282ultra_compact = true  # inline note
283
284# Section about the proxy
285[proxy]
286enabled = false
287custom_user_key = \"keep-me\"
288";
289        let incoming = "\
290ultra_compact = false
291
292[proxy]
293enabled = true
294";
295        let merged = merge_toml(existing, incoming).unwrap();
296
297        // Comments survive.
298        assert!(merged.contains("# My custom config — do not delete!"));
299        assert!(merged.contains("# inline note"));
300        assert!(merged.contains("# Section about the proxy"));
301        // Unknown / user keys survive.
302        assert!(merged.contains("custom_user_key = \"keep-me\""));
303        // Values are updated.
304        assert!(merged.contains("ultra_compact = false"));
305        assert!(merged.contains("enabled = true"));
306        assert!(!merged.contains("enabled = false"));
307    }
308
309    #[test]
310    fn minimal_mode_skips_unset_defaults_but_keeps_existing() {
311        // On-disk: only ultra_compact is explicitly set, with a comment.
312        let existing = "# my config\nultra_compact = true\n";
313        // Incoming: full serialization (all fields present).
314        let incoming = "ultra_compact = false\ncheckpoint_interval = 15\ntheme = \"default\"\n";
315        // Defaults: what an untouched config would serialize to.
316        let defaults = "ultra_compact = false\ncheckpoint_interval = 15\ntheme = \"default\"\n";
317
318        let merged = merge_toml_inner(existing, incoming, Some(defaults)).unwrap();
319
320        // Existing key updated + comment preserved.
321        assert!(merged.contains("# my config"));
322        assert!(merged.contains("ultra_compact = false"));
323        // Default-valued keys that were never on disk are NOT added (stay minimal).
324        assert!(!merged.contains("checkpoint_interval"));
325        assert!(!merged.contains("theme"));
326    }
327
328    #[test]
329    fn minimal_mode_writes_non_default_values() {
330        let existing = "";
331        let incoming = "ultra_compact = false\ncheckpoint_interval = 42\n";
332        let defaults = "ultra_compact = false\ncheckpoint_interval = 15\n";
333
334        let merged = merge_toml_inner(existing, incoming, Some(defaults)).unwrap();
335
336        // Non-default value is written, default value is skipped.
337        assert!(merged.contains("checkpoint_interval = 42"));
338        assert!(!merged.contains("ultra_compact"));
339    }
340
341    #[test]
342    fn minimal_mode_drops_empty_default_tables() {
343        let existing = "";
344        let incoming = "[proxy]\nenabled = false\n\n[lsp]\n";
345        let defaults = "[proxy]\nenabled = false\n\n[lsp]\n";
346
347        let merged = merge_toml_inner(existing, incoming, Some(defaults)).unwrap();
348
349        // Everything equals default and nothing exists on disk → empty output.
350        assert!(!merged.contains("[lsp]"));
351        assert!(!merged.contains("[proxy]"));
352    }
353
354    #[test]
355    fn merge_adds_new_keys_and_sections() {
356        let existing = "ultra_compact = true\n";
357        let incoming = "ultra_compact = true\nnew_key = 42\n\n[updates]\nauto_update = true\n";
358        let merged = merge_toml(existing, incoming).unwrap();
359        assert!(merged.contains("new_key = 42"));
360        assert!(merged.contains("[updates]"));
361        assert!(merged.contains("auto_update = true"));
362    }
363
364    fn unique_tmp(tag: &str) -> std::path::PathBuf {
365        let nanos = std::time::SystemTime::now()
366            .duration_since(std::time::UNIX_EPOCH)
367            .map_or(0, |d| d.as_nanos());
368        std::env::temp_dir().join(format!("lc_{tag}_{}_{nanos}", std::process::id()))
369    }
370
371    #[test]
372    fn write_toml_preserving_backs_up_and_keeps_comments() {
373        let tmp = unique_tmp("cfg_test");
374        let _ = std::fs::create_dir_all(&tmp);
375        let path = tmp.join("config.toml");
376        std::fs::write(&path, "# keep\nultra_compact = true\n").unwrap();
377
378        write_toml_preserving(&path, "ultra_compact = false\n").unwrap();
379
380        let result = std::fs::read_to_string(&path).unwrap();
381        assert!(result.contains("# keep"));
382        assert!(result.contains("ultra_compact = false"));
383        // Backup created.
384        assert!(path.with_file_name("config.toml.bak").exists());
385
386        let _ = std::fs::remove_dir_all(&tmp);
387    }
388
389    #[test]
390    fn write_toml_preserving_handles_missing_file() {
391        let tmp = unique_tmp("cfg_new");
392        let _ = std::fs::remove_dir_all(&tmp);
393        let path = tmp.join("config.toml");
394        write_toml_preserving(&path, "ultra_compact = true\n").unwrap();
395        let result = std::fs::read_to_string(&path).unwrap();
396        assert!(result.contains("ultra_compact = true"));
397        let _ = std::fs::remove_dir_all(&tmp);
398    }
399}