1use std::path::{Path, PathBuf};
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38enum Category {
39 Config,
40 Data,
41 State,
42 Cache,
43 Runtime,
45}
46
47impl Category {
48 fn label(self) -> &'static str {
50 match self {
51 Category::Config => "config",
52 Category::Data => "data",
53 Category::State => "state",
54 Category::Cache => "cache",
55 Category::Runtime => "runtime",
56 }
57 }
58}
59
60fn categorize(name: &str) -> Category {
67 match name {
68 "config.toml" | "env.sh" => Category::Config,
70 n if n.starts_with("shell-hook.") => Category::Config,
71
72 "events.jsonl"
74 | "journal.md"
75 | "tool-calls.log"
76 | "mcp-live.json"
77 | "feedback.json"
78 | "cost_attribution.json"
79 | "context_ledger.json"
80 | "ledger"
81 | "cooccurrence"
82 | "slow-commands.log"
83 | "pipeline_stats.json"
84 | "heatmap.json"
85 | "tee"
86 | "dashboard.token"
87 | "agent_runtime_env.json" => Category::State,
88
89 "semantic_cache"
91 | "models"
92 | "anomaly_detector.json"
93 | "autonomy_drivers_v1.json"
94 | "context_ir_v1.json"
95 | "thresholds_learned.json"
96 | "litm_calibration.json"
97 | "path_mode_memory.json"
98 | "efficacy_snapshots.json"
99 | "latest-version.json"
100 | ".first_run_wow_done" => Category::Cache,
101
102 "daemon.pid" | "daemon.sock" | "daemon-stderr.log" => Category::Runtime,
104 n if n.starts_with(".graph-idx-") => Category::Runtime,
105
106 _ => Category::Data,
107 }
108}
109
110struct Targets {
112 config: PathBuf,
113 data: PathBuf,
114 state: PathBuf,
115 cache: PathBuf,
116}
117
118impl Targets {
119 fn resolve() -> Result<Self, String> {
121 Ok(Self {
122 config: crate::core::paths::config_split_target()?,
123 data: crate::core::paths::data_split_target()?,
124 state: crate::core::paths::state_split_target()?,
125 cache: crate::core::paths::cache_split_target()?,
126 })
127 }
128
129 fn dir_for(&self, cat: Category) -> Option<&Path> {
131 match cat {
132 Category::Config => Some(&self.config),
133 Category::Data => Some(&self.data),
134 Category::State => Some(&self.state),
135 Category::Cache => Some(&self.cache),
136 Category::Runtime => None,
137 }
138 }
139}
140
141struct PlannedMove {
143 from: PathBuf,
144 name: String,
145 category: &'static str,
146 dest_dir: PathBuf,
147 dest: PathBuf,
148}
149
150pub struct MigrationReport {
152 pub source: PathBuf,
154 pub moved: Vec<(String, &'static str)>,
156 pub skipped: Vec<String>,
158 pub errors: Vec<String>,
160}
161
162impl MigrationReport {
163 fn new(source: &Path) -> Self {
164 Self {
165 source: source.to_path_buf(),
166 moved: Vec::new(),
167 skipped: Vec::new(),
168 errors: Vec::new(),
169 }
170 }
171
172 fn is_empty(&self) -> bool {
174 self.moved.is_empty() && self.skipped.is_empty() && self.errors.is_empty()
175 }
176}
177
178fn entries_to_move(src: &Path, targets: &Targets) -> Vec<PlannedMove> {
182 let mut moves = Vec::new();
183 let Ok(rd) = std::fs::read_dir(src) else {
184 return moves;
185 };
186 for entry in rd.flatten() {
187 let raw_name = entry.file_name();
188 let name = raw_name.to_string_lossy().to_string();
189 let cat = categorize(&name);
190 let Some(dest_dir) = targets.dir_for(cat) else {
191 continue; };
193 if dest_dir == src {
194 continue; }
196 moves.push(PlannedMove {
197 from: entry.path(),
198 name,
199 category: cat.label(),
200 dest_dir: dest_dir.to_path_buf(),
201 dest: dest_dir.join(&raw_name),
202 });
203 }
204 moves.sort_by(|a, b| a.name.cmp(&b.name));
205 moves
206}
207
208fn copy_tree(from: &Path, to: &Path) -> std::io::Result<()> {
211 std::fs::create_dir_all(to)?;
212 for entry in std::fs::read_dir(from)? {
213 let entry = entry?;
214 let dst = to.join(entry.file_name());
215 if entry.file_type()?.is_dir() {
216 copy_tree(&entry.path(), &dst)?;
217 } else {
218 std::fs::copy(entry.path(), &dst)?;
219 }
220 }
221 Ok(())
222}
223
224fn move_entry(from: &Path, to: &Path) -> std::io::Result<()> {
228 if std::fs::rename(from, to).is_ok() {
229 return Ok(());
230 }
231 if from.is_dir() {
232 copy_tree(from, to)?;
233 std::fs::remove_dir_all(from)?;
234 } else {
235 std::fs::copy(from, to)?;
236 std::fs::remove_file(from)?;
237 }
238 Ok(())
239}
240
241fn migrate_from(src: &Path, targets: &Targets) -> MigrationReport {
244 let mut report = MigrationReport::new(src);
245 for mv in entries_to_move(src, targets) {
246 if mv.dest.exists() {
247 report.skipped.push(mv.name);
248 continue;
249 }
250 if let Err(e) = std::fs::create_dir_all(&mv.dest_dir) {
251 report.errors.push(format!("{}: {e}", mv.name));
252 continue;
253 }
254 crate::core::data_dir::ensure_dir_permissions(&mv.dest_dir);
255 match move_entry(&mv.from, &mv.dest) {
256 Ok(()) => report.moved.push((mv.name, mv.category)),
257 Err(e) => report.errors.push(format!("{}: {e}", mv.name)),
258 }
259 }
260 report
261}
262
263fn detect() -> Option<(PathBuf, Targets)> {
267 if std::env::var_os("LEAN_CTX_DATA_DIR").is_some() {
268 return None;
269 }
270 let src = crate::core::paths::single_dir_override()?;
271 if !src.is_dir() {
272 return None;
273 }
274 let targets = Targets::resolve().ok()?;
275 Some((src, targets))
276}
277
278pub fn pending() -> Option<(PathBuf, usize)> {
281 let (src, targets) = detect()?;
282 let n = entries_to_move(&src, &targets).len();
283 if n == 0 {
284 return None;
285 }
286 Some((src, n))
287}
288
289pub fn migrate() -> Option<MigrationReport> {
293 let (src, targets) = detect()?;
294 let report = migrate_from(&src, &targets);
295 if report.is_empty() {
296 return None;
297 }
298 Some(report)
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 fn targets_in(root: &Path) -> Targets {
306 Targets {
307 config: root.join("config"),
308 data: root.join("data"),
309 state: root.join("state"),
310 cache: root.join("cache"),
311 }
312 }
313
314 fn touch(dir: &Path, name: &str) {
315 std::fs::create_dir_all(dir).unwrap();
316 std::fs::write(dir.join(name), b"x").unwrap();
317 }
318
319 #[test]
320 fn categorize_routes_each_category() {
321 assert_eq!(categorize("config.toml"), Category::Config);
322 assert_eq!(categorize("shell-hook.zsh"), Category::Config);
323 assert_eq!(categorize("events.jsonl"), Category::State);
324 assert_eq!(categorize("pipeline_stats.json"), Category::State);
325 assert_eq!(categorize("semantic_cache"), Category::Cache);
326 assert_eq!(categorize("models"), Category::Cache);
327 assert_eq!(categorize(".first_run_wow_done"), Category::Cache);
328 assert_eq!(categorize("daemon.sock"), Category::Runtime);
329 assert_eq!(categorize(".graph-idx-abc.lock"), Category::Runtime);
330 assert_eq!(categorize("sessions"), Category::Data);
332 assert_eq!(categorize("stats.json"), Category::Data);
333 assert_eq!(categorize("client-id.json"), Category::Data);
334 assert_eq!(categorize("something-new"), Category::Data);
335 }
336
337 #[test]
338 fn mixed_config_source_splits_data_state_cache_keeps_config() {
339 let tmp = tempfile::tempdir().unwrap();
340 let root = tmp.path();
341 let src = root.join("config");
343 let mut t = targets_in(root);
344 t.config = src.clone();
345
346 touch(&src, "config.toml");
347 touch(&src, "events.jsonl");
348 touch(&src, "anomaly_detector.json");
349 touch(&src, "stats.json");
350 touch(&src.join("sessions"), "s1.json");
351 touch(&src, "daemon.pid"); let report = migrate_from(&src, &t);
354 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
355
356 assert!(src.join("config.toml").exists());
358 assert!(src.join("daemon.pid").exists());
359 assert!(t.state.join("events.jsonl").exists());
361 assert!(t.cache.join("anomaly_detector.json").exists());
362 assert!(t.data.join("stats.json").exists());
363 assert!(t.data.join("sessions/s1.json").exists());
364 assert!(!src.join("events.jsonl").exists());
366 assert!(!src.join("sessions").exists());
367
368 let labels: Vec<_> = report.moved.iter().map(|(n, c)| (n.as_str(), *c)).collect();
369 assert!(labels.contains(&("events.jsonl", "state")));
370 assert!(labels.contains(&("anomaly_detector.json", "cache")));
371 assert!(labels.contains(&("sessions", "data")));
372 assert!(labels.contains(&("stats.json", "data")));
373 }
374
375 #[test]
376 fn legacy_source_moves_everything_including_config() {
377 let tmp = tempfile::tempdir().unwrap();
378 let root = tmp.path();
379 let src = root.join("legacy"); let t = targets_in(root);
381
382 touch(&src, "config.toml");
383 touch(&src, "events.jsonl");
384 touch(&src.join("vectors"), "v.bin");
385
386 let report = migrate_from(&src, &t);
387 assert!(report.errors.is_empty());
388 assert!(t.config.join("config.toml").exists());
389 assert!(t.state.join("events.jsonl").exists());
390 assert!(t.data.join("vectors/v.bin").exists());
391 assert!(!src.join("config.toml").exists());
392 }
393
394 #[test]
395 fn second_run_is_noop_and_existing_dest_is_skipped() {
396 let tmp = tempfile::tempdir().unwrap();
397 let root = tmp.path();
398 let src = root.join("legacy");
399 let t = targets_in(root);
400
401 touch(&src, "events.jsonl");
402 let first = migrate_from(&src, &t);
403 assert_eq!(first.moved.len(), 1);
404
405 touch(&src, "events.jsonl");
407 std::fs::write(t.state.join("events.jsonl"), b"keep").unwrap();
408 let second = migrate_from(&src, &t);
409 assert!(second.moved.is_empty());
410 assert_eq!(second.skipped, vec!["events.jsonl".to_string()]);
411 assert_eq!(
412 std::fs::read_to_string(t.state.join("events.jsonl")).unwrap(),
413 "keep",
414 "existing destination must not be overwritten"
415 );
416 }
417
418 #[test]
419 fn entries_to_move_is_sorted_for_determinism() {
420 let tmp = tempfile::tempdir().unwrap();
421 let root = tmp.path();
422 let src = root.join("legacy");
423 let t = targets_in(root);
424 touch(&src, "events.jsonl");
425 touch(&src, "config.toml");
426 touch(&src, "anomaly_detector.json");
427 let names: Vec<_> = entries_to_move(&src, &t)
428 .into_iter()
429 .map(|m| m.name)
430 .collect();
431 let mut sorted = names.clone();
432 sorted.sort();
433 assert_eq!(names, sorted);
434 }
435
436 #[cfg(unix)]
443 struct EnvVars(Vec<(&'static str, Option<std::ffi::OsString>)>);
444
445 #[cfg(unix)]
446 impl EnvVars {
447 fn apply(pairs: &[(&'static str, Option<&Path>)]) -> Self {
448 let saved = pairs
449 .iter()
450 .map(|(k, _)| (*k, std::env::var_os(k)))
451 .collect();
452 for (k, v) in pairs {
453 match v {
454 Some(p) => std::env::set_var(k, p),
455 None => std::env::remove_var(k),
456 }
457 }
458 EnvVars(saved)
459 }
460 }
461
462 #[cfg(unix)]
463 impl Drop for EnvVars {
464 fn drop(&mut self) {
465 for (k, v) in &self.0 {
466 match v {
467 Some(val) => std::env::set_var(k, val),
468 None => std::env::remove_var(k),
469 }
470 }
471 }
472 }
473
474 #[cfg(unix)]
479 #[test]
480 fn migrate_end_to_end_splits_mixed_xdg_config_install() {
481 let _g = crate::core::data_dir::test_env_lock();
482 let tmp = tempfile::tempdir().unwrap();
483 let root = tmp.path();
484 let home = root.join("home");
485 let xc = root.join("xc");
486 let xd = root.join("xd");
487 let xs = root.join("xs");
488 let xk = root.join("xk");
489 std::fs::create_dir_all(&home).unwrap();
490
491 let _env = EnvVars::apply(&[
492 ("HOME", Some(home.as_path())),
493 ("XDG_CONFIG_HOME", Some(xc.as_path())),
494 ("XDG_DATA_HOME", Some(xd.as_path())),
495 ("XDG_STATE_HOME", Some(xs.as_path())),
496 ("XDG_CACHE_HOME", Some(xk.as_path())),
497 ("LEAN_CTX_DATA_DIR", None),
498 ("LEAN_CTX_CONFIG_DIR", None),
499 ("LEAN_CTX_STATE_DIR", None),
500 ("LEAN_CTX_CACHE_DIR", None),
501 ]);
502
503 let mixed = xc.join("lean-ctx");
505 touch(&mixed, "config.toml");
506 touch(&mixed, "events.jsonl");
507 touch(&mixed, "anomaly_detector.json");
508 touch(&mixed, "stats.json");
509 touch(&mixed.join("sessions"), "s.json");
510
511 let report = migrate().expect("mixed install must migrate");
512 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
513
514 assert!(mixed.join("config.toml").exists(), "config stays in place");
515 assert!(
516 xs.join("lean-ctx/events.jsonl").exists(),
517 "state → XDG_STATE"
518 );
519 assert!(
520 xk.join("lean-ctx/anomaly_detector.json").exists(),
521 "cache → XDG_CACHE"
522 );
523 assert!(xd.join("lean-ctx/stats.json").exists(), "data → XDG_DATA");
524 assert!(
525 xd.join("lean-ctx/sessions/s.json").exists(),
526 "data subdir → XDG_DATA"
527 );
528 assert!(
529 !mixed.join("events.jsonl").exists(),
530 "moved source file removed"
531 );
532
533 assert!(migrate().is_none(), "second run is a no-op (idempotent)");
534 }
535
536 #[cfg(unix)]
539 #[test]
540 fn migrate_respects_explicit_data_dir_override() {
541 let _g = crate::core::data_dir::test_env_lock();
542 let tmp = tempfile::tempdir().unwrap();
543 let single = tmp.path().join("single");
544 touch(&single, "stats.json");
545 touch(&single, "events.jsonl");
546
547 let _env = EnvVars::apply(&[("LEAN_CTX_DATA_DIR", Some(single.as_path()))]);
548 assert!(
549 migrate().is_none(),
550 "explicit LEAN_CTX_DATA_DIR must not be split"
551 );
552 assert!(single.join("events.jsonl").exists(), "nothing moved");
553 }
554}