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 | "context_ir_v1.json"
99 | "thresholds_learned.json"
100 | "litm_calibration.json"
101 | "path_mode_memory.json"
102 | "efficacy_snapshots.json"
103 | "latest-version.json"
104 | ".first_run_wow_done" => Category::Cache,
105
106 "daemon.pid"
108 | "daemon.identity.json"
109 | "daemon.sock"
110 | "daemon-stderr.log"
111 | "dashboard.launch.json" => Category::Runtime,
112 n if n.starts_with(".graph-idx-") => Category::Runtime,
113
114 _ => Category::Data,
115 }
116}
117
118struct Targets {
120 config: PathBuf,
121 data: PathBuf,
122 state: PathBuf,
123 cache: PathBuf,
124}
125
126impl Targets {
127 fn resolve() -> Result<Self, String> {
129 Ok(Self {
130 config: crate::core::paths::config_split_target()?,
131 data: crate::core::paths::data_split_target()?,
132 state: crate::core::paths::state_split_target()?,
133 cache: crate::core::paths::cache_split_target()?,
134 })
135 }
136
137 fn dir_for(&self, cat: Category) -> Option<&Path> {
139 match cat {
140 Category::Config => Some(&self.config),
141 Category::Data => Some(&self.data),
142 Category::State => Some(&self.state),
143 Category::Cache => Some(&self.cache),
144 Category::Runtime => None,
145 }
146 }
147}
148
149struct PlannedMove {
151 from: PathBuf,
152 name: String,
153 category: &'static str,
154 dest_dir: PathBuf,
155 dest: PathBuf,
156}
157
158pub struct MigrationReport {
160 pub source: PathBuf,
162 pub moved: Vec<(String, &'static str)>,
165 pub skipped: Vec<String>,
168 pub conflicts: Vec<String>,
171 pub errors: Vec<String>,
173}
174
175impl MigrationReport {
176 fn new(source: &Path) -> Self {
177 Self {
178 source: source.to_path_buf(),
179 moved: Vec::new(),
180 skipped: Vec::new(),
181 conflicts: Vec::new(),
182 errors: Vec::new(),
183 }
184 }
185
186 fn is_empty(&self) -> bool {
188 self.moved.is_empty()
189 && self.skipped.is_empty()
190 && self.conflicts.is_empty()
191 && self.errors.is_empty()
192 }
193}
194
195fn entries_to_move(src: &Path, targets: &Targets) -> Vec<PlannedMove> {
199 let mut moves = Vec::new();
200 let Ok(rd) = std::fs::read_dir(src) else {
201 return moves;
202 };
203 for entry in rd.flatten() {
204 let raw_name = entry.file_name();
205 let name = raw_name.to_string_lossy().to_string();
206 let cat = categorize(&name);
207 let Some(dest_dir) = targets.dir_for(cat) else {
208 continue; };
210 if dest_dir == src {
211 continue; }
213 moves.push(PlannedMove {
214 from: entry.path(),
215 name,
216 category: cat.label(),
217 dest_dir: dest_dir.to_path_buf(),
218 dest: dest_dir.join(&raw_name),
219 });
220 }
221 moves.sort_by(|a, b| a.name.cmp(&b.name));
222 moves
223}
224
225fn copy_tree(from: &Path, to: &Path) -> std::io::Result<()> {
228 std::fs::create_dir_all(to)?;
229 for entry in std::fs::read_dir(from)? {
230 let entry = entry?;
231 let dst = to.join(entry.file_name());
232 if entry.file_type()?.is_dir() {
233 copy_tree(&entry.path(), &dst)?;
234 } else {
235 std::fs::copy(entry.path(), &dst)?;
236 }
237 }
238 Ok(())
239}
240
241fn move_entry(from: &Path, to: &Path) -> std::io::Result<()> {
245 if std::fs::rename(from, to).is_ok() {
246 return Ok(());
247 }
248 if from.is_dir() {
249 copy_tree(from, to)?;
250 std::fs::remove_dir_all(from)?;
251 } else {
252 std::fs::copy(from, to)?;
253 std::fs::remove_file(from)?;
254 }
255 Ok(())
256}
257
258enum Reconciled {
260 Merged,
262 Deduped,
264 Conflict,
266}
267
268fn migrate_from(src: &Path, targets: &Targets) -> MigrationReport {
271 let mut report = MigrationReport::new(src);
272 for mv in entries_to_move(src, targets) {
273 if let Err(e) = std::fs::create_dir_all(&mv.dest_dir) {
274 report.errors.push(format!("{}: {e}", mv.name));
275 continue;
276 }
277 crate::core::data_dir::ensure_dir_permissions(&mv.dest_dir);
278
279 if mv.dest.exists() {
280 match reconcile_existing(&mv.from, &mv.dest) {
281 Ok(Reconciled::Merged) => report.moved.push((mv.name, mv.category)),
282 Ok(Reconciled::Deduped) => report.skipped.push(mv.name),
283 Ok(Reconciled::Conflict) => report.conflicts.push(mv.name),
284 Err(e) => report.errors.push(format!("{}: {e}", mv.name)),
285 }
286 continue;
287 }
288
289 match move_entry(&mv.from, &mv.dest) {
290 Ok(()) => report.moved.push((mv.name, mv.category)),
291 Err(e) => report.errors.push(format!("{}: {e}", mv.name)),
292 }
293 }
294 report
295}
296
297fn reconcile_existing(from: &Path, dest: &Path) -> std::io::Result<Reconciled> {
302 if from.is_dir() && dest.is_dir() {
303 merge_dir(from, dest)?;
304 return Ok(Reconciled::Merged);
305 }
306 if from.is_file() && dest.is_file() && files_identical(from, dest)? {
307 std::fs::remove_file(from)?;
308 return Ok(Reconciled::Deduped);
309 }
310 let backup = backup_path(dest);
314 move_entry(from, &backup)?;
315 Ok(Reconciled::Conflict)
316}
317
318fn merge_dir(from: &Path, dest: &Path) -> std::io::Result<()> {
321 for entry in std::fs::read_dir(from)? {
322 let entry = entry?;
323 let child_dest = dest.join(entry.file_name());
324 if child_dest.exists() {
325 reconcile_existing(&entry.path(), &child_dest)?;
326 } else {
327 move_entry(&entry.path(), &child_dest)?;
328 }
329 }
330 let _ = std::fs::remove_dir(from);
333 Ok(())
334}
335
336fn files_identical(a: &Path, b: &Path) -> std::io::Result<bool> {
338 let (ma, mb) = (std::fs::metadata(a)?, std::fs::metadata(b)?);
339 if ma.len() != mb.len() {
340 return Ok(false);
341 }
342 Ok(std::fs::read(a)? == std::fs::read(b)?)
343}
344
345fn backup_path(dest: &Path) -> PathBuf {
348 let base = dest.as_os_str().to_os_string();
349 let make = |suffix: &str| {
350 let mut s = base.clone();
351 s.push(suffix);
352 PathBuf::from(s)
353 };
354 let mut candidate = make(".legacy");
355 let mut n = 2;
356 while candidate.exists() {
357 candidate = make(&format!(".legacy-{n}"));
358 n += 1;
359 }
360 candidate
361}
362
363fn detect() -> Option<(PathBuf, Targets)> {
367 if std::env::var_os("LEAN_CTX_DATA_DIR").is_some() {
368 return None;
369 }
370 let src = crate::core::paths::single_dir_override()?;
371 if !src.is_dir() {
372 return None;
373 }
374 let targets = Targets::resolve().ok()?;
375 Some((src, targets))
376}
377
378pub fn pending() -> Option<(PathBuf, usize)> {
381 let (src, targets) = detect()?;
382 let n = entries_to_move(&src, &targets).len();
383 if n == 0 {
384 return None;
385 }
386 Some((src, n))
387}
388
389pub fn migrate() -> Option<MigrationReport> {
393 let (src, targets) = detect()?;
394 let report = migrate_from(&src, &targets);
395 if report.is_empty() {
396 return None;
397 }
398 Some(report)
399}
400
401#[must_use]
428pub fn residual_legacy_present() -> bool {
429 dirs::home_dir().is_some_and(|h| h.join(".lean-ctx").is_dir())
430}
431
432pub fn reclaim_legacy() -> Option<MigrationReport> {
433 if crate::core::paths::dir_override_active() {
435 return None;
436 }
437 let legacy = dirs::home_dir()?.join(".lean-ctx");
438 if !legacy.is_dir() {
439 return None;
440 }
441 if crate::core::data_dir::lean_ctx_data_dir().ok().as_deref() == Some(legacy.as_path()) {
444 return None;
445 }
446 let targets = Targets::resolve().ok()?;
447 let report = migrate_from(&legacy, &targets);
448 let _ = std::fs::remove_dir(&legacy);
450 if report.is_empty() {
451 return None;
452 }
453 Some(report)
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 fn targets_in(root: &Path) -> Targets {
461 Targets {
462 config: root.join("config"),
463 data: root.join("data"),
464 state: root.join("state"),
465 cache: root.join("cache"),
466 }
467 }
468
469 fn touch(dir: &Path, name: &str) {
470 std::fs::create_dir_all(dir).unwrap();
471 std::fs::write(dir.join(name), b"x").unwrap();
472 }
473
474 #[test]
475 fn categorize_routes_each_category() {
476 assert_eq!(categorize("config.toml"), Category::Config);
477 assert_eq!(categorize("shell-hook.zsh"), Category::Config);
478 assert_eq!(categorize("events.jsonl"), Category::State);
479 assert_eq!(categorize("pipeline_stats.json"), Category::State);
480 assert_eq!(categorize("semantic_cache"), Category::Cache);
481 assert_eq!(categorize("models"), Category::Cache);
482 assert_eq!(categorize(".first_run_wow_done"), Category::Cache);
483 assert_eq!(categorize("daemon.sock"), Category::Runtime);
484 assert_eq!(categorize(".graph-idx-abc.lock"), Category::Runtime);
485 assert_eq!(categorize("sessions"), Category::Data);
487 assert_eq!(categorize("stats.json"), Category::Data);
488 assert_eq!(categorize("client-id.json"), Category::Data);
489 assert_eq!(categorize("something-new"), Category::Data);
490 }
491
492 #[test]
493 fn mixed_config_source_splits_data_state_cache_keeps_config() {
494 let tmp = tempfile::tempdir().unwrap();
495 let root = tmp.path();
496 let src = root.join("config");
498 let mut t = targets_in(root);
499 t.config = src.clone();
500
501 touch(&src, "config.toml");
502 touch(&src, "events.jsonl");
503 touch(&src, "thresholds_learned.json");
504 touch(&src, "stats.json");
505 touch(&src.join("sessions"), "s1.json");
506 touch(&src, "daemon.pid"); let report = migrate_from(&src, &t);
509 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
510
511 assert!(src.join("config.toml").exists());
513 assert!(src.join("daemon.pid").exists());
514 assert!(t.state.join("events.jsonl").exists());
516 assert!(t.cache.join("thresholds_learned.json").exists());
517 assert!(t.data.join("stats.json").exists());
518 assert!(t.data.join("sessions/s1.json").exists());
519 assert!(!src.join("events.jsonl").exists());
521 assert!(!src.join("sessions").exists());
522
523 let labels: Vec<_> = report.moved.iter().map(|(n, c)| (n.as_str(), *c)).collect();
524 assert!(labels.contains(&("events.jsonl", "state")));
525 assert!(labels.contains(&("thresholds_learned.json", "cache")));
526 assert!(labels.contains(&("sessions", "data")));
527 assert!(labels.contains(&("stats.json", "data")));
528 }
529
530 #[test]
531 fn legacy_source_moves_everything_including_config() {
532 let tmp = tempfile::tempdir().unwrap();
533 let root = tmp.path();
534 let src = root.join("legacy"); let t = targets_in(root);
536
537 touch(&src, "config.toml");
538 touch(&src, "events.jsonl");
539 touch(&src.join("vectors"), "v.bin");
540
541 let report = migrate_from(&src, &t);
542 assert!(report.errors.is_empty(), "{:?}", report.errors);
543 assert!(t.config.join("config.toml").exists());
544 assert!(t.state.join("events.jsonl").exists());
545 assert!(t.data.join("vectors/v.bin").exists());
546 assert!(!src.join("config.toml").exists());
547 }
548
549 #[test]
550 fn identical_dest_is_deduped_and_source_cleared() {
551 let tmp = tempfile::tempdir().unwrap();
552 let root = tmp.path();
553 let src = root.join("legacy");
554 let t = targets_in(root);
555
556 touch(&src, "events.jsonl"); std::fs::create_dir_all(&t.state).unwrap();
559 std::fs::write(t.state.join("events.jsonl"), b"x").unwrap();
560
561 let report = migrate_from(&src, &t);
562 assert!(report.errors.is_empty(), "{:?}", report.errors);
563 assert!(report.moved.is_empty(), "{:?}", report.moved);
564 assert_eq!(report.skipped, vec!["events.jsonl".to_string()]);
565 assert!(report.conflicts.is_empty(), "{:?}", report.conflicts);
566 assert!(
568 !src.join("events.jsonl").exists(),
569 "duplicate source dropped"
570 );
571 assert_eq!(
572 std::fs::read_to_string(t.state.join("events.jsonl")).unwrap(),
573 "x"
574 );
575 assert!(entries_to_move(&src, &t).is_empty());
577 }
578
579 #[test]
580 fn conflicting_dest_backs_up_source_and_clears_legacy() {
581 let tmp = tempfile::tempdir().unwrap();
582 let root = tmp.path();
583 let src = root.join("legacy");
584 let t = targets_in(root);
585
586 touch(&src, "events.jsonl"); std::fs::create_dir_all(&t.state).unwrap();
588 std::fs::write(t.state.join("events.jsonl"), b"keep").unwrap(); let report = migrate_from(&src, &t);
591 assert!(report.errors.is_empty(), "{:?}", report.errors);
592 assert_eq!(report.conflicts, vec!["events.jsonl".to_string()]);
593 assert_eq!(
595 std::fs::read_to_string(t.state.join("events.jsonl")).unwrap(),
596 "keep",
597 "existing destination must not be overwritten"
598 );
599 assert_eq!(
600 std::fs::read_to_string(t.state.join("events.jsonl.legacy")).unwrap(),
601 "x",
602 "different source preserved next to the winner"
603 );
604 assert!(!src.join("events.jsonl").exists());
605 assert!(
606 entries_to_move(&src, &t).is_empty(),
607 "warning clears once the source is reconciled"
608 );
609 }
610
611 #[test]
616 fn dir_collision_merges_and_empties_legacy_429() {
617 let tmp = tempfile::tempdir().unwrap();
618 let root = tmp.path();
619 let src = root.join("legacy");
620 let t = targets_in(root);
621
622 touch(&src.join("sessions"), "old.json"); std::fs::write(src.join("sessions").join("dup.json"), b"same").unwrap();
626 touch(&t.data.join("sessions"), "existing.json"); std::fs::write(t.data.join("sessions").join("dup.json"), b"same").unwrap();
628
629 let report = migrate_from(&src, &t);
630 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
631
632 assert!(t.data.join("sessions/existing.json").exists());
634 assert!(t.data.join("sessions/old.json").exists());
635 assert!(t.data.join("sessions/dup.json").exists());
636 assert!(!src.join("sessions").exists(), "merged source dir removed");
638 assert!(
639 entries_to_move(&src, &t).is_empty(),
640 "#429: nothing left to migrate after a merge"
641 );
642 }
643
644 #[test]
645 fn entries_to_move_is_sorted_for_determinism() {
646 let tmp = tempfile::tempdir().unwrap();
647 let root = tmp.path();
648 let src = root.join("legacy");
649 let t = targets_in(root);
650 touch(&src, "events.jsonl");
651 touch(&src, "config.toml");
652 touch(&src, "thresholds_learned.json");
653 let names: Vec<_> = entries_to_move(&src, &t)
654 .into_iter()
655 .map(|m| m.name)
656 .collect();
657 let mut sorted = names.clone();
658 sorted.sort();
659 assert_eq!(names, sorted);
660 }
661
662 #[cfg(unix)]
669 struct EnvVars(Vec<(&'static str, Option<std::ffi::OsString>)>);
670
671 #[cfg(unix)]
672 impl EnvVars {
673 fn apply(pairs: &[(&'static str, Option<&Path>)]) -> Self {
674 let saved = pairs
675 .iter()
676 .map(|(k, _)| (*k, std::env::var_os(k)))
677 .collect();
678 for (k, v) in pairs {
679 match v {
680 Some(p) => crate::test_env::set_var(k, p),
681 None => crate::test_env::remove_var(k),
682 }
683 }
684 EnvVars(saved)
685 }
686 }
687
688 #[cfg(unix)]
689 impl Drop for EnvVars {
690 fn drop(&mut self) {
691 for (k, v) in &self.0 {
692 match v {
693 Some(val) => crate::test_env::set_var(k, val),
694 None => crate::test_env::remove_var(k),
695 }
696 }
697 }
698 }
699
700 #[cfg(unix)]
705 #[test]
706 fn migrate_end_to_end_splits_mixed_xdg_config_install() {
707 let _g = crate::core::data_dir::test_env_lock();
708 let tmp = tempfile::tempdir().unwrap();
709 let root = tmp.path();
710 let home = root.join("home");
711 let xc = root.join("xc");
712 let xd = root.join("xd");
713 let xs = root.join("xs");
714 let xk = root.join("xk");
715 std::fs::create_dir_all(&home).unwrap();
716
717 let _env = EnvVars::apply(&[
718 ("HOME", Some(home.as_path())),
719 ("XDG_CONFIG_HOME", Some(xc.as_path())),
720 ("XDG_DATA_HOME", Some(xd.as_path())),
721 ("XDG_STATE_HOME", Some(xs.as_path())),
722 ("XDG_CACHE_HOME", Some(xk.as_path())),
723 ("LEAN_CTX_DATA_DIR", None),
724 ("LEAN_CTX_CONFIG_DIR", None),
725 ("LEAN_CTX_STATE_DIR", None),
726 ("LEAN_CTX_CACHE_DIR", None),
727 ]);
728
729 let mixed = xc.join("lean-ctx");
731 touch(&mixed, "config.toml");
732 touch(&mixed, "events.jsonl");
733 touch(&mixed, "thresholds_learned.json");
734 touch(&mixed, "stats.json");
735 touch(&mixed.join("sessions"), "s.json");
736
737 let report = migrate().expect("mixed install must migrate");
738 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
739
740 assert!(mixed.join("config.toml").exists(), "config stays in place");
741 assert!(
742 xs.join("lean-ctx/events.jsonl").exists(),
743 "state → XDG_STATE"
744 );
745 assert!(
746 xk.join("lean-ctx/thresholds_learned.json").exists(),
747 "cache → XDG_CACHE"
748 );
749 assert!(xd.join("lean-ctx/stats.json").exists(), "data → XDG_DATA");
750 assert!(
751 xd.join("lean-ctx/sessions/s.json").exists(),
752 "data subdir → XDG_DATA"
753 );
754 assert!(
755 !mixed.join("events.jsonl").exists(),
756 "moved source file removed"
757 );
758
759 assert!(migrate().is_none(), "second run is a no-op (idempotent)");
760 }
761
762 #[cfg(unix)]
765 #[test]
766 fn migrate_respects_explicit_data_dir_override() {
767 let _g = crate::core::data_dir::test_env_lock();
768 let tmp = tempfile::tempdir().unwrap();
769 let single = tmp.path().join("single");
770 touch(&single, "stats.json");
771 touch(&single, "events.jsonl");
772
773 let _env = EnvVars::apply(&[("LEAN_CTX_DATA_DIR", Some(single.as_path()))]);
774 assert!(
775 migrate().is_none(),
776 "explicit LEAN_CTX_DATA_DIR must not be split"
777 );
778 assert!(single.join("events.jsonl").exists(), "nothing moved");
779 }
780
781 #[cfg(unix)]
786 #[test]
787 fn reclaim_legacy_drains_and_removes_residual_dir() {
788 let _g = crate::core::data_dir::test_env_lock();
789 let tmp = tempfile::tempdir().unwrap();
790 let root = tmp.path();
791 let home = root.join("home");
792 let xc = root.join("xc");
793 let xd = root.join("xd");
794 let xs = root.join("xs");
795 let xk = root.join("xk");
796 std::fs::create_dir_all(&home).unwrap();
797 let legacy = home.join(".lean-ctx");
798 touch(&legacy.join("doctor"), "latest.json");
800
801 let _env = EnvVars::apply(&[
802 ("HOME", Some(home.as_path())),
803 ("XDG_CONFIG_HOME", Some(xc.as_path())),
804 ("XDG_DATA_HOME", Some(xd.as_path())),
805 ("XDG_STATE_HOME", Some(xs.as_path())),
806 ("XDG_CACHE_HOME", Some(xk.as_path())),
807 ("LEAN_CTX_DATA_DIR", None),
808 ("LEAN_CTX_CONFIG_DIR", None),
809 ("LEAN_CTX_STATE_DIR", None),
810 ("LEAN_CTX_CACHE_DIR", None),
811 ]);
812
813 let report = reclaim_legacy().expect("residual legacy must be reclaimed");
814 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
815 assert!(
816 xd.join("lean-ctx/doctor/latest.json").exists(),
817 "report drained into XDG data"
818 );
819 assert!(!legacy.exists(), "empty legacy dir removed");
820 assert!(reclaim_legacy().is_none(), "second run is a no-op");
821 }
822}