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" => 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
399pub fn reclaim_legacy() -> Option<MigrationReport> {
416 if std::env::var_os("LEAN_CTX_DATA_DIR").is_some() {
417 return None;
418 }
419 let legacy = dirs::home_dir()?.join(".lean-ctx");
420 if !legacy.is_dir() {
421 return None;
422 }
423 if crate::core::data_dir::lean_ctx_data_dir().ok().as_deref() == Some(legacy.as_path()) {
426 return None;
427 }
428 let targets = Targets::resolve().ok()?;
429 let report = migrate_from(&legacy, &targets);
430 let _ = std::fs::remove_dir(&legacy);
432 if report.is_empty() {
433 return None;
434 }
435 Some(report)
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 fn targets_in(root: &Path) -> Targets {
443 Targets {
444 config: root.join("config"),
445 data: root.join("data"),
446 state: root.join("state"),
447 cache: root.join("cache"),
448 }
449 }
450
451 fn touch(dir: &Path, name: &str) {
452 std::fs::create_dir_all(dir).unwrap();
453 std::fs::write(dir.join(name), b"x").unwrap();
454 }
455
456 #[test]
457 fn categorize_routes_each_category() {
458 assert_eq!(categorize("config.toml"), Category::Config);
459 assert_eq!(categorize("shell-hook.zsh"), Category::Config);
460 assert_eq!(categorize("events.jsonl"), Category::State);
461 assert_eq!(categorize("pipeline_stats.json"), Category::State);
462 assert_eq!(categorize("semantic_cache"), Category::Cache);
463 assert_eq!(categorize("models"), Category::Cache);
464 assert_eq!(categorize(".first_run_wow_done"), Category::Cache);
465 assert_eq!(categorize("daemon.sock"), Category::Runtime);
466 assert_eq!(categorize(".graph-idx-abc.lock"), Category::Runtime);
467 assert_eq!(categorize("sessions"), Category::Data);
469 assert_eq!(categorize("stats.json"), Category::Data);
470 assert_eq!(categorize("client-id.json"), Category::Data);
471 assert_eq!(categorize("something-new"), Category::Data);
472 }
473
474 #[test]
475 fn mixed_config_source_splits_data_state_cache_keeps_config() {
476 let tmp = tempfile::tempdir().unwrap();
477 let root = tmp.path();
478 let src = root.join("config");
480 let mut t = targets_in(root);
481 t.config = src.clone();
482
483 touch(&src, "config.toml");
484 touch(&src, "events.jsonl");
485 touch(&src, "anomaly_detector.json");
486 touch(&src, "stats.json");
487 touch(&src.join("sessions"), "s1.json");
488 touch(&src, "daemon.pid"); let report = migrate_from(&src, &t);
491 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
492
493 assert!(src.join("config.toml").exists());
495 assert!(src.join("daemon.pid").exists());
496 assert!(t.state.join("events.jsonl").exists());
498 assert!(t.cache.join("anomaly_detector.json").exists());
499 assert!(t.data.join("stats.json").exists());
500 assert!(t.data.join("sessions/s1.json").exists());
501 assert!(!src.join("events.jsonl").exists());
503 assert!(!src.join("sessions").exists());
504
505 let labels: Vec<_> = report.moved.iter().map(|(n, c)| (n.as_str(), *c)).collect();
506 assert!(labels.contains(&("events.jsonl", "state")));
507 assert!(labels.contains(&("anomaly_detector.json", "cache")));
508 assert!(labels.contains(&("sessions", "data")));
509 assert!(labels.contains(&("stats.json", "data")));
510 }
511
512 #[test]
513 fn legacy_source_moves_everything_including_config() {
514 let tmp = tempfile::tempdir().unwrap();
515 let root = tmp.path();
516 let src = root.join("legacy"); let t = targets_in(root);
518
519 touch(&src, "config.toml");
520 touch(&src, "events.jsonl");
521 touch(&src.join("vectors"), "v.bin");
522
523 let report = migrate_from(&src, &t);
524 assert!(report.errors.is_empty());
525 assert!(t.config.join("config.toml").exists());
526 assert!(t.state.join("events.jsonl").exists());
527 assert!(t.data.join("vectors/v.bin").exists());
528 assert!(!src.join("config.toml").exists());
529 }
530
531 #[test]
532 fn identical_dest_is_deduped_and_source_cleared() {
533 let tmp = tempfile::tempdir().unwrap();
534 let root = tmp.path();
535 let src = root.join("legacy");
536 let t = targets_in(root);
537
538 touch(&src, "events.jsonl"); std::fs::create_dir_all(&t.state).unwrap();
541 std::fs::write(t.state.join("events.jsonl"), b"x").unwrap();
542
543 let report = migrate_from(&src, &t);
544 assert!(report.errors.is_empty());
545 assert!(report.moved.is_empty());
546 assert_eq!(report.skipped, vec!["events.jsonl".to_string()]);
547 assert!(report.conflicts.is_empty());
548 assert!(
550 !src.join("events.jsonl").exists(),
551 "duplicate source dropped"
552 );
553 assert_eq!(
554 std::fs::read_to_string(t.state.join("events.jsonl")).unwrap(),
555 "x"
556 );
557 assert!(entries_to_move(&src, &t).is_empty());
559 }
560
561 #[test]
562 fn conflicting_dest_backs_up_source_and_clears_legacy() {
563 let tmp = tempfile::tempdir().unwrap();
564 let root = tmp.path();
565 let src = root.join("legacy");
566 let t = targets_in(root);
567
568 touch(&src, "events.jsonl"); std::fs::create_dir_all(&t.state).unwrap();
570 std::fs::write(t.state.join("events.jsonl"), b"keep").unwrap(); let report = migrate_from(&src, &t);
573 assert!(report.errors.is_empty());
574 assert_eq!(report.conflicts, vec!["events.jsonl".to_string()]);
575 assert_eq!(
577 std::fs::read_to_string(t.state.join("events.jsonl")).unwrap(),
578 "keep",
579 "existing destination must not be overwritten"
580 );
581 assert_eq!(
582 std::fs::read_to_string(t.state.join("events.jsonl.legacy")).unwrap(),
583 "x",
584 "different source preserved next to the winner"
585 );
586 assert!(!src.join("events.jsonl").exists());
587 assert!(
588 entries_to_move(&src, &t).is_empty(),
589 "warning clears once the source is reconciled"
590 );
591 }
592
593 #[test]
598 fn dir_collision_merges_and_empties_legacy_429() {
599 let tmp = tempfile::tempdir().unwrap();
600 let root = tmp.path();
601 let src = root.join("legacy");
602 let t = targets_in(root);
603
604 touch(&src.join("sessions"), "old.json"); std::fs::write(src.join("sessions").join("dup.json"), b"same").unwrap();
608 touch(&t.data.join("sessions"), "existing.json"); std::fs::write(t.data.join("sessions").join("dup.json"), b"same").unwrap();
610
611 let report = migrate_from(&src, &t);
612 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
613
614 assert!(t.data.join("sessions/existing.json").exists());
616 assert!(t.data.join("sessions/old.json").exists());
617 assert!(t.data.join("sessions/dup.json").exists());
618 assert!(!src.join("sessions").exists(), "merged source dir removed");
620 assert!(
621 entries_to_move(&src, &t).is_empty(),
622 "#429: nothing left to migrate after a merge"
623 );
624 }
625
626 #[test]
627 fn entries_to_move_is_sorted_for_determinism() {
628 let tmp = tempfile::tempdir().unwrap();
629 let root = tmp.path();
630 let src = root.join("legacy");
631 let t = targets_in(root);
632 touch(&src, "events.jsonl");
633 touch(&src, "config.toml");
634 touch(&src, "anomaly_detector.json");
635 let names: Vec<_> = entries_to_move(&src, &t)
636 .into_iter()
637 .map(|m| m.name)
638 .collect();
639 let mut sorted = names.clone();
640 sorted.sort();
641 assert_eq!(names, sorted);
642 }
643
644 #[cfg(unix)]
651 struct EnvVars(Vec<(&'static str, Option<std::ffi::OsString>)>);
652
653 #[cfg(unix)]
654 impl EnvVars {
655 fn apply(pairs: &[(&'static str, Option<&Path>)]) -> Self {
656 let saved = pairs
657 .iter()
658 .map(|(k, _)| (*k, std::env::var_os(k)))
659 .collect();
660 for (k, v) in pairs {
661 match v {
662 Some(p) => crate::test_env::set_var(k, p),
663 None => crate::test_env::remove_var(k),
664 }
665 }
666 EnvVars(saved)
667 }
668 }
669
670 #[cfg(unix)]
671 impl Drop for EnvVars {
672 fn drop(&mut self) {
673 for (k, v) in &self.0 {
674 match v {
675 Some(val) => crate::test_env::set_var(k, val),
676 None => crate::test_env::remove_var(k),
677 }
678 }
679 }
680 }
681
682 #[cfg(unix)]
687 #[test]
688 fn migrate_end_to_end_splits_mixed_xdg_config_install() {
689 let _g = crate::core::data_dir::test_env_lock();
690 let tmp = tempfile::tempdir().unwrap();
691 let root = tmp.path();
692 let home = root.join("home");
693 let xc = root.join("xc");
694 let xd = root.join("xd");
695 let xs = root.join("xs");
696 let xk = root.join("xk");
697 std::fs::create_dir_all(&home).unwrap();
698
699 let _env = EnvVars::apply(&[
700 ("HOME", Some(home.as_path())),
701 ("XDG_CONFIG_HOME", Some(xc.as_path())),
702 ("XDG_DATA_HOME", Some(xd.as_path())),
703 ("XDG_STATE_HOME", Some(xs.as_path())),
704 ("XDG_CACHE_HOME", Some(xk.as_path())),
705 ("LEAN_CTX_DATA_DIR", None),
706 ("LEAN_CTX_CONFIG_DIR", None),
707 ("LEAN_CTX_STATE_DIR", None),
708 ("LEAN_CTX_CACHE_DIR", None),
709 ]);
710
711 let mixed = xc.join("lean-ctx");
713 touch(&mixed, "config.toml");
714 touch(&mixed, "events.jsonl");
715 touch(&mixed, "anomaly_detector.json");
716 touch(&mixed, "stats.json");
717 touch(&mixed.join("sessions"), "s.json");
718
719 let report = migrate().expect("mixed install must migrate");
720 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
721
722 assert!(mixed.join("config.toml").exists(), "config stays in place");
723 assert!(
724 xs.join("lean-ctx/events.jsonl").exists(),
725 "state → XDG_STATE"
726 );
727 assert!(
728 xk.join("lean-ctx/anomaly_detector.json").exists(),
729 "cache → XDG_CACHE"
730 );
731 assert!(xd.join("lean-ctx/stats.json").exists(), "data → XDG_DATA");
732 assert!(
733 xd.join("lean-ctx/sessions/s.json").exists(),
734 "data subdir → XDG_DATA"
735 );
736 assert!(
737 !mixed.join("events.jsonl").exists(),
738 "moved source file removed"
739 );
740
741 assert!(migrate().is_none(), "second run is a no-op (idempotent)");
742 }
743
744 #[cfg(unix)]
747 #[test]
748 fn migrate_respects_explicit_data_dir_override() {
749 let _g = crate::core::data_dir::test_env_lock();
750 let tmp = tempfile::tempdir().unwrap();
751 let single = tmp.path().join("single");
752 touch(&single, "stats.json");
753 touch(&single, "events.jsonl");
754
755 let _env = EnvVars::apply(&[("LEAN_CTX_DATA_DIR", Some(single.as_path()))]);
756 assert!(
757 migrate().is_none(),
758 "explicit LEAN_CTX_DATA_DIR must not be split"
759 );
760 assert!(single.join("events.jsonl").exists(), "nothing moved");
761 }
762
763 #[cfg(unix)]
768 #[test]
769 fn reclaim_legacy_drains_and_removes_residual_dir() {
770 let _g = crate::core::data_dir::test_env_lock();
771 let tmp = tempfile::tempdir().unwrap();
772 let root = tmp.path();
773 let home = root.join("home");
774 let xc = root.join("xc");
775 let xd = root.join("xd");
776 let xs = root.join("xs");
777 let xk = root.join("xk");
778 std::fs::create_dir_all(&home).unwrap();
779 let legacy = home.join(".lean-ctx");
780 touch(&legacy.join("doctor"), "latest.json");
782
783 let _env = EnvVars::apply(&[
784 ("HOME", Some(home.as_path())),
785 ("XDG_CONFIG_HOME", Some(xc.as_path())),
786 ("XDG_DATA_HOME", Some(xd.as_path())),
787 ("XDG_STATE_HOME", Some(xs.as_path())),
788 ("XDG_CACHE_HOME", Some(xk.as_path())),
789 ("LEAN_CTX_DATA_DIR", None),
790 ("LEAN_CTX_CONFIG_DIR", None),
791 ("LEAN_CTX_STATE_DIR", None),
792 ("LEAN_CTX_CACHE_DIR", None),
793 ]);
794
795 let report = reclaim_legacy().expect("residual legacy must be reclaimed");
796 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
797 assert!(
798 xd.join("lean-ctx/doctor/latest.json").exists(),
799 "report drained into XDG data"
800 );
801 assert!(!legacy.exists(), "empty legacy dir removed");
802 assert!(reclaim_legacy().is_none(), "second run is a no-op");
803 }
804}