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" | "providers.toml" | "providers" | "personas"
78 | "multi-repo.toml" => Category::Config,
79 n if n.starts_with("shell-hook.") => Category::Config,
80
81 "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 "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 "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
121struct Targets {
123 config: PathBuf,
124 data: PathBuf,
125 state: PathBuf,
126 cache: PathBuf,
127}
128
129impl Targets {
130 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 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
152struct PlannedMove {
154 from: PathBuf,
155 name: String,
156 category: &'static str,
157 dest_dir: PathBuf,
158 dest: PathBuf,
159}
160
161pub struct MigrationReport {
163 pub source: PathBuf,
165 pub moved: Vec<(String, &'static str)>,
168 pub skipped: Vec<String>,
171 pub conflicts: Vec<String>,
174 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 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
198fn 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; };
213 if dest_dir == src {
214 continue; }
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
228fn 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
244fn 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
261enum Reconciled {
263 Merged,
265 Deduped,
267 Conflict,
269}
270
271fn 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
300fn 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 let backup = backup_path(dest);
317 move_entry(from, &backup)?;
318 Ok(Reconciled::Conflict)
319}
320
321fn 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 let _ = std::fs::remove_dir(from);
336 Ok(())
337}
338
339fn 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
348fn 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
366fn detect() -> Option<(PathBuf, Targets)> {
370 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
384pub 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
395pub 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#[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 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 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 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 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 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"); let report = migrate_from(&src, &t);
518 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
519
520 assert!(src.join("config.toml").exists());
522 assert!(src.join("daemon.pid").exists());
523 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 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"); 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 touch(&src, "events.jsonl"); 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 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 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"); std::fs::create_dir_all(&t.state).unwrap();
597 std::fs::write(t.state.join("events.jsonl"), b"keep").unwrap(); 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 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 #[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 touch(&src.join("sessions"), "old.json"); std::fs::write(src.join("sessions").join("dup.json"), b"same").unwrap();
635 touch(&t.data.join("sessions"), "existing.json"); 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 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 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 #[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 #[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 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 #[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 #[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 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}