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 **skipped** (never clobbered), which
25//!   makes re-running after a partial failure safe.
26//! - An explicit `LEAN_CTX_DATA_DIR` is treated as a deliberate single-dir
27//!   choice and is **never** auto-split.
28//! - Runtime files (`daemon.pid`, sockets, lock files) are left in place; they
29//!   are ephemeral and regenerated by the daemon.
30//!
31//! Determinism (#498): the planned move set is sorted by entry name, so report
32//! bodies are a pure function of the on-disk layout.
33
34use std::path::{Path, PathBuf};
35
36/// XDG category an on-disk entry belongs to.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38enum Category {
39    Config,
40    Data,
41    State,
42    Cache,
43    /// Ephemeral runtime files — left in place (regenerated by the daemon).
44    Runtime,
45}
46
47impl Category {
48    /// Stable, human-readable label used in migration reports.
49    fn label(self) -> &'static str {
50        match self {
51            Category::Config => "config",
52            Category::Data => "data",
53            Category::State => "state",
54            Category::Cache => "cache",
55            Category::Runtime => "runtime",
56        }
57    }
58}
59
60/// Classify a top-level entry name into its XDG category.
61///
62/// The CONFIG/STATE/CACHE/RUNTIME sets are explicit and must match where the
63/// code actually reads/writes each file (see the GL #603/#604 migrations); every
64/// unlisted entry falls through to DATA, the catch-all (`sessions/`, `vectors/`,
65/// `graphs/`, `knowledge/`, `archives/`, `stats.json`, `client-id.json`, …).
66fn categorize(name: &str) -> Category {
67    match name {
68        // --- config: RO-safe ---
69        "config.toml" | "env.sh" => Category::Config,
70        n if n.starts_with("shell-hook.") => Category::Config,
71
72        // --- state: events, logs, journals, ledgers, dashboards ---
73        "events.jsonl"
74        | "journal.md"
75        | "tool-calls.log"
76        | "mcp-live.json"
77        | "feedback.json"
78        | "cost_attribution.json"
79        | "context_ledger.json"
80        | "ledger"
81        | "cooccurrence"
82        | "slow-commands.log"
83        | "pipeline_stats.json"
84        | "heatmap.json"
85        | "tee"
86        | "dashboard.token"
87        | "agent_runtime_env.json" => Category::State,
88
89        // --- cache: regenerable models, embeddings, learned patterns ---
90        "semantic_cache"
91        | "models"
92        | "anomaly_detector.json"
93        | "autonomy_drivers_v1.json"
94        | "context_ir_v1.json"
95        | "thresholds_learned.json"
96        | "litm_calibration.json"
97        | "path_mode_memory.json"
98        | "efficacy_snapshots.json"
99        | "latest-version.json"
100        | ".first_run_wow_done" => Category::Cache,
101
102        // --- runtime: ephemeral, left in place ---
103        "daemon.pid" | "daemon.sock" | "daemon-stderr.log" => Category::Runtime,
104        n if n.starts_with(".graph-idx-") => Category::Runtime,
105
106        _ => Category::Data,
107    }
108}
109
110/// Resolved per-category target directories for a split.
111struct Targets {
112    config: PathBuf,
113    data: PathBuf,
114    state: PathBuf,
115    cache: PathBuf,
116}
117
118impl Targets {
119    /// Resolve targets from the environment, bypassing single-dir back-compat.
120    fn resolve() -> Result<Self, String> {
121        Ok(Self {
122            config: crate::core::paths::config_split_target()?,
123            data: crate::core::paths::data_split_target()?,
124            state: crate::core::paths::state_split_target()?,
125            cache: crate::core::paths::cache_split_target()?,
126        })
127    }
128
129    /// Target dir for a category, or `None` for runtime (left in place).
130    fn dir_for(&self, cat: Category) -> Option<&Path> {
131        match cat {
132            Category::Config => Some(&self.config),
133            Category::Data => Some(&self.data),
134            Category::State => Some(&self.state),
135            Category::Cache => Some(&self.cache),
136            Category::Runtime => None,
137        }
138    }
139}
140
141/// A single planned move computed from the source layout.
142struct PlannedMove {
143    from: PathBuf,
144    name: String,
145    category: &'static str,
146    dest_dir: PathBuf,
147    dest: PathBuf,
148}
149
150/// Outcome of a migration run, surfaced through `doctor --fix`.
151pub struct MigrationReport {
152    /// The single directory that was split.
153    pub source: PathBuf,
154    /// `(entry, category)` for every entry successfully relocated.
155    pub moved: Vec<(String, &'static str)>,
156    /// Entries skipped because the destination already existed.
157    pub skipped: Vec<String>,
158    /// Per-entry move failures (`entry: error`).
159    pub errors: Vec<String>,
160}
161
162impl MigrationReport {
163    fn new(source: &Path) -> Self {
164        Self {
165            source: source.to_path_buf(),
166            moved: Vec::new(),
167            skipped: Vec::new(),
168            errors: Vec::new(),
169        }
170    }
171
172    /// Whether the run produced any observable effect worth reporting.
173    fn is_empty(&self) -> bool {
174        self.moved.is_empty() && self.skipped.is_empty() && self.errors.is_empty()
175    }
176}
177
178/// Compute the deterministic set of entries that must move to split `src` into
179/// `targets`. Entries whose category target equals `src` (e.g. config files in a
180/// mixed `$XDG_CONFIG_HOME/lean-ctx`) and runtime files stay put.
181fn entries_to_move(src: &Path, targets: &Targets) -> Vec<PlannedMove> {
182    let mut moves = Vec::new();
183    let Ok(rd) = std::fs::read_dir(src) else {
184        return moves;
185    };
186    for entry in rd.flatten() {
187        let raw_name = entry.file_name();
188        let name = raw_name.to_string_lossy().to_string();
189        let cat = categorize(&name);
190        let Some(dest_dir) = targets.dir_for(cat) else {
191            continue; // runtime → leave in place
192        };
193        if dest_dir == src {
194            continue; // already in the right place (e.g. mixed config dir)
195        }
196        moves.push(PlannedMove {
197            from: entry.path(),
198            name,
199            category: cat.label(),
200            dest_dir: dest_dir.to_path_buf(),
201            dest: dest_dir.join(&raw_name),
202        });
203    }
204    moves.sort_by(|a, b| a.name.cmp(&b.name));
205    moves
206}
207
208/// Recursively copy `from` into `to` (used as the cross-filesystem fallback when
209/// `rename` cannot move across mount points).
210fn copy_tree(from: &Path, to: &Path) -> std::io::Result<()> {
211    std::fs::create_dir_all(to)?;
212    for entry in std::fs::read_dir(from)? {
213        let entry = entry?;
214        let dst = to.join(entry.file_name());
215        if entry.file_type()?.is_dir() {
216            copy_tree(&entry.path(), &dst)?;
217        } else {
218            std::fs::copy(entry.path(), &dst)?;
219        }
220    }
221    Ok(())
222}
223
224/// Move `from` to `to`, preferring an atomic `rename` and falling back to
225/// copy+remove across filesystems. The source is removed only after the copy
226/// succeeds, so an interrupted move never loses data.
227fn move_entry(from: &Path, to: &Path) -> std::io::Result<()> {
228    if std::fs::rename(from, to).is_ok() {
229        return Ok(());
230    }
231    if from.is_dir() {
232        copy_tree(from, to)?;
233        std::fs::remove_dir_all(from)?;
234    } else {
235        std::fs::copy(from, to)?;
236        std::fs::remove_file(from)?;
237    }
238    Ok(())
239}
240
241/// Execute the split of `src` into `targets`. Pure with respect to its inputs
242/// (no environment access) so it can be tested hermetically.
243fn migrate_from(src: &Path, targets: &Targets) -> MigrationReport {
244    let mut report = MigrationReport::new(src);
245    for mv in entries_to_move(src, targets) {
246        if mv.dest.exists() {
247            report.skipped.push(mv.name);
248            continue;
249        }
250        if let Err(e) = std::fs::create_dir_all(&mv.dest_dir) {
251            report.errors.push(format!("{}: {e}", mv.name));
252            continue;
253        }
254        crate::core::data_dir::ensure_dir_permissions(&mv.dest_dir);
255        match move_entry(&mv.from, &mv.dest) {
256            Ok(()) => report.moved.push((mv.name, mv.category)),
257            Err(e) => report.errors.push(format!("{}: {e}", mv.name)),
258        }
259    }
260    report
261}
262
263/// Returns the single-dir source plus the resolved split targets, or `None`
264/// when there is nothing to split: a fresh/already-split install, or an explicit
265/// `LEAN_CTX_DATA_DIR` (a deliberate single-dir choice we must not override).
266fn detect() -> Option<(PathBuf, Targets)> {
267    if std::env::var_os("LEAN_CTX_DATA_DIR").is_some() {
268        return None;
269    }
270    let src = crate::core::paths::single_dir_override()?;
271    if !src.is_dir() {
272        return None;
273    }
274    let targets = Targets::resolve().ok()?;
275    Some((src, targets))
276}
277
278/// Count entries that a split would relocate, for the read-only `doctor` report.
279/// Returns `None` when no migration applies.
280pub fn pending() -> Option<(PathBuf, usize)> {
281    let (src, targets) = detect()?;
282    let n = entries_to_move(&src, &targets).len();
283    if n == 0 {
284        return None;
285    }
286    Some((src, n))
287}
288
289/// Split a legacy/mixed single-dir install into the four XDG dirs. Returns
290/// `None` when nothing applies (fresh install, explicit `LEAN_CTX_DATA_DIR`, or
291/// already split). Drives `lean-ctx doctor --fix`.
292pub fn migrate() -> Option<MigrationReport> {
293    let (src, targets) = detect()?;
294    let report = migrate_from(&src, &targets);
295    if report.is_empty() {
296        return None;
297    }
298    Some(report)
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    fn targets_in(root: &Path) -> Targets {
306        Targets {
307            config: root.join("config"),
308            data: root.join("data"),
309            state: root.join("state"),
310            cache: root.join("cache"),
311        }
312    }
313
314    fn touch(dir: &Path, name: &str) {
315        std::fs::create_dir_all(dir).unwrap();
316        std::fs::write(dir.join(name), b"x").unwrap();
317    }
318
319    #[test]
320    fn categorize_routes_each_category() {
321        assert_eq!(categorize("config.toml"), Category::Config);
322        assert_eq!(categorize("shell-hook.zsh"), Category::Config);
323        assert_eq!(categorize("events.jsonl"), Category::State);
324        assert_eq!(categorize("pipeline_stats.json"), Category::State);
325        assert_eq!(categorize("semantic_cache"), Category::Cache);
326        assert_eq!(categorize("models"), Category::Cache);
327        assert_eq!(categorize(".first_run_wow_done"), Category::Cache);
328        assert_eq!(categorize("daemon.sock"), Category::Runtime);
329        assert_eq!(categorize(".graph-idx-abc.lock"), Category::Runtime);
330        // catch-all → data
331        assert_eq!(categorize("sessions"), Category::Data);
332        assert_eq!(categorize("stats.json"), Category::Data);
333        assert_eq!(categorize("client-id.json"), Category::Data);
334        assert_eq!(categorize("something-new"), Category::Data);
335    }
336
337    #[test]
338    fn mixed_config_source_splits_data_state_cache_keeps_config() {
339        let tmp = tempfile::tempdir().unwrap();
340        let root = tmp.path();
341        // Source IS the config target → config entries must stay.
342        let src = root.join("config");
343        let mut t = targets_in(root);
344        t.config = src.clone();
345
346        touch(&src, "config.toml");
347        touch(&src, "events.jsonl");
348        touch(&src, "anomaly_detector.json");
349        touch(&src, "stats.json");
350        touch(&src.join("sessions"), "s1.json");
351        touch(&src, "daemon.pid"); // runtime stays
352
353        let report = migrate_from(&src, &t);
354        assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
355
356        // config + runtime stay
357        assert!(src.join("config.toml").exists());
358        assert!(src.join("daemon.pid").exists());
359        // categories relocate
360        assert!(t.state.join("events.jsonl").exists());
361        assert!(t.cache.join("anomaly_detector.json").exists());
362        assert!(t.data.join("stats.json").exists());
363        assert!(t.data.join("sessions/s1.json").exists());
364        // originals gone
365        assert!(!src.join("events.jsonl").exists());
366        assert!(!src.join("sessions").exists());
367
368        let labels: Vec<_> = report.moved.iter().map(|(n, c)| (n.as_str(), *c)).collect();
369        assert!(labels.contains(&("events.jsonl", "state")));
370        assert!(labels.contains(&("anomaly_detector.json", "cache")));
371        assert!(labels.contains(&("sessions", "data")));
372        assert!(labels.contains(&("stats.json", "data")));
373    }
374
375    #[test]
376    fn legacy_source_moves_everything_including_config() {
377        let tmp = tempfile::tempdir().unwrap();
378        let root = tmp.path();
379        let src = root.join("legacy"); // distinct from every target
380        let t = targets_in(root);
381
382        touch(&src, "config.toml");
383        touch(&src, "events.jsonl");
384        touch(&src.join("vectors"), "v.bin");
385
386        let report = migrate_from(&src, &t);
387        assert!(report.errors.is_empty());
388        assert!(t.config.join("config.toml").exists());
389        assert!(t.state.join("events.jsonl").exists());
390        assert!(t.data.join("vectors/v.bin").exists());
391        assert!(!src.join("config.toml").exists());
392    }
393
394    #[test]
395    fn second_run_is_noop_and_existing_dest_is_skipped() {
396        let tmp = tempfile::tempdir().unwrap();
397        let root = tmp.path();
398        let src = root.join("legacy");
399        let t = targets_in(root);
400
401        touch(&src, "events.jsonl");
402        let first = migrate_from(&src, &t);
403        assert_eq!(first.moved.len(), 1);
404
405        // Re-create the source entry to prove an existing dest is never clobbered.
406        touch(&src, "events.jsonl");
407        std::fs::write(t.state.join("events.jsonl"), b"keep").unwrap();
408        let second = migrate_from(&src, &t);
409        assert!(second.moved.is_empty());
410        assert_eq!(second.skipped, vec!["events.jsonl".to_string()]);
411        assert_eq!(
412            std::fs::read_to_string(t.state.join("events.jsonl")).unwrap(),
413            "keep",
414            "existing destination must not be overwritten"
415        );
416    }
417
418    #[test]
419    fn entries_to_move_is_sorted_for_determinism() {
420        let tmp = tempfile::tempdir().unwrap();
421        let root = tmp.path();
422        let src = root.join("legacy");
423        let t = targets_in(root);
424        touch(&src, "events.jsonl");
425        touch(&src, "config.toml");
426        touch(&src, "anomaly_detector.json");
427        let names: Vec<_> = entries_to_move(&src, &t)
428            .into_iter()
429            .map(|m| m.name)
430            .collect();
431        let mut sorted = names.clone();
432        sorted.sort();
433        assert_eq!(names, sorted);
434    }
435
436    /// Saves a set of env vars and restores them on drop (panic-safe) so an
437    /// env-driven test can never leak `HOME`/`XDG_*` into other tests.
438    ///
439    /// Only the `#[cfg(unix)]` end-to-end tests below construct this, so the
440    /// helper is unix-gated too — otherwise it is dead code on Windows where
441    /// `-D warnings` would fail the build.
442    #[cfg(unix)]
443    struct EnvVars(Vec<(&'static str, Option<std::ffi::OsString>)>);
444
445    #[cfg(unix)]
446    impl EnvVars {
447        fn apply(pairs: &[(&'static str, Option<&Path>)]) -> Self {
448            let saved = pairs
449                .iter()
450                .map(|(k, _)| (*k, std::env::var_os(k)))
451                .collect();
452            for (k, v) in pairs {
453                match v {
454                    Some(p) => std::env::set_var(k, p),
455                    None => std::env::remove_var(k),
456                }
457            }
458            EnvVars(saved)
459        }
460    }
461
462    #[cfg(unix)]
463    impl Drop for EnvVars {
464        fn drop(&mut self) {
465            for (k, v) in &self.0 {
466                match v {
467                    Some(val) => std::env::set_var(k, val),
468                    None => std::env::remove_var(k),
469                }
470            }
471        }
472    }
473
474    // End-to-end through the real env-detection + split-target wiring (the unit
475    // tests above drive `migrate_from` with explicit dirs). Proves a mixed
476    // `$XDG_CONFIG_HOME/lean-ctx` install splits into the four XDG homes and that
477    // a second run is a no-op once the data markers are gone.
478    #[cfg(unix)]
479    #[test]
480    fn migrate_end_to_end_splits_mixed_xdg_config_install() {
481        let _g = crate::core::data_dir::test_env_lock();
482        let tmp = tempfile::tempdir().unwrap();
483        let root = tmp.path();
484        let home = root.join("home");
485        let xc = root.join("xc");
486        let xd = root.join("xd");
487        let xs = root.join("xs");
488        let xk = root.join("xk");
489        std::fs::create_dir_all(&home).unwrap();
490
491        let _env = EnvVars::apply(&[
492            ("HOME", Some(home.as_path())),
493            ("XDG_CONFIG_HOME", Some(xc.as_path())),
494            ("XDG_DATA_HOME", Some(xd.as_path())),
495            ("XDG_STATE_HOME", Some(xs.as_path())),
496            ("XDG_CACHE_HOME", Some(xk.as_path())),
497            ("LEAN_CTX_DATA_DIR", None),
498            ("LEAN_CTX_CONFIG_DIR", None),
499            ("LEAN_CTX_STATE_DIR", None),
500            ("LEAN_CTX_CACHE_DIR", None),
501        ]);
502
503        // Mixed install: config + every category mixed under $XDG_CONFIG_HOME.
504        let mixed = xc.join("lean-ctx");
505        touch(&mixed, "config.toml");
506        touch(&mixed, "events.jsonl");
507        touch(&mixed, "anomaly_detector.json");
508        touch(&mixed, "stats.json");
509        touch(&mixed.join("sessions"), "s.json");
510
511        let report = migrate().expect("mixed install must migrate");
512        assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
513
514        assert!(mixed.join("config.toml").exists(), "config stays in place");
515        assert!(
516            xs.join("lean-ctx/events.jsonl").exists(),
517            "state → XDG_STATE"
518        );
519        assert!(
520            xk.join("lean-ctx/anomaly_detector.json").exists(),
521            "cache → XDG_CACHE"
522        );
523        assert!(xd.join("lean-ctx/stats.json").exists(), "data → XDG_DATA");
524        assert!(
525            xd.join("lean-ctx/sessions/s.json").exists(),
526            "data subdir → XDG_DATA"
527        );
528        assert!(
529            !mixed.join("events.jsonl").exists(),
530            "moved source file removed"
531        );
532
533        assert!(migrate().is_none(), "second run is a no-op (idempotent)");
534    }
535
536    // An explicit `LEAN_CTX_DATA_DIR` is a deliberate single-dir choice and must
537    // never be auto-split, even when the dir clearly mixes categories.
538    #[cfg(unix)]
539    #[test]
540    fn migrate_respects_explicit_data_dir_override() {
541        let _g = crate::core::data_dir::test_env_lock();
542        let tmp = tempfile::tempdir().unwrap();
543        let single = tmp.path().join("single");
544        touch(&single, "stats.json");
545        touch(&single, "events.jsonl");
546
547        let _env = EnvVars::apply(&[("LEAN_CTX_DATA_DIR", Some(single.as_path()))]);
548        assert!(
549            migrate().is_none(),
550            "explicit LEAN_CTX_DATA_DIR must not be split"
551        );
552        assert!(single.join("events.jsonl").exists(), "nothing moved");
553    }
554}