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
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 fn targets_in(root: &Path) -> Targets {
404 Targets {
405 config: root.join("config"),
406 data: root.join("data"),
407 state: root.join("state"),
408 cache: root.join("cache"),
409 }
410 }
411
412 fn touch(dir: &Path, name: &str) {
413 std::fs::create_dir_all(dir).unwrap();
414 std::fs::write(dir.join(name), b"x").unwrap();
415 }
416
417 #[test]
418 fn categorize_routes_each_category() {
419 assert_eq!(categorize("config.toml"), Category::Config);
420 assert_eq!(categorize("shell-hook.zsh"), Category::Config);
421 assert_eq!(categorize("events.jsonl"), Category::State);
422 assert_eq!(categorize("pipeline_stats.json"), Category::State);
423 assert_eq!(categorize("semantic_cache"), Category::Cache);
424 assert_eq!(categorize("models"), Category::Cache);
425 assert_eq!(categorize(".first_run_wow_done"), Category::Cache);
426 assert_eq!(categorize("daemon.sock"), Category::Runtime);
427 assert_eq!(categorize(".graph-idx-abc.lock"), Category::Runtime);
428 assert_eq!(categorize("sessions"), Category::Data);
430 assert_eq!(categorize("stats.json"), Category::Data);
431 assert_eq!(categorize("client-id.json"), Category::Data);
432 assert_eq!(categorize("something-new"), Category::Data);
433 }
434
435 #[test]
436 fn mixed_config_source_splits_data_state_cache_keeps_config() {
437 let tmp = tempfile::tempdir().unwrap();
438 let root = tmp.path();
439 let src = root.join("config");
441 let mut t = targets_in(root);
442 t.config = src.clone();
443
444 touch(&src, "config.toml");
445 touch(&src, "events.jsonl");
446 touch(&src, "anomaly_detector.json");
447 touch(&src, "stats.json");
448 touch(&src.join("sessions"), "s1.json");
449 touch(&src, "daemon.pid"); let report = migrate_from(&src, &t);
452 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
453
454 assert!(src.join("config.toml").exists());
456 assert!(src.join("daemon.pid").exists());
457 assert!(t.state.join("events.jsonl").exists());
459 assert!(t.cache.join("anomaly_detector.json").exists());
460 assert!(t.data.join("stats.json").exists());
461 assert!(t.data.join("sessions/s1.json").exists());
462 assert!(!src.join("events.jsonl").exists());
464 assert!(!src.join("sessions").exists());
465
466 let labels: Vec<_> = report.moved.iter().map(|(n, c)| (n.as_str(), *c)).collect();
467 assert!(labels.contains(&("events.jsonl", "state")));
468 assert!(labels.contains(&("anomaly_detector.json", "cache")));
469 assert!(labels.contains(&("sessions", "data")));
470 assert!(labels.contains(&("stats.json", "data")));
471 }
472
473 #[test]
474 fn legacy_source_moves_everything_including_config() {
475 let tmp = tempfile::tempdir().unwrap();
476 let root = tmp.path();
477 let src = root.join("legacy"); let t = targets_in(root);
479
480 touch(&src, "config.toml");
481 touch(&src, "events.jsonl");
482 touch(&src.join("vectors"), "v.bin");
483
484 let report = migrate_from(&src, &t);
485 assert!(report.errors.is_empty());
486 assert!(t.config.join("config.toml").exists());
487 assert!(t.state.join("events.jsonl").exists());
488 assert!(t.data.join("vectors/v.bin").exists());
489 assert!(!src.join("config.toml").exists());
490 }
491
492 #[test]
493 fn identical_dest_is_deduped_and_source_cleared() {
494 let tmp = tempfile::tempdir().unwrap();
495 let root = tmp.path();
496 let src = root.join("legacy");
497 let t = targets_in(root);
498
499 touch(&src, "events.jsonl"); std::fs::create_dir_all(&t.state).unwrap();
502 std::fs::write(t.state.join("events.jsonl"), b"x").unwrap();
503
504 let report = migrate_from(&src, &t);
505 assert!(report.errors.is_empty());
506 assert!(report.moved.is_empty());
507 assert_eq!(report.skipped, vec!["events.jsonl".to_string()]);
508 assert!(report.conflicts.is_empty());
509 assert!(
511 !src.join("events.jsonl").exists(),
512 "duplicate source dropped"
513 );
514 assert_eq!(
515 std::fs::read_to_string(t.state.join("events.jsonl")).unwrap(),
516 "x"
517 );
518 assert!(entries_to_move(&src, &t).is_empty());
520 }
521
522 #[test]
523 fn conflicting_dest_backs_up_source_and_clears_legacy() {
524 let tmp = tempfile::tempdir().unwrap();
525 let root = tmp.path();
526 let src = root.join("legacy");
527 let t = targets_in(root);
528
529 touch(&src, "events.jsonl"); std::fs::create_dir_all(&t.state).unwrap();
531 std::fs::write(t.state.join("events.jsonl"), b"keep").unwrap(); let report = migrate_from(&src, &t);
534 assert!(report.errors.is_empty());
535 assert_eq!(report.conflicts, vec!["events.jsonl".to_string()]);
536 assert_eq!(
538 std::fs::read_to_string(t.state.join("events.jsonl")).unwrap(),
539 "keep",
540 "existing destination must not be overwritten"
541 );
542 assert_eq!(
543 std::fs::read_to_string(t.state.join("events.jsonl.legacy")).unwrap(),
544 "x",
545 "different source preserved next to the winner"
546 );
547 assert!(!src.join("events.jsonl").exists());
548 assert!(
549 entries_to_move(&src, &t).is_empty(),
550 "warning clears once the source is reconciled"
551 );
552 }
553
554 #[test]
559 fn dir_collision_merges_and_empties_legacy_429() {
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.join("sessions"), "old.json"); std::fs::write(src.join("sessions").join("dup.json"), b"same").unwrap();
569 touch(&t.data.join("sessions"), "existing.json"); std::fs::write(t.data.join("sessions").join("dup.json"), b"same").unwrap();
571
572 let report = migrate_from(&src, &t);
573 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
574
575 assert!(t.data.join("sessions/existing.json").exists());
577 assert!(t.data.join("sessions/old.json").exists());
578 assert!(t.data.join("sessions/dup.json").exists());
579 assert!(!src.join("sessions").exists(), "merged source dir removed");
581 assert!(
582 entries_to_move(&src, &t).is_empty(),
583 "#429: nothing left to migrate after a merge"
584 );
585 }
586
587 #[test]
588 fn entries_to_move_is_sorted_for_determinism() {
589 let tmp = tempfile::tempdir().unwrap();
590 let root = tmp.path();
591 let src = root.join("legacy");
592 let t = targets_in(root);
593 touch(&src, "events.jsonl");
594 touch(&src, "config.toml");
595 touch(&src, "anomaly_detector.json");
596 let names: Vec<_> = entries_to_move(&src, &t)
597 .into_iter()
598 .map(|m| m.name)
599 .collect();
600 let mut sorted = names.clone();
601 sorted.sort();
602 assert_eq!(names, sorted);
603 }
604
605 #[cfg(unix)]
612 struct EnvVars(Vec<(&'static str, Option<std::ffi::OsString>)>);
613
614 #[cfg(unix)]
615 impl EnvVars {
616 fn apply(pairs: &[(&'static str, Option<&Path>)]) -> Self {
617 let saved = pairs
618 .iter()
619 .map(|(k, _)| (*k, std::env::var_os(k)))
620 .collect();
621 for (k, v) in pairs {
622 match v {
623 Some(p) => std::env::set_var(k, p),
624 None => std::env::remove_var(k),
625 }
626 }
627 EnvVars(saved)
628 }
629 }
630
631 #[cfg(unix)]
632 impl Drop for EnvVars {
633 fn drop(&mut self) {
634 for (k, v) in &self.0 {
635 match v {
636 Some(val) => std::env::set_var(k, val),
637 None => std::env::remove_var(k),
638 }
639 }
640 }
641 }
642
643 #[cfg(unix)]
648 #[test]
649 fn migrate_end_to_end_splits_mixed_xdg_config_install() {
650 let _g = crate::core::data_dir::test_env_lock();
651 let tmp = tempfile::tempdir().unwrap();
652 let root = tmp.path();
653 let home = root.join("home");
654 let xc = root.join("xc");
655 let xd = root.join("xd");
656 let xs = root.join("xs");
657 let xk = root.join("xk");
658 std::fs::create_dir_all(&home).unwrap();
659
660 let _env = EnvVars::apply(&[
661 ("HOME", Some(home.as_path())),
662 ("XDG_CONFIG_HOME", Some(xc.as_path())),
663 ("XDG_DATA_HOME", Some(xd.as_path())),
664 ("XDG_STATE_HOME", Some(xs.as_path())),
665 ("XDG_CACHE_HOME", Some(xk.as_path())),
666 ("LEAN_CTX_DATA_DIR", None),
667 ("LEAN_CTX_CONFIG_DIR", None),
668 ("LEAN_CTX_STATE_DIR", None),
669 ("LEAN_CTX_CACHE_DIR", None),
670 ]);
671
672 let mixed = xc.join("lean-ctx");
674 touch(&mixed, "config.toml");
675 touch(&mixed, "events.jsonl");
676 touch(&mixed, "anomaly_detector.json");
677 touch(&mixed, "stats.json");
678 touch(&mixed.join("sessions"), "s.json");
679
680 let report = migrate().expect("mixed install must migrate");
681 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
682
683 assert!(mixed.join("config.toml").exists(), "config stays in place");
684 assert!(
685 xs.join("lean-ctx/events.jsonl").exists(),
686 "state → XDG_STATE"
687 );
688 assert!(
689 xk.join("lean-ctx/anomaly_detector.json").exists(),
690 "cache → XDG_CACHE"
691 );
692 assert!(xd.join("lean-ctx/stats.json").exists(), "data → XDG_DATA");
693 assert!(
694 xd.join("lean-ctx/sessions/s.json").exists(),
695 "data subdir → XDG_DATA"
696 );
697 assert!(
698 !mixed.join("events.jsonl").exists(),
699 "moved source file removed"
700 );
701
702 assert!(migrate().is_none(), "second run is a no-op (idempotent)");
703 }
704
705 #[cfg(unix)]
708 #[test]
709 fn migrate_respects_explicit_data_dir_override() {
710 let _g = crate::core::data_dir::test_env_lock();
711 let tmp = tempfile::tempdir().unwrap();
712 let single = tmp.path().join("single");
713 touch(&single, "stats.json");
714 touch(&single, "events.jsonl");
715
716 let _env = EnvVars::apply(&[("LEAN_CTX_DATA_DIR", Some(single.as_path()))]);
717 assert!(
718 migrate().is_none(),
719 "explicit LEAN_CTX_DATA_DIR must not be split"
720 );
721 assert!(single.join("events.jsonl").exists(), "nothing moved");
722 }
723}