1use std::path::{Path, PathBuf};
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44enum Category {
45 Config,
46 Data,
47 State,
48 Cache,
49 Runtime,
51}
52
53impl Category {
54 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
66fn categorize(name: &str) -> Category {
73 match name {
74 "config.toml" | "env.sh" | "layout.toml" => Category::Config,
76 n if n.starts_with("shell-hook.") => Category::Config,
77
78 "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 "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 "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
116struct Targets {
118 config: PathBuf,
119 data: PathBuf,
120 state: PathBuf,
121 cache: PathBuf,
122}
123
124impl Targets {
125 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 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
147struct PlannedMove {
149 from: PathBuf,
150 name: String,
151 category: &'static str,
152 dest_dir: PathBuf,
153 dest: PathBuf,
154}
155
156pub struct MigrationReport {
158 pub source: PathBuf,
160 pub moved: Vec<(String, &'static str)>,
163 pub skipped: Vec<String>,
166 pub conflicts: Vec<String>,
169 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 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
193fn 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; };
208 if dest_dir == src {
209 continue; }
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
223fn 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
239fn 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
256enum Reconciled {
258 Merged,
260 Deduped,
262 Conflict,
264}
265
266fn 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
295fn 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 let backup = backup_path(dest);
312 move_entry(from, &backup)?;
313 Ok(Reconciled::Conflict)
314}
315
316fn 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 let _ = std::fs::remove_dir(from);
331 Ok(())
332}
333
334fn 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
343fn 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
361fn 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
376pub 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
387pub 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#[must_use]
426pub fn residual_legacy_present() -> bool {
427 dirs::home_dir().is_some_and(|h| h.join(".lean-ctx").is_dir())
428}
429
430pub fn reclaim_legacy() -> Option<MigrationReport> {
431 if std::env::var_os("LEAN_CTX_DATA_DIR").is_some() {
432 return None;
433 }
434 let legacy = dirs::home_dir()?.join(".lean-ctx");
435 if !legacy.is_dir() {
436 return None;
437 }
438 if crate::core::data_dir::lean_ctx_data_dir().ok().as_deref() == Some(legacy.as_path()) {
441 return None;
442 }
443 let targets = Targets::resolve().ok()?;
444 let report = migrate_from(&legacy, &targets);
445 let _ = std::fs::remove_dir(&legacy);
447 if report.is_empty() {
448 return None;
449 }
450 Some(report)
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 fn targets_in(root: &Path) -> Targets {
458 Targets {
459 config: root.join("config"),
460 data: root.join("data"),
461 state: root.join("state"),
462 cache: root.join("cache"),
463 }
464 }
465
466 fn touch(dir: &Path, name: &str) {
467 std::fs::create_dir_all(dir).unwrap();
468 std::fs::write(dir.join(name), b"x").unwrap();
469 }
470
471 #[test]
472 fn categorize_routes_each_category() {
473 assert_eq!(categorize("config.toml"), Category::Config);
474 assert_eq!(categorize("shell-hook.zsh"), Category::Config);
475 assert_eq!(categorize("events.jsonl"), Category::State);
476 assert_eq!(categorize("pipeline_stats.json"), Category::State);
477 assert_eq!(categorize("semantic_cache"), Category::Cache);
478 assert_eq!(categorize("models"), Category::Cache);
479 assert_eq!(categorize(".first_run_wow_done"), Category::Cache);
480 assert_eq!(categorize("daemon.sock"), Category::Runtime);
481 assert_eq!(categorize(".graph-idx-abc.lock"), Category::Runtime);
482 assert_eq!(categorize("sessions"), Category::Data);
484 assert_eq!(categorize("stats.json"), Category::Data);
485 assert_eq!(categorize("client-id.json"), Category::Data);
486 assert_eq!(categorize("something-new"), Category::Data);
487 }
488
489 #[test]
490 fn mixed_config_source_splits_data_state_cache_keeps_config() {
491 let tmp = tempfile::tempdir().unwrap();
492 let root = tmp.path();
493 let src = root.join("config");
495 let mut t = targets_in(root);
496 t.config = src.clone();
497
498 touch(&src, "config.toml");
499 touch(&src, "events.jsonl");
500 touch(&src, "anomaly_detector.json");
501 touch(&src, "stats.json");
502 touch(&src.join("sessions"), "s1.json");
503 touch(&src, "daemon.pid"); let report = migrate_from(&src, &t);
506 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
507
508 assert!(src.join("config.toml").exists());
510 assert!(src.join("daemon.pid").exists());
511 assert!(t.state.join("events.jsonl").exists());
513 assert!(t.cache.join("anomaly_detector.json").exists());
514 assert!(t.data.join("stats.json").exists());
515 assert!(t.data.join("sessions/s1.json").exists());
516 assert!(!src.join("events.jsonl").exists());
518 assert!(!src.join("sessions").exists());
519
520 let labels: Vec<_> = report.moved.iter().map(|(n, c)| (n.as_str(), *c)).collect();
521 assert!(labels.contains(&("events.jsonl", "state")));
522 assert!(labels.contains(&("anomaly_detector.json", "cache")));
523 assert!(labels.contains(&("sessions", "data")));
524 assert!(labels.contains(&("stats.json", "data")));
525 }
526
527 #[test]
528 fn legacy_source_moves_everything_including_config() {
529 let tmp = tempfile::tempdir().unwrap();
530 let root = tmp.path();
531 let src = root.join("legacy"); let t = targets_in(root);
533
534 touch(&src, "config.toml");
535 touch(&src, "events.jsonl");
536 touch(&src.join("vectors"), "v.bin");
537
538 let report = migrate_from(&src, &t);
539 assert!(report.errors.is_empty());
540 assert!(t.config.join("config.toml").exists());
541 assert!(t.state.join("events.jsonl").exists());
542 assert!(t.data.join("vectors/v.bin").exists());
543 assert!(!src.join("config.toml").exists());
544 }
545
546 #[test]
547 fn identical_dest_is_deduped_and_source_cleared() {
548 let tmp = tempfile::tempdir().unwrap();
549 let root = tmp.path();
550 let src = root.join("legacy");
551 let t = targets_in(root);
552
553 touch(&src, "events.jsonl"); std::fs::create_dir_all(&t.state).unwrap();
556 std::fs::write(t.state.join("events.jsonl"), b"x").unwrap();
557
558 let report = migrate_from(&src, &t);
559 assert!(report.errors.is_empty());
560 assert!(report.moved.is_empty());
561 assert_eq!(report.skipped, vec!["events.jsonl".to_string()]);
562 assert!(report.conflicts.is_empty());
563 assert!(
565 !src.join("events.jsonl").exists(),
566 "duplicate source dropped"
567 );
568 assert_eq!(
569 std::fs::read_to_string(t.state.join("events.jsonl")).unwrap(),
570 "x"
571 );
572 assert!(entries_to_move(&src, &t).is_empty());
574 }
575
576 #[test]
577 fn conflicting_dest_backs_up_source_and_clears_legacy() {
578 let tmp = tempfile::tempdir().unwrap();
579 let root = tmp.path();
580 let src = root.join("legacy");
581 let t = targets_in(root);
582
583 touch(&src, "events.jsonl"); std::fs::create_dir_all(&t.state).unwrap();
585 std::fs::write(t.state.join("events.jsonl"), b"keep").unwrap(); let report = migrate_from(&src, &t);
588 assert!(report.errors.is_empty());
589 assert_eq!(report.conflicts, vec!["events.jsonl".to_string()]);
590 assert_eq!(
592 std::fs::read_to_string(t.state.join("events.jsonl")).unwrap(),
593 "keep",
594 "existing destination must not be overwritten"
595 );
596 assert_eq!(
597 std::fs::read_to_string(t.state.join("events.jsonl.legacy")).unwrap(),
598 "x",
599 "different source preserved next to the winner"
600 );
601 assert!(!src.join("events.jsonl").exists());
602 assert!(
603 entries_to_move(&src, &t).is_empty(),
604 "warning clears once the source is reconciled"
605 );
606 }
607
608 #[test]
613 fn dir_collision_merges_and_empties_legacy_429() {
614 let tmp = tempfile::tempdir().unwrap();
615 let root = tmp.path();
616 let src = root.join("legacy");
617 let t = targets_in(root);
618
619 touch(&src.join("sessions"), "old.json"); std::fs::write(src.join("sessions").join("dup.json"), b"same").unwrap();
623 touch(&t.data.join("sessions"), "existing.json"); std::fs::write(t.data.join("sessions").join("dup.json"), b"same").unwrap();
625
626 let report = migrate_from(&src, &t);
627 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
628
629 assert!(t.data.join("sessions/existing.json").exists());
631 assert!(t.data.join("sessions/old.json").exists());
632 assert!(t.data.join("sessions/dup.json").exists());
633 assert!(!src.join("sessions").exists(), "merged source dir removed");
635 assert!(
636 entries_to_move(&src, &t).is_empty(),
637 "#429: nothing left to migrate after a merge"
638 );
639 }
640
641 #[test]
642 fn entries_to_move_is_sorted_for_determinism() {
643 let tmp = tempfile::tempdir().unwrap();
644 let root = tmp.path();
645 let src = root.join("legacy");
646 let t = targets_in(root);
647 touch(&src, "events.jsonl");
648 touch(&src, "config.toml");
649 touch(&src, "anomaly_detector.json");
650 let names: Vec<_> = entries_to_move(&src, &t)
651 .into_iter()
652 .map(|m| m.name)
653 .collect();
654 let mut sorted = names.clone();
655 sorted.sort();
656 assert_eq!(names, sorted);
657 }
658
659 #[cfg(unix)]
666 struct EnvVars(Vec<(&'static str, Option<std::ffi::OsString>)>);
667
668 #[cfg(unix)]
669 impl EnvVars {
670 fn apply(pairs: &[(&'static str, Option<&Path>)]) -> Self {
671 let saved = pairs
672 .iter()
673 .map(|(k, _)| (*k, std::env::var_os(k)))
674 .collect();
675 for (k, v) in pairs {
676 match v {
677 Some(p) => crate::test_env::set_var(k, p),
678 None => crate::test_env::remove_var(k),
679 }
680 }
681 EnvVars(saved)
682 }
683 }
684
685 #[cfg(unix)]
686 impl Drop for EnvVars {
687 fn drop(&mut self) {
688 for (k, v) in &self.0 {
689 match v {
690 Some(val) => crate::test_env::set_var(k, val),
691 None => crate::test_env::remove_var(k),
692 }
693 }
694 }
695 }
696
697 #[cfg(unix)]
702 #[test]
703 fn migrate_end_to_end_splits_mixed_xdg_config_install() {
704 let _g = crate::core::data_dir::test_env_lock();
705 let tmp = tempfile::tempdir().unwrap();
706 let root = tmp.path();
707 let home = root.join("home");
708 let xc = root.join("xc");
709 let xd = root.join("xd");
710 let xs = root.join("xs");
711 let xk = root.join("xk");
712 std::fs::create_dir_all(&home).unwrap();
713
714 let _env = EnvVars::apply(&[
715 ("HOME", Some(home.as_path())),
716 ("XDG_CONFIG_HOME", Some(xc.as_path())),
717 ("XDG_DATA_HOME", Some(xd.as_path())),
718 ("XDG_STATE_HOME", Some(xs.as_path())),
719 ("XDG_CACHE_HOME", Some(xk.as_path())),
720 ("LEAN_CTX_DATA_DIR", None),
721 ("LEAN_CTX_CONFIG_DIR", None),
722 ("LEAN_CTX_STATE_DIR", None),
723 ("LEAN_CTX_CACHE_DIR", None),
724 ]);
725
726 let mixed = xc.join("lean-ctx");
728 touch(&mixed, "config.toml");
729 touch(&mixed, "events.jsonl");
730 touch(&mixed, "anomaly_detector.json");
731 touch(&mixed, "stats.json");
732 touch(&mixed.join("sessions"), "s.json");
733
734 let report = migrate().expect("mixed install must migrate");
735 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
736
737 assert!(mixed.join("config.toml").exists(), "config stays in place");
738 assert!(
739 xs.join("lean-ctx/events.jsonl").exists(),
740 "state → XDG_STATE"
741 );
742 assert!(
743 xk.join("lean-ctx/anomaly_detector.json").exists(),
744 "cache → XDG_CACHE"
745 );
746 assert!(xd.join("lean-ctx/stats.json").exists(), "data → XDG_DATA");
747 assert!(
748 xd.join("lean-ctx/sessions/s.json").exists(),
749 "data subdir → XDG_DATA"
750 );
751 assert!(
752 !mixed.join("events.jsonl").exists(),
753 "moved source file removed"
754 );
755
756 assert!(migrate().is_none(), "second run is a no-op (idempotent)");
757 }
758
759 #[cfg(unix)]
762 #[test]
763 fn migrate_respects_explicit_data_dir_override() {
764 let _g = crate::core::data_dir::test_env_lock();
765 let tmp = tempfile::tempdir().unwrap();
766 let single = tmp.path().join("single");
767 touch(&single, "stats.json");
768 touch(&single, "events.jsonl");
769
770 let _env = EnvVars::apply(&[("LEAN_CTX_DATA_DIR", Some(single.as_path()))]);
771 assert!(
772 migrate().is_none(),
773 "explicit LEAN_CTX_DATA_DIR must not be split"
774 );
775 assert!(single.join("events.jsonl").exists(), "nothing moved");
776 }
777
778 #[cfg(unix)]
783 #[test]
784 fn reclaim_legacy_drains_and_removes_residual_dir() {
785 let _g = crate::core::data_dir::test_env_lock();
786 let tmp = tempfile::tempdir().unwrap();
787 let root = tmp.path();
788 let home = root.join("home");
789 let xc = root.join("xc");
790 let xd = root.join("xd");
791 let xs = root.join("xs");
792 let xk = root.join("xk");
793 std::fs::create_dir_all(&home).unwrap();
794 let legacy = home.join(".lean-ctx");
795 touch(&legacy.join("doctor"), "latest.json");
797
798 let _env = EnvVars::apply(&[
799 ("HOME", Some(home.as_path())),
800 ("XDG_CONFIG_HOME", Some(xc.as_path())),
801 ("XDG_DATA_HOME", Some(xd.as_path())),
802 ("XDG_STATE_HOME", Some(xs.as_path())),
803 ("XDG_CACHE_HOME", Some(xk.as_path())),
804 ("LEAN_CTX_DATA_DIR", None),
805 ("LEAN_CTX_CONFIG_DIR", None),
806 ("LEAN_CTX_STATE_DIR", None),
807 ("LEAN_CTX_CACHE_DIR", None),
808 ]);
809
810 let report = reclaim_legacy().expect("residual legacy must be reclaimed");
811 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
812 assert!(
813 xd.join("lean-ctx/doctor/latest.json").exists(),
814 "report drained into XDG data"
815 );
816 assert!(!legacy.exists(), "empty legacy dir removed");
817 assert!(reclaim_legacy().is_none(), "second run is a no-op");
818 }
819}