Skip to main content

lean_ctx/core/
data_consolidate.rs

1//! Consolidate a split data layout into one canonical directory (GH #414).
2//!
3//! Some installs ended up with data in **two** trees at once — e.g. a legacy
4//! `~/.lean-ctx` *and* a `$XDG_CONFIG_HOME/lean-ctx` (or `$XDG_DATA_HOME`) — so
5//! the resolver picks one as canonical and silently orphans the other. The old
6//! `migrate_if_split` only handled the "canonical has no stats yet" case and
7//! bailed the instant **both** trees held a `stats.json` (exactly the reported
8//! situation), so `doctor` kept flagging "stats.json found in 2 locations" with
9//! no way to fix it.
10//!
11//! This module merges every non-canonical tree **into** the canonical one
12//! (newer file wins, the newer copy is never lost), emptying and removing the
13//! source afterwards so it stops triggering split-brain. The subsequent
14//! [`crate::core::xdg_migrate`] pass then performs the normal single→XDG split.
15//!
16//! An explicit `LEAN_CTX_DATA_DIR` is a deliberate single-dir choice and is
17//! never touched.
18
19use std::path::{Path, PathBuf};
20
21/// Outcome of a consolidation pass, surfaced through `doctor --fix`.
22#[derive(Debug, Default)]
23pub struct ConsolidationReport {
24    /// The canonical directory everything was merged into.
25    pub canonical: PathBuf,
26    /// Source dirs that were merged and removed.
27    pub merged_from: Vec<PathBuf>,
28    /// Files relocated into the canonical dir.
29    pub files_moved: usize,
30    /// Files dropped because the canonical copy was newer-or-equal.
31    pub files_superseded: usize,
32    /// Per-entry failures (`path: error`).
33    pub errors: Vec<String>,
34}
35
36impl ConsolidationReport {
37    fn changed(&self) -> bool {
38        self.files_moved > 0 || self.files_superseded > 0 || !self.merged_from.is_empty()
39    }
40}
41
42/// Merge all non-canonical data dirs (those holding a `stats.json`) into the
43/// canonical [`crate::core::data_dir::lean_ctx_data_dir`]. Returns `None` when
44/// there is nothing to do (single tree, or an explicit `LEAN_CTX_DATA_DIR` pin).
45pub fn consolidate() -> Option<ConsolidationReport> {
46    // An explicit data-dir pin is a deliberate single-dir choice — don't merge.
47    if std::env::var_os("LEAN_CTX_DATA_DIR").is_some() {
48        return None;
49    }
50    let canonical = crate::core::data_dir::lean_ctx_data_dir().ok()?;
51    let sources: Vec<PathBuf> = crate::core::data_dir::all_data_dirs_with_stats()
52        .into_iter()
53        .filter(|d| *d != canonical)
54        .collect();
55    if sources.is_empty() {
56        return None;
57    }
58    let report = consolidate_into(&canonical, &sources);
59    report.changed().then_some(report)
60}
61
62/// Pure core of [`consolidate`]: merge each `source` tree into `canonical`.
63/// Hermetic (no environment access) so it can be unit-tested with explicit dirs.
64fn consolidate_into(canonical: &Path, sources: &[PathBuf]) -> ConsolidationReport {
65    let mut report = ConsolidationReport {
66        canonical: canonical.to_path_buf(),
67        ..Default::default()
68    };
69    if let Err(e) = std::fs::create_dir_all(canonical) {
70        report.errors.push(format!("{}: {e}", canonical.display()));
71        return report;
72    }
73    crate::core::data_dir::ensure_dir_permissions(canonical);
74
75    for src in sources {
76        if src == canonical || !src.is_dir() {
77            continue;
78        }
79        merge_dir(src, canonical, &mut report);
80        // The source is empty once every entry has been merged out; drop it so it
81        // no longer holds data markers and stops resolving as a second tree.
82        let _ = std::fs::remove_dir(src);
83        report.merged_from.push(src.clone());
84    }
85    report
86}
87
88/// Recursively merge `src` into `dst`, moving files and recursing into dirs.
89fn merge_dir(src: &Path, dst: &Path, report: &mut ConsolidationReport) {
90    let Ok(rd) = std::fs::read_dir(src) else {
91        report
92            .errors
93            .push(format!("{}: cannot read", src.display()));
94        return;
95    };
96    for entry in rd.flatten() {
97        let from = entry.path();
98        let to = dst.join(entry.file_name());
99        let is_dir = entry.file_type().is_ok_and(|t| t.is_dir());
100        if is_dir {
101            if let Err(e) = std::fs::create_dir_all(&to) {
102                report.errors.push(format!("{}: {e}", to.display()));
103                continue;
104            }
105            merge_dir(&from, &to, report);
106            let _ = std::fs::remove_dir(&from); // remove once emptied
107        } else {
108            merge_file(&from, &to, report);
109        }
110    }
111}
112
113/// Move `from` onto `to` when the destination is absent or older; otherwise drop
114/// the stale duplicate. Guarantees the newer copy is the one that survives.
115fn merge_file(from: &Path, to: &Path, report: &mut ConsolidationReport) {
116    if to.exists() && !source_is_newer(from, to) {
117        let _ = std::fs::remove_file(from);
118        report.files_superseded += 1;
119        return;
120    }
121    match move_overwrite(from, to) {
122        Ok(()) => report.files_moved += 1,
123        Err(e) => report.errors.push(format!("{}: {e}", from.display())),
124    }
125}
126
127/// True when `from` has a strictly newer mtime than `to`. Unreadable mtimes are
128/// treated as not-newer so a canonical file is never clobbered on uncertainty.
129fn source_is_newer(from: &Path, to: &Path) -> bool {
130    let mtime = |p: &Path| std::fs::metadata(p).and_then(|m| m.modified()).ok();
131    match (mtime(from), mtime(to)) {
132        (Some(a), Some(b)) => a > b,
133        _ => false,
134    }
135}
136
137/// Move `from` onto `to`, replacing any existing file. Atomic `rename` first,
138/// with a copy+remove fallback across filesystems; the source is only removed
139/// after the copy succeeds, so an interrupted move never loses data.
140fn move_overwrite(from: &Path, to: &Path) -> std::io::Result<()> {
141    if std::fs::rename(from, to).is_ok() {
142        return Ok(());
143    }
144    std::fs::copy(from, to)?;
145    std::fs::remove_file(from)?;
146    Ok(())
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use filetime::{FileTime, set_file_mtime};
153
154    fn write(path: &Path, body: &str) {
155        if let Some(parent) = path.parent() {
156            std::fs::create_dir_all(parent).unwrap();
157        }
158        std::fs::write(path, body).unwrap();
159    }
160
161    fn set_mtime(path: &Path, secs: i64) {
162        set_file_mtime(path, FileTime::from_unix_time(secs, 0)).unwrap();
163    }
164
165    #[test]
166    fn moves_orphan_files_into_canonical() {
167        let tmp = tempfile::tempdir().unwrap();
168        let canonical = tmp.path().join("canonical");
169        let orphan = tmp.path().join("orphan");
170        std::fs::create_dir_all(&canonical).unwrap();
171        write(&orphan.join("stats.json"), r#"{"total_commands":3}"#);
172        write(&orphan.join("sessions").join("s1.json"), "{}");
173
174        let report = consolidate_into(&canonical, std::slice::from_ref(&orphan));
175
176        assert_eq!(report.files_moved, 2);
177        assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
178        assert!(canonical.join("stats.json").exists());
179        assert!(canonical.join("sessions/s1.json").exists());
180        // The emptied source tree is removed so it stops resolving as a 2nd dir.
181        assert!(!orphan.exists(), "merged source dir must be removed");
182        assert_eq!(report.merged_from, vec![orphan]);
183    }
184
185    #[test]
186    fn newer_source_wins_older_canonical_kept() {
187        let tmp = tempfile::tempdir().unwrap();
188        let canonical = tmp.path().join("canonical");
189        let orphan = tmp.path().join("orphan");
190
191        // `stats.json`: source is newer → must overwrite canonical.
192        write(&canonical.join("stats.json"), "OLD");
193        set_mtime(&canonical.join("stats.json"), 1_000);
194        write(&orphan.join("stats.json"), "NEW");
195        set_mtime(&orphan.join("stats.json"), 2_000);
196
197        // `client-id.json`: canonical is newer → source dropped, canonical kept.
198        write(&canonical.join("client-id.json"), "KEEP");
199        set_mtime(&canonical.join("client-id.json"), 5_000);
200        write(&orphan.join("client-id.json"), "STALE");
201        set_mtime(&orphan.join("client-id.json"), 1_000);
202
203        let report = consolidate_into(&canonical, std::slice::from_ref(&orphan));
204
205        assert_eq!(
206            std::fs::read_to_string(canonical.join("stats.json")).unwrap(),
207            "NEW",
208            "newer source must win"
209        );
210        assert_eq!(
211            std::fs::read_to_string(canonical.join("client-id.json")).unwrap(),
212            "KEEP",
213            "newer canonical must be preserved"
214        );
215        assert_eq!(report.files_moved, 1);
216        assert_eq!(report.files_superseded, 1);
217        assert!(!orphan.exists());
218    }
219
220    #[test]
221    fn merges_nested_dirs_without_clobbering_existing() {
222        let tmp = tempfile::tempdir().unwrap();
223        let canonical = tmp.path().join("canonical");
224        let orphan = tmp.path().join("orphan");
225
226        write(&canonical.join("vectors").join("a.bin"), "a");
227        write(&orphan.join("vectors").join("b.bin"), "b");
228
229        let report = consolidate_into(&canonical, std::slice::from_ref(&orphan));
230
231        assert!(canonical.join("vectors/a.bin").exists(), "existing kept");
232        assert!(canonical.join("vectors/b.bin").exists(), "new merged in");
233        assert_eq!(report.files_moved, 1);
234        assert!(!orphan.exists());
235    }
236
237    #[test]
238    fn no_sources_is_noop() {
239        let tmp = tempfile::tempdir().unwrap();
240        let canonical = tmp.path().join("canonical");
241        std::fs::create_dir_all(&canonical).unwrap();
242        let report = consolidate_into(&canonical, &[]);
243        assert!(!report.changed());
244    }
245}