Skip to main content

lean_ctx/core/
xdg_migrate.rs

1//! On-demand XDG split migration (GH #408 / GL #606).
2//!
3//! Fresh installs already land in the four typed XDG dirs (config/data/state/
4//! cache) since GL #606. Existing **legacy** (`~/.lean-ctx`) and **mixed**
5//! (`$XDG_CONFIG_HOME/lean-ctx`) installs keep resolving every category onto
6//! their single directory for backward compatibility — splitting them silently
7//! would be data-destructive. `lean-ctx doctor --fix` performs the split *on
8//! demand* by moving each entry to the directory its category resolves to.
9//!
10//! ## Why it must be all-or-nothing
11//!
12//! Resolution collapses onto the single dir only while that dir still
13//! `has_data_files` (see `crate::core::paths::single_dir_override`). Moving only
14//! *some* categories out would leave data markers behind, so every resolver
15//! would keep pointing at the source and the just-moved state/cache files would
16//! be orphaned. We therefore move **all** classifiable entries in one pass; the
17//! source stops triggering single-dir mode only once its data is gone, which is
18//! also what makes a second run a no-op (idempotent + resumable).
19//!
20//! ## Safety
21//!
22//! - Per-entry `rename` with a cross-filesystem copy+remove fallback; the source
23//!   is only removed after a successful copy, so an aborted run never loses data.
24//! - A destination that already exists is **reconciled, never clobbered** (#429):
25//!   colliding directories are merged child-by-child, a source file byte-identical
26//!   to the destination is dropped as a duplicate, and a genuinely different
27//!   source is moved aside next to the destination under a `*.legacy` name. This
28//!   is what lets the legacy dir fully empty out — the earlier "skip and leave
29//!   the source in place" behaviour meant any pre-existing target (a parallel
30//!   data dir, a half-finished earlier run) left items behind forever, so the
31//!   `doctor` warning never cleared no matter how often `--fix` ran.
32//! - An explicit `LEAN_CTX_DATA_DIR` is treated as a deliberate single-dir
33//!   choice and is **never** auto-split.
34//! - Runtime files (`daemon.pid`, sockets, lock files) are left in place; they
35//!   are ephemeral and regenerated by the daemon.
36//!
37//! Determinism (#498): the planned move set is sorted by entry name, so report
38//! bodies are a pure function of the on-disk layout.
39
40use std::path::{Path, PathBuf};
41
42/// XDG category an on-disk entry belongs to.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44enum Category {
45    Config,
46    Data,
47    State,
48    Cache,
49    /// Ephemeral runtime files — left in place (regenerated by the daemon).
50    Runtime,
51}
52
53impl Category {
54    /// Stable, human-readable label used in migration reports.
55    fn label(self) -> &'static str {
56        match self {
57            Category::Config => "config",
58            Category::Data => "data",
59            Category::State => "state",
60            Category::Cache => "cache",
61            Category::Runtime => "runtime",
62        }
63    }
64}
65
66/// Classify a top-level entry name into its XDG category.
67///
68/// The CONFIG/STATE/CACHE/RUNTIME sets are explicit and must match where the
69/// code actually reads/writes each file (see the GL #603/#604 migrations); every
70/// unlisted entry falls through to DATA, the catch-all (`sessions/`, `vectors/`,
71/// `graphs/`, `knowledge/`, `archives/`, `stats.json`, `client-id.json`, …).
72fn categorize(name: &str) -> Category {
73    match name {
74        // --- config: RO-safe ---
75        // providers/personas/multi-repo are resolved through
76        // `paths::config_dir_member`: a split must put them where they are read.
77        "config.toml" | "env.sh" | "layout.toml" | "providers.toml" | "providers" | "personas"
78        | "multi-repo.toml" => Category::Config,
79        n if n.starts_with("shell-hook.") => Category::Config,
80
81        // --- state: events, logs, journals, ledgers, dashboards ---
82        "events.jsonl"
83        | "journal.md"
84        | "tool-calls.log"
85        | "mcp-live.json"
86        | "feedback.json"
87        | "cost_attribution.json"
88        | "context_ledger.json"
89        | "ledger"
90        | "cooccurrence"
91        | "slow-commands.log"
92        | "pipeline_stats.json"
93        | "heatmap.json"
94        | "tee"
95        | "dashboard.token"
96        | "agent_runtime_env.json" => Category::State,
97
98        // --- cache: regenerable models, embeddings, learned patterns ---
99        "semantic_cache"
100        | "models"
101        | "context_ir_v1.json"
102        | "thresholds_learned.json"
103        | "litm_calibration.json"
104        | "path_mode_memory.json"
105        | "efficacy_snapshots.json"
106        | "latest-version.json"
107        | ".first_run_wow_done" => Category::Cache,
108
109        // --- runtime: ephemeral, left in place ---
110        "daemon.pid"
111        | "daemon.identity.json"
112        | "daemon.sock"
113        | "daemon-stderr.log"
114        | "dashboard.launch.json" => Category::Runtime,
115        n if n.starts_with(".graph-idx-") => Category::Runtime,
116
117        _ => Category::Data,
118    }
119}
120
121/// Resolved per-category target directories for a split.
122struct Targets {
123    config: PathBuf,
124    data: PathBuf,
125    state: PathBuf,
126    cache: PathBuf,
127}
128
129impl Targets {
130    /// Resolve targets from the environment, bypassing single-dir back-compat.
131    fn resolve() -> Result<Self, String> {
132        Ok(Self {
133            config: crate::core::paths::config_split_target()?,
134            data: crate::core::paths::data_split_target()?,
135            state: crate::core::paths::state_split_target()?,
136            cache: crate::core::paths::cache_split_target()?,
137        })
138    }
139
140    /// Target dir for a category, or `None` for runtime (left in place).
141    fn dir_for(&self, cat: Category) -> Option<&Path> {
142        match cat {
143            Category::Config => Some(&self.config),
144            Category::Data => Some(&self.data),
145            Category::State => Some(&self.state),
146            Category::Cache => Some(&self.cache),
147            Category::Runtime => None,
148        }
149    }
150}
151
152/// A single planned move computed from the source layout.
153struct PlannedMove {
154    from: PathBuf,
155    name: String,
156    category: &'static str,
157    dest_dir: PathBuf,
158    dest: PathBuf,
159}
160
161/// Outcome of a migration run, surfaced through `doctor --fix`.
162pub struct MigrationReport {
163    /// The single directory that was split.
164    pub source: PathBuf,
165    /// `(entry, category)` for every entry successfully relocated (moved or
166    /// merged into an existing destination directory).
167    pub moved: Vec<(String, &'static str)>,
168    /// Entries dropped because the destination already held a byte-identical
169    /// copy — nothing new was written; the duplicate source was removed.
170    pub skipped: Vec<String>,
171    /// Entries whose destination held *different* data: the source was preserved
172    /// next to the destination under a `*.legacy` name rather than lost (#429).
173    pub conflicts: Vec<String>,
174    /// Per-entry move failures (`entry: error`).
175    pub errors: Vec<String>,
176}
177
178impl MigrationReport {
179    fn new(source: &Path) -> Self {
180        Self {
181            source: source.to_path_buf(),
182            moved: Vec::new(),
183            skipped: Vec::new(),
184            conflicts: Vec::new(),
185            errors: Vec::new(),
186        }
187    }
188
189    /// Whether the run produced any observable effect worth reporting.
190    fn is_empty(&self) -> bool {
191        self.moved.is_empty()
192            && self.skipped.is_empty()
193            && self.conflicts.is_empty()
194            && self.errors.is_empty()
195    }
196}
197
198/// Compute the deterministic set of entries that must move to split `src` into
199/// `targets`. Entries whose category target equals `src` (e.g. config files in a
200/// mixed `$XDG_CONFIG_HOME/lean-ctx`) and runtime files stay put.
201fn entries_to_move(src: &Path, targets: &Targets) -> Vec<PlannedMove> {
202    let mut moves = Vec::new();
203    let Ok(rd) = std::fs::read_dir(src) else {
204        return moves;
205    };
206    for entry in rd.flatten() {
207        let raw_name = entry.file_name();
208        let name = raw_name.to_string_lossy().to_string();
209        let cat = categorize(&name);
210        let Some(dest_dir) = targets.dir_for(cat) else {
211            continue; // runtime → leave in place
212        };
213        if dest_dir == src {
214            continue; // already in the right place (e.g. mixed config dir)
215        }
216        moves.push(PlannedMove {
217            from: entry.path(),
218            name,
219            category: cat.label(),
220            dest_dir: dest_dir.to_path_buf(),
221            dest: dest_dir.join(&raw_name),
222        });
223    }
224    moves.sort_by(|a, b| a.name.cmp(&b.name));
225    moves
226}
227
228/// Recursively copy `from` into `to` (used as the cross-filesystem fallback when
229/// `rename` cannot move across mount points).
230fn copy_tree(from: &Path, to: &Path) -> std::io::Result<()> {
231    std::fs::create_dir_all(to)?;
232    for entry in std::fs::read_dir(from)? {
233        let entry = entry?;
234        let dst = to.join(entry.file_name());
235        if entry.file_type()?.is_dir() {
236            copy_tree(&entry.path(), &dst)?;
237        } else {
238            std::fs::copy(entry.path(), &dst)?;
239        }
240    }
241    Ok(())
242}
243
244/// Move `from` to `to`, preferring an atomic `rename` and falling back to
245/// copy+remove across filesystems. The source is removed only after the copy
246/// succeeds, so an interrupted move never loses data.
247fn move_entry(from: &Path, to: &Path) -> std::io::Result<()> {
248    if std::fs::rename(from, to).is_ok() {
249        return Ok(());
250    }
251    if from.is_dir() {
252        copy_tree(from, to)?;
253        std::fs::remove_dir_all(from)?;
254    } else {
255        std::fs::copy(from, to)?;
256        std::fs::remove_file(from)?;
257    }
258    Ok(())
259}
260
261/// How an entry whose destination already existed was reconciled (#429).
262enum Reconciled {
263    /// Source directory merged into the existing destination directory.
264    Merged,
265    /// Source was a byte-identical duplicate and was dropped.
266    Deduped,
267    /// Destination held different data; source preserved next to it as `*.legacy`.
268    Conflict,
269}
270
271/// Execute the split of `src` into `targets`. Pure with respect to its inputs
272/// (no environment access) so it can be tested hermetically.
273fn migrate_from(src: &Path, targets: &Targets) -> MigrationReport {
274    let mut report = MigrationReport::new(src);
275    for mv in entries_to_move(src, targets) {
276        if let Err(e) = std::fs::create_dir_all(&mv.dest_dir) {
277            report.errors.push(format!("{}: {e}", mv.name));
278            continue;
279        }
280        crate::core::data_dir::ensure_dir_permissions(&mv.dest_dir);
281
282        if mv.dest.exists() {
283            match reconcile_existing(&mv.from, &mv.dest) {
284                Ok(Reconciled::Merged) => report.moved.push((mv.name, mv.category)),
285                Ok(Reconciled::Deduped) => report.skipped.push(mv.name),
286                Ok(Reconciled::Conflict) => report.conflicts.push(mv.name),
287                Err(e) => report.errors.push(format!("{}: {e}", mv.name)),
288            }
289            continue;
290        }
291
292        match move_entry(&mv.from, &mv.dest) {
293            Ok(()) => report.moved.push((mv.name, mv.category)),
294            Err(e) => report.errors.push(format!("{}: {e}", mv.name)),
295        }
296    }
297    report
298}
299
300/// Reconcile a source entry whose destination already exists, without ever
301/// overwriting the destination or leaving the source behind (#429): merge
302/// directories child-by-child, drop byte-identical duplicate files, and preserve
303/// a genuinely different source next to the destination under a `*.legacy` name.
304fn reconcile_existing(from: &Path, dest: &Path) -> std::io::Result<Reconciled> {
305    if from.is_dir() && dest.is_dir() {
306        merge_dir(from, dest)?;
307        return Ok(Reconciled::Merged);
308    }
309    if from.is_file() && dest.is_file() && files_identical(from, dest)? {
310        std::fs::remove_file(from)?;
311        return Ok(Reconciled::Deduped);
312    }
313    // Genuine conflict (differing files, or a file-vs-dir type clash): keep the
314    // destination as the winner and move the source aside, so the legacy dir
315    // can still empty out. Data is preserved, just renamed.
316    let backup = backup_path(dest);
317    move_entry(from, &backup)?;
318    Ok(Reconciled::Conflict)
319}
320
321/// Recursively merge `from`'s children into the existing directory `dest`,
322/// reconciling each per-child collision, then remove `from` once it is empty.
323fn merge_dir(from: &Path, dest: &Path) -> std::io::Result<()> {
324    for entry in std::fs::read_dir(from)? {
325        let entry = entry?;
326        let child_dest = dest.join(entry.file_name());
327        if child_dest.exists() {
328            reconcile_existing(&entry.path(), &child_dest)?;
329        } else {
330            move_entry(&entry.path(), &child_dest)?;
331        }
332    }
333    // Succeeds only when every child was relocated; a leftover keeps the dir so
334    // nothing is ever silently dropped.
335    let _ = std::fs::remove_dir(from);
336    Ok(())
337}
338
339/// Byte-compare two files, cheaply short-circuiting on differing length.
340fn files_identical(a: &Path, b: &Path) -> std::io::Result<bool> {
341    let (ma, mb) = (std::fs::metadata(a)?, std::fs::metadata(b)?);
342    if ma.len() != mb.len() {
343        return Ok(false);
344    }
345    Ok(std::fs::read(a)? == std::fs::read(b)?)
346}
347
348/// First free `<dest>.legacy`, `<dest>.legacy-2`, … sibling path, so a conflicting
349/// source lives right next to the winner — clearly marked, never lost.
350fn backup_path(dest: &Path) -> PathBuf {
351    let base = dest.as_os_str().to_os_string();
352    let make = |suffix: &str| {
353        let mut s = base.clone();
354        s.push(suffix);
355        PathBuf::from(s)
356    };
357    let mut candidate = make(".legacy");
358    let mut n = 2;
359    while candidate.exists() {
360        candidate = make(&format!(".legacy-{n}"));
361        n += 1;
362    }
363    candidate
364}
365
366/// Returns the single-dir source plus the resolved split targets, or `None`
367/// when there is nothing to split: a fresh/already-split install, or an explicit
368/// `LEAN_CTX_DATA_DIR` (a deliberate single-dir choice we must not override).
369fn detect() -> Option<(PathBuf, Targets)> {
370    // Any directory override, not only the data dir: a `LEAN_CTX_CONFIG_DIR`
371    // (or state/cache) override would otherwise become a split target and pull
372    // the real install's files into it.
373    if crate::core::paths::dir_override_active() {
374        return None;
375    }
376    let src = crate::core::paths::single_dir_override()?;
377    if !src.is_dir() {
378        return None;
379    }
380    let targets = Targets::resolve().ok()?;
381    Some((src, targets))
382}
383
384/// Count entries that a split would relocate, for the read-only `doctor` report.
385/// Returns `None` when no migration applies.
386pub fn pending() -> Option<(PathBuf, usize)> {
387    let (src, targets) = detect()?;
388    let n = entries_to_move(&src, &targets).len();
389    if n == 0 {
390        return None;
391    }
392    Some((src, n))
393}
394
395/// Split a legacy/mixed single-dir install into the four XDG dirs. Returns
396/// `None` when nothing applies (fresh install, explicit `LEAN_CTX_DATA_DIR`, or
397/// already split). Drives `lean-ctx doctor --fix`.
398pub fn migrate() -> Option<MigrationReport> {
399    let (src, targets) = detect()?;
400    let report = migrate_from(&src, &targets);
401    if report.is_empty() {
402        return None;
403    }
404    Some(report)
405}
406
407/// Reclaim a residual legacy `~/.lean-ctx` directory after its data has already
408/// moved to XDG (an earlier `--fix`, or the GH #408 default flip). Unlike
409/// [`migrate`] — which only fires while the source still `has_data_files` — this
410/// drains whatever non-runtime entries linger (old `doctor/`, `setup/`,
411/// `status/` reports, stray catch-all files) into their typed XDG dirs and then
412/// removes the now-empty legacy dir, so `~/.lean-ctx` actually disappears
413/// instead of being silently re-adopted as the data dir (GH #434, #436).
414///
415/// Safety:
416/// - Operates ONLY on `~/.lean-ctx` (never a mixed `$XDG_CONFIG_HOME` source).
417/// - Skips when `LEAN_CTX_DATA_DIR` pins a single dir, or when the legacy dir is
418///   still the active data dir (unmigrated data present) — `migrate` performs the
419///   split first, and this guard prevents ever draining a live data dir.
420/// - Reuses the copy-before-remove / reconcile logic, so no data is ever lost.
421/// - Removes the dir only when it ends up empty; a surviving runtime file
422///   (e.g. a live socket) keeps it — fine, it is no longer the data dir.
423///
424/// `true` when a residual legacy `~/.lean-ctx` directory still exists on disk.
425///
426/// Diagnostics (`doctor`) use this to surface a leftover dir that [`heal`] /
427/// [`reclaim_legacy`] will drain on the next start — without reconstructing
428/// `home.join(".lean-ctx")` themselves, which the legacy-path firewall
429/// (`tests/legacy_path_firewall.rs`) forbids outside the resolver/migrator
430/// modules. The migrator owns the legacy path, so the knowledge lives here.
431///
432/// [`heal`]: crate::core::layout_pin::heal
433#[must_use]
434pub fn residual_legacy_present() -> bool {
435    dirs::home_dir().is_some_and(|h| h.join(".lean-ctx").is_dir())
436}
437
438pub fn reclaim_legacy() -> Option<MigrationReport> {
439    // Any directory override, not only the data dir (GH #2007).
440    if crate::core::paths::dir_override_active() {
441        return None;
442    }
443    let legacy = dirs::home_dir()?.join(".lean-ctx");
444    if !legacy.is_dir() {
445        return None;
446    }
447    // Never drain the directory that is still the active data dir (an unmigrated
448    // legacy install, or a migration that left markers behind).
449    if crate::core::data_dir::lean_ctx_data_dir().ok().as_deref() == Some(legacy.as_path()) {
450        return None;
451    }
452    let targets = Targets::resolve().ok()?;
453    let report = migrate_from(&legacy, &targets);
454    // Best-effort: drop the dir once it is empty.
455    let _ = std::fs::remove_dir(&legacy);
456    if report.is_empty() {
457        return None;
458    }
459    Some(report)
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    fn targets_in(root: &Path) -> Targets {
467        Targets {
468            config: root.join("config"),
469            data: root.join("data"),
470            state: root.join("state"),
471            cache: root.join("cache"),
472        }
473    }
474
475    fn touch(dir: &Path, name: &str) {
476        std::fs::create_dir_all(dir).unwrap();
477        std::fs::write(dir.join(name), b"x").unwrap();
478    }
479
480    #[test]
481    fn categorize_routes_each_category() {
482        assert_eq!(categorize("config.toml"), Category::Config);
483        assert_eq!(categorize("shell-hook.zsh"), Category::Config);
484        for member in ["providers.toml", "providers", "personas", "multi-repo.toml"] {
485            assert_eq!(categorize(member), Category::Config, "{member}");
486        }
487        assert_eq!(categorize("events.jsonl"), Category::State);
488        assert_eq!(categorize("pipeline_stats.json"), Category::State);
489        assert_eq!(categorize("semantic_cache"), Category::Cache);
490        assert_eq!(categorize("models"), Category::Cache);
491        assert_eq!(categorize(".first_run_wow_done"), Category::Cache);
492        assert_eq!(categorize("daemon.sock"), Category::Runtime);
493        assert_eq!(categorize(".graph-idx-abc.lock"), Category::Runtime);
494        // catch-all → data
495        assert_eq!(categorize("sessions"), Category::Data);
496        assert_eq!(categorize("stats.json"), Category::Data);
497        assert_eq!(categorize("client-id.json"), Category::Data);
498        assert_eq!(categorize("something-new"), Category::Data);
499    }
500
501    #[test]
502    fn mixed_config_source_splits_data_state_cache_keeps_config() {
503        let tmp = tempfile::tempdir().unwrap();
504        let root = tmp.path();
505        // Source IS the config target → config entries must stay.
506        let src = root.join("config");
507        let mut t = targets_in(root);
508        t.config = src.clone();
509
510        touch(&src, "config.toml");
511        touch(&src, "events.jsonl");
512        touch(&src, "thresholds_learned.json");
513        touch(&src, "stats.json");
514        touch(&src.join("sessions"), "s1.json");
515        touch(&src, "daemon.pid"); // runtime stays
516
517        let report = migrate_from(&src, &t);
518        assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
519
520        // config + runtime stay
521        assert!(src.join("config.toml").exists());
522        assert!(src.join("daemon.pid").exists());
523        // categories relocate
524        assert!(t.state.join("events.jsonl").exists());
525        assert!(t.cache.join("thresholds_learned.json").exists());
526        assert!(t.data.join("stats.json").exists());
527        assert!(t.data.join("sessions/s1.json").exists());
528        // originals gone
529        assert!(!src.join("events.jsonl").exists());
530        assert!(!src.join("sessions").exists());
531
532        let labels: Vec<_> = report.moved.iter().map(|(n, c)| (n.as_str(), *c)).collect();
533        assert!(labels.contains(&("events.jsonl", "state")));
534        assert!(labels.contains(&("thresholds_learned.json", "cache")));
535        assert!(labels.contains(&("sessions", "data")));
536        assert!(labels.contains(&("stats.json", "data")));
537    }
538
539    #[test]
540    fn legacy_source_moves_everything_including_config() {
541        let tmp = tempfile::tempdir().unwrap();
542        let root = tmp.path();
543        let src = root.join("legacy"); // distinct from every target
544        let t = targets_in(root);
545
546        touch(&src, "config.toml");
547        touch(&src, "events.jsonl");
548        touch(&src.join("vectors"), "v.bin");
549
550        let report = migrate_from(&src, &t);
551        assert!(report.errors.is_empty(), "{:?}", report.errors);
552        assert!(t.config.join("config.toml").exists());
553        assert!(t.state.join("events.jsonl").exists());
554        assert!(t.data.join("vectors/v.bin").exists());
555        assert!(!src.join("config.toml").exists());
556    }
557
558    #[test]
559    fn identical_dest_is_deduped_and_source_cleared() {
560        let tmp = tempfile::tempdir().unwrap();
561        let root = tmp.path();
562        let src = root.join("legacy");
563        let t = targets_in(root);
564
565        // Destination already holds a byte-identical copy.
566        touch(&src, "events.jsonl"); // content "x"
567        std::fs::create_dir_all(&t.state).unwrap();
568        std::fs::write(t.state.join("events.jsonl"), b"x").unwrap();
569
570        let report = migrate_from(&src, &t);
571        assert!(report.errors.is_empty(), "{:?}", report.errors);
572        assert!(report.moved.is_empty(), "{:?}", report.moved);
573        assert_eq!(report.skipped, vec!["events.jsonl".to_string()]);
574        assert!(report.conflicts.is_empty(), "{:?}", report.conflicts);
575        // Duplicate source dropped; destination untouched.
576        assert!(
577            !src.join("events.jsonl").exists(),
578            "duplicate source dropped"
579        );
580        assert_eq!(
581            std::fs::read_to_string(t.state.join("events.jsonl")).unwrap(),
582            "x"
583        );
584        // Legacy dir is now empty → a re-scan plans nothing (warning clears).
585        assert!(entries_to_move(&src, &t).is_empty());
586    }
587
588    #[test]
589    fn conflicting_dest_backs_up_source_and_clears_legacy() {
590        let tmp = tempfile::tempdir().unwrap();
591        let root = tmp.path();
592        let src = root.join("legacy");
593        let t = targets_in(root);
594
595        touch(&src, "events.jsonl"); // content "x"
596        std::fs::create_dir_all(&t.state).unwrap();
597        std::fs::write(t.state.join("events.jsonl"), b"keep").unwrap(); // differs
598
599        let report = migrate_from(&src, &t);
600        assert!(report.errors.is_empty(), "{:?}", report.errors);
601        assert_eq!(report.conflicts, vec!["events.jsonl".to_string()]);
602        // Winner preserved, source moved aside as *.legacy, legacy dir emptied.
603        assert_eq!(
604            std::fs::read_to_string(t.state.join("events.jsonl")).unwrap(),
605            "keep",
606            "existing destination must not be overwritten"
607        );
608        assert_eq!(
609            std::fs::read_to_string(t.state.join("events.jsonl.legacy")).unwrap(),
610            "x",
611            "different source preserved next to the winner"
612        );
613        assert!(!src.join("events.jsonl").exists());
614        assert!(
615            entries_to_move(&src, &t).is_empty(),
616            "warning clears once the source is reconciled"
617        );
618    }
619
620    /// #429: a destination directory that already holds *some* content (a parallel
621    /// data dir, a half-finished earlier run) must be merged, not skipped —
622    /// otherwise the legacy entries linger and `doctor` warns forever no matter
623    /// how often `--fix` runs. After the merge the legacy source is fully empty.
624    #[test]
625    fn dir_collision_merges_and_empties_legacy_429() {
626        let tmp = tempfile::tempdir().unwrap();
627        let root = tmp.path();
628        let src = root.join("legacy");
629        let t = targets_in(root);
630
631        // Source sessions/ has a new file and a duplicate; dest sessions/ exists
632        // already with its own file plus the same duplicate.
633        touch(&src.join("sessions"), "old.json"); // only in source
634        std::fs::write(src.join("sessions").join("dup.json"), b"same").unwrap();
635        touch(&t.data.join("sessions"), "existing.json"); // only in dest
636        std::fs::write(t.data.join("sessions").join("dup.json"), b"same").unwrap();
637
638        let report = migrate_from(&src, &t);
639        assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
640
641        // Merged: destination keeps its own entry and gains the source-only one.
642        assert!(t.data.join("sessions/existing.json").exists());
643        assert!(t.data.join("sessions/old.json").exists());
644        assert!(t.data.join("sessions/dup.json").exists());
645        // Source dir fully removed → legacy no longer triggers single-dir mode.
646        assert!(!src.join("sessions").exists(), "merged source dir removed");
647        assert!(
648            entries_to_move(&src, &t).is_empty(),
649            "#429: nothing left to migrate after a merge"
650        );
651    }
652
653    #[test]
654    fn entries_to_move_is_sorted_for_determinism() {
655        let tmp = tempfile::tempdir().unwrap();
656        let root = tmp.path();
657        let src = root.join("legacy");
658        let t = targets_in(root);
659        touch(&src, "events.jsonl");
660        touch(&src, "config.toml");
661        touch(&src, "thresholds_learned.json");
662        let names: Vec<_> = entries_to_move(&src, &t)
663            .into_iter()
664            .map(|m| m.name)
665            .collect();
666        let mut sorted = names.clone();
667        sorted.sort();
668        assert_eq!(names, sorted);
669    }
670
671    /// Saves a set of env vars and restores them on drop (panic-safe) so an
672    /// env-driven test can never leak `HOME`/`XDG_*` into other tests.
673    ///
674    /// Only the `#[cfg(unix)]` end-to-end tests below construct this, so the
675    /// helper is unix-gated too — otherwise it is dead code on Windows where
676    /// `-D warnings` would fail the build.
677    #[cfg(unix)]
678    struct EnvVars(Vec<(&'static str, Option<std::ffi::OsString>)>);
679
680    #[cfg(unix)]
681    impl EnvVars {
682        fn apply(pairs: &[(&'static str, Option<&Path>)]) -> Self {
683            let saved = pairs
684                .iter()
685                .map(|(k, _)| (*k, std::env::var_os(k)))
686                .collect();
687            for (k, v) in pairs {
688                match v {
689                    Some(p) => crate::test_env::set_var(k, p),
690                    None => crate::test_env::remove_var(k),
691                }
692            }
693            EnvVars(saved)
694        }
695    }
696
697    #[cfg(unix)]
698    impl Drop for EnvVars {
699        fn drop(&mut self) {
700            for (k, v) in &self.0 {
701                match v {
702                    Some(val) => crate::test_env::set_var(k, val),
703                    None => crate::test_env::remove_var(k),
704                }
705            }
706        }
707    }
708
709    // End-to-end through the real env-detection + split-target wiring (the unit
710    // tests above drive `migrate_from` with explicit dirs). Proves a mixed
711    // `$XDG_CONFIG_HOME/lean-ctx` install splits into the four XDG homes and that
712    // a second run is a no-op once the data markers are gone.
713    #[cfg(unix)]
714    #[test]
715    fn migrate_end_to_end_splits_mixed_xdg_config_install() {
716        let _g = crate::core::data_dir::test_env_lock();
717        let tmp = tempfile::tempdir().unwrap();
718        let root = tmp.path();
719        let home = root.join("home");
720        let xc = root.join("xc");
721        let xd = root.join("xd");
722        let xs = root.join("xs");
723        let xk = root.join("xk");
724        std::fs::create_dir_all(&home).unwrap();
725
726        let _env = EnvVars::apply(&[
727            ("HOME", Some(home.as_path())),
728            ("XDG_CONFIG_HOME", Some(xc.as_path())),
729            ("XDG_DATA_HOME", Some(xd.as_path())),
730            ("XDG_STATE_HOME", Some(xs.as_path())),
731            ("XDG_CACHE_HOME", Some(xk.as_path())),
732            ("LEAN_CTX_DATA_DIR", None),
733            ("LEAN_CTX_CONFIG_DIR", None),
734            ("LEAN_CTX_STATE_DIR", None),
735            ("LEAN_CTX_CACHE_DIR", None),
736        ]);
737
738        // Mixed install: config + every category mixed under $XDG_CONFIG_HOME.
739        let mixed = xc.join("lean-ctx");
740        touch(&mixed, "config.toml");
741        touch(&mixed, "events.jsonl");
742        touch(&mixed, "thresholds_learned.json");
743        touch(&mixed, "stats.json");
744        touch(&mixed.join("sessions"), "s.json");
745
746        let report = migrate().expect("mixed install must migrate");
747        assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
748
749        assert!(mixed.join("config.toml").exists(), "config stays in place");
750        assert!(
751            xs.join("lean-ctx/events.jsonl").exists(),
752            "state → XDG_STATE"
753        );
754        assert!(
755            xk.join("lean-ctx/thresholds_learned.json").exists(),
756            "cache → XDG_CACHE"
757        );
758        assert!(xd.join("lean-ctx/stats.json").exists(), "data → XDG_DATA");
759        assert!(
760            xd.join("lean-ctx/sessions/s.json").exists(),
761            "data subdir → XDG_DATA"
762        );
763        assert!(
764            !mixed.join("events.jsonl").exists(),
765            "moved source file removed"
766        );
767
768        assert!(migrate().is_none(), "second run is a no-op (idempotent)");
769    }
770
771    // An explicit `LEAN_CTX_DATA_DIR` is a deliberate single-dir choice and must
772    // never be auto-split, even when the dir clearly mixes categories.
773    #[cfg(unix)]
774    #[test]
775    fn migrate_respects_explicit_data_dir_override() {
776        let _g = crate::core::data_dir::test_env_lock();
777        let tmp = tempfile::tempdir().unwrap();
778        let single = tmp.path().join("single");
779        touch(&single, "stats.json");
780        touch(&single, "events.jsonl");
781
782        let _env = EnvVars::apply(&[("LEAN_CTX_DATA_DIR", Some(single.as_path()))]);
783        assert!(
784            migrate().is_none(),
785            "explicit LEAN_CTX_DATA_DIR must not be split"
786        );
787        assert!(single.join("events.jsonl").exists(), "nothing moved");
788    }
789
790    // #434/#436: a residual `~/.lean-ctx` left behind after the data already
791    // moved to XDG (only a stale `doctor/` report, no data markers) must be
792    // drained into the typed XDG dirs and the empty dir removed, so it stops
793    // being re-adopted as the data dir.
794    #[cfg(unix)]
795    #[test]
796    fn reclaim_legacy_drains_and_removes_residual_dir() {
797        let _g = crate::core::data_dir::test_env_lock();
798        let tmp = tempfile::tempdir().unwrap();
799        let root = tmp.path();
800        let home = root.join("home");
801        let xc = root.join("xc");
802        let xd = root.join("xd");
803        let xs = root.join("xs");
804        let xk = root.join("xk");
805        std::fs::create_dir_all(&home).unwrap();
806        let legacy = home.join(".lean-ctx");
807        // Residual leftover: a doctor report (catch-all → data), NO data markers.
808        touch(&legacy.join("doctor"), "latest.json");
809
810        let _env = EnvVars::apply(&[
811            ("HOME", Some(home.as_path())),
812            ("XDG_CONFIG_HOME", Some(xc.as_path())),
813            ("XDG_DATA_HOME", Some(xd.as_path())),
814            ("XDG_STATE_HOME", Some(xs.as_path())),
815            ("XDG_CACHE_HOME", Some(xk.as_path())),
816            ("LEAN_CTX_DATA_DIR", None),
817            ("LEAN_CTX_CONFIG_DIR", None),
818            ("LEAN_CTX_STATE_DIR", None),
819            ("LEAN_CTX_CACHE_DIR", None),
820        ]);
821
822        let report = reclaim_legacy().expect("residual legacy must be reclaimed");
823        assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
824        assert!(
825            xd.join("lean-ctx/doctor/latest.json").exists(),
826            "report drained into XDG data"
827        );
828        assert!(!legacy.exists(), "empty legacy dir removed");
829        assert!(reclaim_legacy().is_none(), "second run is a no-op");
830    }
831}