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        "config.toml" | "env.sh" => Category::Config,
76        n if n.starts_with("shell-hook.") => Category::Config,
77
78        // --- state: events, logs, journals, ledgers, dashboards ---
79        "events.jsonl"
80        | "journal.md"
81        | "tool-calls.log"
82        | "mcp-live.json"
83        | "feedback.json"
84        | "cost_attribution.json"
85        | "context_ledger.json"
86        | "ledger"
87        | "cooccurrence"
88        | "slow-commands.log"
89        | "pipeline_stats.json"
90        | "heatmap.json"
91        | "tee"
92        | "dashboard.token"
93        | "agent_runtime_env.json" => Category::State,
94
95        // --- cache: regenerable models, embeddings, learned patterns ---
96        "semantic_cache"
97        | "models"
98        | "anomaly_detector.json"
99        | "autonomy_drivers_v1.json"
100        | "context_ir_v1.json"
101        | "thresholds_learned.json"
102        | "litm_calibration.json"
103        | "path_mode_memory.json"
104        | "efficacy_snapshots.json"
105        | "latest-version.json"
106        | ".first_run_wow_done" => Category::Cache,
107
108        // --- runtime: ephemeral, left in place ---
109        "daemon.pid" | "daemon.sock" | "daemon-stderr.log" => Category::Runtime,
110        n if n.starts_with(".graph-idx-") => Category::Runtime,
111
112        _ => Category::Data,
113    }
114}
115
116/// Resolved per-category target directories for a split.
117struct Targets {
118    config: PathBuf,
119    data: PathBuf,
120    state: PathBuf,
121    cache: PathBuf,
122}
123
124impl Targets {
125    /// Resolve targets from the environment, bypassing single-dir back-compat.
126    fn resolve() -> Result<Self, String> {
127        Ok(Self {
128            config: crate::core::paths::config_split_target()?,
129            data: crate::core::paths::data_split_target()?,
130            state: crate::core::paths::state_split_target()?,
131            cache: crate::core::paths::cache_split_target()?,
132        })
133    }
134
135    /// Target dir for a category, or `None` for runtime (left in place).
136    fn dir_for(&self, cat: Category) -> Option<&Path> {
137        match cat {
138            Category::Config => Some(&self.config),
139            Category::Data => Some(&self.data),
140            Category::State => Some(&self.state),
141            Category::Cache => Some(&self.cache),
142            Category::Runtime => None,
143        }
144    }
145}
146
147/// A single planned move computed from the source layout.
148struct PlannedMove {
149    from: PathBuf,
150    name: String,
151    category: &'static str,
152    dest_dir: PathBuf,
153    dest: PathBuf,
154}
155
156/// Outcome of a migration run, surfaced through `doctor --fix`.
157pub struct MigrationReport {
158    /// The single directory that was split.
159    pub source: PathBuf,
160    /// `(entry, category)` for every entry successfully relocated (moved or
161    /// merged into an existing destination directory).
162    pub moved: Vec<(String, &'static str)>,
163    /// Entries dropped because the destination already held a byte-identical
164    /// copy — nothing new was written; the duplicate source was removed.
165    pub skipped: Vec<String>,
166    /// Entries whose destination held *different* data: the source was preserved
167    /// next to the destination under a `*.legacy` name rather than lost (#429).
168    pub conflicts: Vec<String>,
169    /// Per-entry move failures (`entry: error`).
170    pub errors: Vec<String>,
171}
172
173impl MigrationReport {
174    fn new(source: &Path) -> Self {
175        Self {
176            source: source.to_path_buf(),
177            moved: Vec::new(),
178            skipped: Vec::new(),
179            conflicts: Vec::new(),
180            errors: Vec::new(),
181        }
182    }
183
184    /// Whether the run produced any observable effect worth reporting.
185    fn is_empty(&self) -> bool {
186        self.moved.is_empty()
187            && self.skipped.is_empty()
188            && self.conflicts.is_empty()
189            && self.errors.is_empty()
190    }
191}
192
193/// Compute the deterministic set of entries that must move to split `src` into
194/// `targets`. Entries whose category target equals `src` (e.g. config files in a
195/// mixed `$XDG_CONFIG_HOME/lean-ctx`) and runtime files stay put.
196fn entries_to_move(src: &Path, targets: &Targets) -> Vec<PlannedMove> {
197    let mut moves = Vec::new();
198    let Ok(rd) = std::fs::read_dir(src) else {
199        return moves;
200    };
201    for entry in rd.flatten() {
202        let raw_name = entry.file_name();
203        let name = raw_name.to_string_lossy().to_string();
204        let cat = categorize(&name);
205        let Some(dest_dir) = targets.dir_for(cat) else {
206            continue; // runtime → leave in place
207        };
208        if dest_dir == src {
209            continue; // already in the right place (e.g. mixed config dir)
210        }
211        moves.push(PlannedMove {
212            from: entry.path(),
213            name,
214            category: cat.label(),
215            dest_dir: dest_dir.to_path_buf(),
216            dest: dest_dir.join(&raw_name),
217        });
218    }
219    moves.sort_by(|a, b| a.name.cmp(&b.name));
220    moves
221}
222
223/// Recursively copy `from` into `to` (used as the cross-filesystem fallback when
224/// `rename` cannot move across mount points).
225fn copy_tree(from: &Path, to: &Path) -> std::io::Result<()> {
226    std::fs::create_dir_all(to)?;
227    for entry in std::fs::read_dir(from)? {
228        let entry = entry?;
229        let dst = to.join(entry.file_name());
230        if entry.file_type()?.is_dir() {
231            copy_tree(&entry.path(), &dst)?;
232        } else {
233            std::fs::copy(entry.path(), &dst)?;
234        }
235    }
236    Ok(())
237}
238
239/// Move `from` to `to`, preferring an atomic `rename` and falling back to
240/// copy+remove across filesystems. The source is removed only after the copy
241/// succeeds, so an interrupted move never loses data.
242fn move_entry(from: &Path, to: &Path) -> std::io::Result<()> {
243    if std::fs::rename(from, to).is_ok() {
244        return Ok(());
245    }
246    if from.is_dir() {
247        copy_tree(from, to)?;
248        std::fs::remove_dir_all(from)?;
249    } else {
250        std::fs::copy(from, to)?;
251        std::fs::remove_file(from)?;
252    }
253    Ok(())
254}
255
256/// How an entry whose destination already existed was reconciled (#429).
257enum Reconciled {
258    /// Source directory merged into the existing destination directory.
259    Merged,
260    /// Source was a byte-identical duplicate and was dropped.
261    Deduped,
262    /// Destination held different data; source preserved next to it as `*.legacy`.
263    Conflict,
264}
265
266/// Execute the split of `src` into `targets`. Pure with respect to its inputs
267/// (no environment access) so it can be tested hermetically.
268fn migrate_from(src: &Path, targets: &Targets) -> MigrationReport {
269    let mut report = MigrationReport::new(src);
270    for mv in entries_to_move(src, targets) {
271        if let Err(e) = std::fs::create_dir_all(&mv.dest_dir) {
272            report.errors.push(format!("{}: {e}", mv.name));
273            continue;
274        }
275        crate::core::data_dir::ensure_dir_permissions(&mv.dest_dir);
276
277        if mv.dest.exists() {
278            match reconcile_existing(&mv.from, &mv.dest) {
279                Ok(Reconciled::Merged) => report.moved.push((mv.name, mv.category)),
280                Ok(Reconciled::Deduped) => report.skipped.push(mv.name),
281                Ok(Reconciled::Conflict) => report.conflicts.push(mv.name),
282                Err(e) => report.errors.push(format!("{}: {e}", mv.name)),
283            }
284            continue;
285        }
286
287        match move_entry(&mv.from, &mv.dest) {
288            Ok(()) => report.moved.push((mv.name, mv.category)),
289            Err(e) => report.errors.push(format!("{}: {e}", mv.name)),
290        }
291    }
292    report
293}
294
295/// Reconcile a source entry whose destination already exists, without ever
296/// overwriting the destination or leaving the source behind (#429): merge
297/// directories child-by-child, drop byte-identical duplicate files, and preserve
298/// a genuinely different source next to the destination under a `*.legacy` name.
299fn reconcile_existing(from: &Path, dest: &Path) -> std::io::Result<Reconciled> {
300    if from.is_dir() && dest.is_dir() {
301        merge_dir(from, dest)?;
302        return Ok(Reconciled::Merged);
303    }
304    if from.is_file() && dest.is_file() && files_identical(from, dest)? {
305        std::fs::remove_file(from)?;
306        return Ok(Reconciled::Deduped);
307    }
308    // Genuine conflict (differing files, or a file-vs-dir type clash): keep the
309    // destination as the winner and move the source aside, so the legacy dir
310    // can still empty out. Data is preserved, just renamed.
311    let backup = backup_path(dest);
312    move_entry(from, &backup)?;
313    Ok(Reconciled::Conflict)
314}
315
316/// Recursively merge `from`'s children into the existing directory `dest`,
317/// reconciling each per-child collision, then remove `from` once it is empty.
318fn merge_dir(from: &Path, dest: &Path) -> std::io::Result<()> {
319    for entry in std::fs::read_dir(from)? {
320        let entry = entry?;
321        let child_dest = dest.join(entry.file_name());
322        if child_dest.exists() {
323            reconcile_existing(&entry.path(), &child_dest)?;
324        } else {
325            move_entry(&entry.path(), &child_dest)?;
326        }
327    }
328    // Succeeds only when every child was relocated; a leftover keeps the dir so
329    // nothing is ever silently dropped.
330    let _ = std::fs::remove_dir(from);
331    Ok(())
332}
333
334/// Byte-compare two files, cheaply short-circuiting on differing length.
335fn files_identical(a: &Path, b: &Path) -> std::io::Result<bool> {
336    let (ma, mb) = (std::fs::metadata(a)?, std::fs::metadata(b)?);
337    if ma.len() != mb.len() {
338        return Ok(false);
339    }
340    Ok(std::fs::read(a)? == std::fs::read(b)?)
341}
342
343/// First free `<dest>.legacy`, `<dest>.legacy-2`, … sibling path, so a conflicting
344/// source lives right next to the winner — clearly marked, never lost.
345fn backup_path(dest: &Path) -> PathBuf {
346    let base = dest.as_os_str().to_os_string();
347    let make = |suffix: &str| {
348        let mut s = base.clone();
349        s.push(suffix);
350        PathBuf::from(s)
351    };
352    let mut candidate = make(".legacy");
353    let mut n = 2;
354    while candidate.exists() {
355        candidate = make(&format!(".legacy-{n}"));
356        n += 1;
357    }
358    candidate
359}
360
361/// Returns the single-dir source plus the resolved split targets, or `None`
362/// when there is nothing to split: a fresh/already-split install, or an explicit
363/// `LEAN_CTX_DATA_DIR` (a deliberate single-dir choice we must not override).
364fn detect() -> Option<(PathBuf, Targets)> {
365    if std::env::var_os("LEAN_CTX_DATA_DIR").is_some() {
366        return None;
367    }
368    let src = crate::core::paths::single_dir_override()?;
369    if !src.is_dir() {
370        return None;
371    }
372    let targets = Targets::resolve().ok()?;
373    Some((src, targets))
374}
375
376/// Count entries that a split would relocate, for the read-only `doctor` report.
377/// Returns `None` when no migration applies.
378pub fn pending() -> Option<(PathBuf, usize)> {
379    let (src, targets) = detect()?;
380    let n = entries_to_move(&src, &targets).len();
381    if n == 0 {
382        return None;
383    }
384    Some((src, n))
385}
386
387/// Split a legacy/mixed single-dir install into the four XDG dirs. Returns
388/// `None` when nothing applies (fresh install, explicit `LEAN_CTX_DATA_DIR`, or
389/// already split). Drives `lean-ctx doctor --fix`.
390pub fn migrate() -> Option<MigrationReport> {
391    let (src, targets) = detect()?;
392    let report = migrate_from(&src, &targets);
393    if report.is_empty() {
394        return None;
395    }
396    Some(report)
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    fn targets_in(root: &Path) -> Targets {
404        Targets {
405            config: root.join("config"),
406            data: root.join("data"),
407            state: root.join("state"),
408            cache: root.join("cache"),
409        }
410    }
411
412    fn touch(dir: &Path, name: &str) {
413        std::fs::create_dir_all(dir).unwrap();
414        std::fs::write(dir.join(name), b"x").unwrap();
415    }
416
417    #[test]
418    fn categorize_routes_each_category() {
419        assert_eq!(categorize("config.toml"), Category::Config);
420        assert_eq!(categorize("shell-hook.zsh"), Category::Config);
421        assert_eq!(categorize("events.jsonl"), Category::State);
422        assert_eq!(categorize("pipeline_stats.json"), Category::State);
423        assert_eq!(categorize("semantic_cache"), Category::Cache);
424        assert_eq!(categorize("models"), Category::Cache);
425        assert_eq!(categorize(".first_run_wow_done"), Category::Cache);
426        assert_eq!(categorize("daemon.sock"), Category::Runtime);
427        assert_eq!(categorize(".graph-idx-abc.lock"), Category::Runtime);
428        // catch-all → data
429        assert_eq!(categorize("sessions"), Category::Data);
430        assert_eq!(categorize("stats.json"), Category::Data);
431        assert_eq!(categorize("client-id.json"), Category::Data);
432        assert_eq!(categorize("something-new"), Category::Data);
433    }
434
435    #[test]
436    fn mixed_config_source_splits_data_state_cache_keeps_config() {
437        let tmp = tempfile::tempdir().unwrap();
438        let root = tmp.path();
439        // Source IS the config target → config entries must stay.
440        let src = root.join("config");
441        let mut t = targets_in(root);
442        t.config = src.clone();
443
444        touch(&src, "config.toml");
445        touch(&src, "events.jsonl");
446        touch(&src, "anomaly_detector.json");
447        touch(&src, "stats.json");
448        touch(&src.join("sessions"), "s1.json");
449        touch(&src, "daemon.pid"); // runtime stays
450
451        let report = migrate_from(&src, &t);
452        assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
453
454        // config + runtime stay
455        assert!(src.join("config.toml").exists());
456        assert!(src.join("daemon.pid").exists());
457        // categories relocate
458        assert!(t.state.join("events.jsonl").exists());
459        assert!(t.cache.join("anomaly_detector.json").exists());
460        assert!(t.data.join("stats.json").exists());
461        assert!(t.data.join("sessions/s1.json").exists());
462        // originals gone
463        assert!(!src.join("events.jsonl").exists());
464        assert!(!src.join("sessions").exists());
465
466        let labels: Vec<_> = report.moved.iter().map(|(n, c)| (n.as_str(), *c)).collect();
467        assert!(labels.contains(&("events.jsonl", "state")));
468        assert!(labels.contains(&("anomaly_detector.json", "cache")));
469        assert!(labels.contains(&("sessions", "data")));
470        assert!(labels.contains(&("stats.json", "data")));
471    }
472
473    #[test]
474    fn legacy_source_moves_everything_including_config() {
475        let tmp = tempfile::tempdir().unwrap();
476        let root = tmp.path();
477        let src = root.join("legacy"); // distinct from every target
478        let t = targets_in(root);
479
480        touch(&src, "config.toml");
481        touch(&src, "events.jsonl");
482        touch(&src.join("vectors"), "v.bin");
483
484        let report = migrate_from(&src, &t);
485        assert!(report.errors.is_empty());
486        assert!(t.config.join("config.toml").exists());
487        assert!(t.state.join("events.jsonl").exists());
488        assert!(t.data.join("vectors/v.bin").exists());
489        assert!(!src.join("config.toml").exists());
490    }
491
492    #[test]
493    fn identical_dest_is_deduped_and_source_cleared() {
494        let tmp = tempfile::tempdir().unwrap();
495        let root = tmp.path();
496        let src = root.join("legacy");
497        let t = targets_in(root);
498
499        // Destination already holds a byte-identical copy.
500        touch(&src, "events.jsonl"); // content "x"
501        std::fs::create_dir_all(&t.state).unwrap();
502        std::fs::write(t.state.join("events.jsonl"), b"x").unwrap();
503
504        let report = migrate_from(&src, &t);
505        assert!(report.errors.is_empty());
506        assert!(report.moved.is_empty());
507        assert_eq!(report.skipped, vec!["events.jsonl".to_string()]);
508        assert!(report.conflicts.is_empty());
509        // Duplicate source dropped; destination untouched.
510        assert!(
511            !src.join("events.jsonl").exists(),
512            "duplicate source dropped"
513        );
514        assert_eq!(
515            std::fs::read_to_string(t.state.join("events.jsonl")).unwrap(),
516            "x"
517        );
518        // Legacy dir is now empty → a re-scan plans nothing (warning clears).
519        assert!(entries_to_move(&src, &t).is_empty());
520    }
521
522    #[test]
523    fn conflicting_dest_backs_up_source_and_clears_legacy() {
524        let tmp = tempfile::tempdir().unwrap();
525        let root = tmp.path();
526        let src = root.join("legacy");
527        let t = targets_in(root);
528
529        touch(&src, "events.jsonl"); // content "x"
530        std::fs::create_dir_all(&t.state).unwrap();
531        std::fs::write(t.state.join("events.jsonl"), b"keep").unwrap(); // differs
532
533        let report = migrate_from(&src, &t);
534        assert!(report.errors.is_empty());
535        assert_eq!(report.conflicts, vec!["events.jsonl".to_string()]);
536        // Winner preserved, source moved aside as *.legacy, legacy dir emptied.
537        assert_eq!(
538            std::fs::read_to_string(t.state.join("events.jsonl")).unwrap(),
539            "keep",
540            "existing destination must not be overwritten"
541        );
542        assert_eq!(
543            std::fs::read_to_string(t.state.join("events.jsonl.legacy")).unwrap(),
544            "x",
545            "different source preserved next to the winner"
546        );
547        assert!(!src.join("events.jsonl").exists());
548        assert!(
549            entries_to_move(&src, &t).is_empty(),
550            "warning clears once the source is reconciled"
551        );
552    }
553
554    /// #429: a destination directory that already holds *some* content (a parallel
555    /// data dir, a half-finished earlier run) must be merged, not skipped —
556    /// otherwise the legacy entries linger and `doctor` warns forever no matter
557    /// how often `--fix` runs. After the merge the legacy source is fully empty.
558    #[test]
559    fn dir_collision_merges_and_empties_legacy_429() {
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        // Source sessions/ has a new file and a duplicate; dest sessions/ exists
566        // already with its own file plus the same duplicate.
567        touch(&src.join("sessions"), "old.json"); // only in source
568        std::fs::write(src.join("sessions").join("dup.json"), b"same").unwrap();
569        touch(&t.data.join("sessions"), "existing.json"); // only in dest
570        std::fs::write(t.data.join("sessions").join("dup.json"), b"same").unwrap();
571
572        let report = migrate_from(&src, &t);
573        assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
574
575        // Merged: destination keeps its own entry and gains the source-only one.
576        assert!(t.data.join("sessions/existing.json").exists());
577        assert!(t.data.join("sessions/old.json").exists());
578        assert!(t.data.join("sessions/dup.json").exists());
579        // Source dir fully removed → legacy no longer triggers single-dir mode.
580        assert!(!src.join("sessions").exists(), "merged source dir removed");
581        assert!(
582            entries_to_move(&src, &t).is_empty(),
583            "#429: nothing left to migrate after a merge"
584        );
585    }
586
587    #[test]
588    fn entries_to_move_is_sorted_for_determinism() {
589        let tmp = tempfile::tempdir().unwrap();
590        let root = tmp.path();
591        let src = root.join("legacy");
592        let t = targets_in(root);
593        touch(&src, "events.jsonl");
594        touch(&src, "config.toml");
595        touch(&src, "anomaly_detector.json");
596        let names: Vec<_> = entries_to_move(&src, &t)
597            .into_iter()
598            .map(|m| m.name)
599            .collect();
600        let mut sorted = names.clone();
601        sorted.sort();
602        assert_eq!(names, sorted);
603    }
604
605    /// Saves a set of env vars and restores them on drop (panic-safe) so an
606    /// env-driven test can never leak `HOME`/`XDG_*` into other tests.
607    ///
608    /// Only the `#[cfg(unix)]` end-to-end tests below construct this, so the
609    /// helper is unix-gated too — otherwise it is dead code on Windows where
610    /// `-D warnings` would fail the build.
611    #[cfg(unix)]
612    struct EnvVars(Vec<(&'static str, Option<std::ffi::OsString>)>);
613
614    #[cfg(unix)]
615    impl EnvVars {
616        fn apply(pairs: &[(&'static str, Option<&Path>)]) -> Self {
617            let saved = pairs
618                .iter()
619                .map(|(k, _)| (*k, std::env::var_os(k)))
620                .collect();
621            for (k, v) in pairs {
622                match v {
623                    Some(p) => std::env::set_var(k, p),
624                    None => std::env::remove_var(k),
625                }
626            }
627            EnvVars(saved)
628        }
629    }
630
631    #[cfg(unix)]
632    impl Drop for EnvVars {
633        fn drop(&mut self) {
634            for (k, v) in &self.0 {
635                match v {
636                    Some(val) => std::env::set_var(k, val),
637                    None => std::env::remove_var(k),
638                }
639            }
640        }
641    }
642
643    // End-to-end through the real env-detection + split-target wiring (the unit
644    // tests above drive `migrate_from` with explicit dirs). Proves a mixed
645    // `$XDG_CONFIG_HOME/lean-ctx` install splits into the four XDG homes and that
646    // a second run is a no-op once the data markers are gone.
647    #[cfg(unix)]
648    #[test]
649    fn migrate_end_to_end_splits_mixed_xdg_config_install() {
650        let _g = crate::core::data_dir::test_env_lock();
651        let tmp = tempfile::tempdir().unwrap();
652        let root = tmp.path();
653        let home = root.join("home");
654        let xc = root.join("xc");
655        let xd = root.join("xd");
656        let xs = root.join("xs");
657        let xk = root.join("xk");
658        std::fs::create_dir_all(&home).unwrap();
659
660        let _env = EnvVars::apply(&[
661            ("HOME", Some(home.as_path())),
662            ("XDG_CONFIG_HOME", Some(xc.as_path())),
663            ("XDG_DATA_HOME", Some(xd.as_path())),
664            ("XDG_STATE_HOME", Some(xs.as_path())),
665            ("XDG_CACHE_HOME", Some(xk.as_path())),
666            ("LEAN_CTX_DATA_DIR", None),
667            ("LEAN_CTX_CONFIG_DIR", None),
668            ("LEAN_CTX_STATE_DIR", None),
669            ("LEAN_CTX_CACHE_DIR", None),
670        ]);
671
672        // Mixed install: config + every category mixed under $XDG_CONFIG_HOME.
673        let mixed = xc.join("lean-ctx");
674        touch(&mixed, "config.toml");
675        touch(&mixed, "events.jsonl");
676        touch(&mixed, "anomaly_detector.json");
677        touch(&mixed, "stats.json");
678        touch(&mixed.join("sessions"), "s.json");
679
680        let report = migrate().expect("mixed install must migrate");
681        assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
682
683        assert!(mixed.join("config.toml").exists(), "config stays in place");
684        assert!(
685            xs.join("lean-ctx/events.jsonl").exists(),
686            "state → XDG_STATE"
687        );
688        assert!(
689            xk.join("lean-ctx/anomaly_detector.json").exists(),
690            "cache → XDG_CACHE"
691        );
692        assert!(xd.join("lean-ctx/stats.json").exists(), "data → XDG_DATA");
693        assert!(
694            xd.join("lean-ctx/sessions/s.json").exists(),
695            "data subdir → XDG_DATA"
696        );
697        assert!(
698            !mixed.join("events.jsonl").exists(),
699            "moved source file removed"
700        );
701
702        assert!(migrate().is_none(), "second run is a no-op (idempotent)");
703    }
704
705    // An explicit `LEAN_CTX_DATA_DIR` is a deliberate single-dir choice and must
706    // never be auto-split, even when the dir clearly mixes categories.
707    #[cfg(unix)]
708    #[test]
709    fn migrate_respects_explicit_data_dir_override() {
710        let _g = crate::core::data_dir::test_env_lock();
711        let tmp = tempfile::tempdir().unwrap();
712        let single = tmp.path().join("single");
713        touch(&single, "stats.json");
714        touch(&single, "events.jsonl");
715
716        let _env = EnvVars::apply(&[("LEAN_CTX_DATA_DIR", Some(single.as_path()))]);
717        assert!(
718            migrate().is_none(),
719            "explicit LEAN_CTX_DATA_DIR must not be split"
720        );
721        assert!(single.join("events.jsonl").exists(), "nothing moved");
722    }
723}