use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Category {
Config,
Data,
State,
Cache,
Runtime,
}
impl Category {
fn label(self) -> &'static str {
match self {
Category::Config => "config",
Category::Data => "data",
Category::State => "state",
Category::Cache => "cache",
Category::Runtime => "runtime",
}
}
}
fn categorize(name: &str) -> Category {
match name {
"config.toml" | "env.sh" => Category::Config,
n if n.starts_with("shell-hook.") => Category::Config,
"events.jsonl"
| "journal.md"
| "tool-calls.log"
| "mcp-live.json"
| "feedback.json"
| "cost_attribution.json"
| "context_ledger.json"
| "ledger"
| "cooccurrence"
| "slow-commands.log"
| "pipeline_stats.json"
| "heatmap.json"
| "tee"
| "dashboard.token"
| "agent_runtime_env.json" => Category::State,
"semantic_cache"
| "models"
| "anomaly_detector.json"
| "autonomy_drivers_v1.json"
| "context_ir_v1.json"
| "thresholds_learned.json"
| "litm_calibration.json"
| "path_mode_memory.json"
| "efficacy_snapshots.json"
| "latest-version.json"
| ".first_run_wow_done" => Category::Cache,
"daemon.pid" | "daemon.sock" | "daemon-stderr.log" => Category::Runtime,
n if n.starts_with(".graph-idx-") => Category::Runtime,
_ => Category::Data,
}
}
struct Targets {
config: PathBuf,
data: PathBuf,
state: PathBuf,
cache: PathBuf,
}
impl Targets {
fn resolve() -> Result<Self, String> {
Ok(Self {
config: crate::core::paths::config_split_target()?,
data: crate::core::paths::data_split_target()?,
state: crate::core::paths::state_split_target()?,
cache: crate::core::paths::cache_split_target()?,
})
}
fn dir_for(&self, cat: Category) -> Option<&Path> {
match cat {
Category::Config => Some(&self.config),
Category::Data => Some(&self.data),
Category::State => Some(&self.state),
Category::Cache => Some(&self.cache),
Category::Runtime => None,
}
}
}
struct PlannedMove {
from: PathBuf,
name: String,
category: &'static str,
dest_dir: PathBuf,
dest: PathBuf,
}
pub struct MigrationReport {
pub source: PathBuf,
pub moved: Vec<(String, &'static str)>,
pub skipped: Vec<String>,
pub errors: Vec<String>,
}
impl MigrationReport {
fn new(source: &Path) -> Self {
Self {
source: source.to_path_buf(),
moved: Vec::new(),
skipped: Vec::new(),
errors: Vec::new(),
}
}
fn is_empty(&self) -> bool {
self.moved.is_empty() && self.skipped.is_empty() && self.errors.is_empty()
}
}
fn entries_to_move(src: &Path, targets: &Targets) -> Vec<PlannedMove> {
let mut moves = Vec::new();
let Ok(rd) = std::fs::read_dir(src) else {
return moves;
};
for entry in rd.flatten() {
let raw_name = entry.file_name();
let name = raw_name.to_string_lossy().to_string();
let cat = categorize(&name);
let Some(dest_dir) = targets.dir_for(cat) else {
continue; };
if dest_dir == src {
continue; }
moves.push(PlannedMove {
from: entry.path(),
name,
category: cat.label(),
dest_dir: dest_dir.to_path_buf(),
dest: dest_dir.join(&raw_name),
});
}
moves.sort_by(|a, b| a.name.cmp(&b.name));
moves
}
fn copy_tree(from: &Path, to: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let dst = to.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &dst)?;
} else {
std::fs::copy(entry.path(), &dst)?;
}
}
Ok(())
}
fn move_entry(from: &Path, to: &Path) -> std::io::Result<()> {
if std::fs::rename(from, to).is_ok() {
return Ok(());
}
if from.is_dir() {
copy_tree(from, to)?;
std::fs::remove_dir_all(from)?;
} else {
std::fs::copy(from, to)?;
std::fs::remove_file(from)?;
}
Ok(())
}
fn migrate_from(src: &Path, targets: &Targets) -> MigrationReport {
let mut report = MigrationReport::new(src);
for mv in entries_to_move(src, targets) {
if mv.dest.exists() {
report.skipped.push(mv.name);
continue;
}
if let Err(e) = std::fs::create_dir_all(&mv.dest_dir) {
report.errors.push(format!("{}: {e}", mv.name));
continue;
}
crate::core::data_dir::ensure_dir_permissions(&mv.dest_dir);
match move_entry(&mv.from, &mv.dest) {
Ok(()) => report.moved.push((mv.name, mv.category)),
Err(e) => report.errors.push(format!("{}: {e}", mv.name)),
}
}
report
}
fn detect() -> Option<(PathBuf, Targets)> {
if std::env::var_os("LEAN_CTX_DATA_DIR").is_some() {
return None;
}
let src = crate::core::paths::single_dir_override()?;
if !src.is_dir() {
return None;
}
let targets = Targets::resolve().ok()?;
Some((src, targets))
}
pub fn pending() -> Option<(PathBuf, usize)> {
let (src, targets) = detect()?;
let n = entries_to_move(&src, &targets).len();
if n == 0 {
return None;
}
Some((src, n))
}
pub fn migrate() -> Option<MigrationReport> {
let (src, targets) = detect()?;
let report = migrate_from(&src, &targets);
if report.is_empty() {
return None;
}
Some(report)
}
#[cfg(test)]
mod tests {
use super::*;
fn targets_in(root: &Path) -> Targets {
Targets {
config: root.join("config"),
data: root.join("data"),
state: root.join("state"),
cache: root.join("cache"),
}
}
fn touch(dir: &Path, name: &str) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join(name), b"x").unwrap();
}
#[test]
fn categorize_routes_each_category() {
assert_eq!(categorize("config.toml"), Category::Config);
assert_eq!(categorize("shell-hook.zsh"), Category::Config);
assert_eq!(categorize("events.jsonl"), Category::State);
assert_eq!(categorize("pipeline_stats.json"), Category::State);
assert_eq!(categorize("semantic_cache"), Category::Cache);
assert_eq!(categorize("models"), Category::Cache);
assert_eq!(categorize(".first_run_wow_done"), Category::Cache);
assert_eq!(categorize("daemon.sock"), Category::Runtime);
assert_eq!(categorize(".graph-idx-abc.lock"), Category::Runtime);
assert_eq!(categorize("sessions"), Category::Data);
assert_eq!(categorize("stats.json"), Category::Data);
assert_eq!(categorize("client-id.json"), Category::Data);
assert_eq!(categorize("something-new"), Category::Data);
}
#[test]
fn mixed_config_source_splits_data_state_cache_keeps_config() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let src = root.join("config");
let mut t = targets_in(root);
t.config = src.clone();
touch(&src, "config.toml");
touch(&src, "events.jsonl");
touch(&src, "anomaly_detector.json");
touch(&src, "stats.json");
touch(&src.join("sessions"), "s1.json");
touch(&src, "daemon.pid");
let report = migrate_from(&src, &t);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
assert!(src.join("config.toml").exists());
assert!(src.join("daemon.pid").exists());
assert!(t.state.join("events.jsonl").exists());
assert!(t.cache.join("anomaly_detector.json").exists());
assert!(t.data.join("stats.json").exists());
assert!(t.data.join("sessions/s1.json").exists());
assert!(!src.join("events.jsonl").exists());
assert!(!src.join("sessions").exists());
let labels: Vec<_> = report.moved.iter().map(|(n, c)| (n.as_str(), *c)).collect();
assert!(labels.contains(&("events.jsonl", "state")));
assert!(labels.contains(&("anomaly_detector.json", "cache")));
assert!(labels.contains(&("sessions", "data")));
assert!(labels.contains(&("stats.json", "data")));
}
#[test]
fn legacy_source_moves_everything_including_config() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let src = root.join("legacy"); let t = targets_in(root);
touch(&src, "config.toml");
touch(&src, "events.jsonl");
touch(&src.join("vectors"), "v.bin");
let report = migrate_from(&src, &t);
assert!(report.errors.is_empty());
assert!(t.config.join("config.toml").exists());
assert!(t.state.join("events.jsonl").exists());
assert!(t.data.join("vectors/v.bin").exists());
assert!(!src.join("config.toml").exists());
}
#[test]
fn second_run_is_noop_and_existing_dest_is_skipped() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let src = root.join("legacy");
let t = targets_in(root);
touch(&src, "events.jsonl");
let first = migrate_from(&src, &t);
assert_eq!(first.moved.len(), 1);
touch(&src, "events.jsonl");
std::fs::write(t.state.join("events.jsonl"), b"keep").unwrap();
let second = migrate_from(&src, &t);
assert!(second.moved.is_empty());
assert_eq!(second.skipped, vec!["events.jsonl".to_string()]);
assert_eq!(
std::fs::read_to_string(t.state.join("events.jsonl")).unwrap(),
"keep",
"existing destination must not be overwritten"
);
}
#[test]
fn entries_to_move_is_sorted_for_determinism() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let src = root.join("legacy");
let t = targets_in(root);
touch(&src, "events.jsonl");
touch(&src, "config.toml");
touch(&src, "anomaly_detector.json");
let names: Vec<_> = entries_to_move(&src, &t)
.into_iter()
.map(|m| m.name)
.collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted);
}
#[cfg(unix)]
struct EnvVars(Vec<(&'static str, Option<std::ffi::OsString>)>);
#[cfg(unix)]
impl EnvVars {
fn apply(pairs: &[(&'static str, Option<&Path>)]) -> Self {
let saved = pairs
.iter()
.map(|(k, _)| (*k, std::env::var_os(k)))
.collect();
for (k, v) in pairs {
match v {
Some(p) => std::env::set_var(k, p),
None => std::env::remove_var(k),
}
}
EnvVars(saved)
}
}
#[cfg(unix)]
impl Drop for EnvVars {
fn drop(&mut self) {
for (k, v) in &self.0 {
match v {
Some(val) => std::env::set_var(k, val),
None => std::env::remove_var(k),
}
}
}
}
#[cfg(unix)]
#[test]
fn migrate_end_to_end_splits_mixed_xdg_config_install() {
let _g = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let home = root.join("home");
let xc = root.join("xc");
let xd = root.join("xd");
let xs = root.join("xs");
let xk = root.join("xk");
std::fs::create_dir_all(&home).unwrap();
let _env = EnvVars::apply(&[
("HOME", Some(home.as_path())),
("XDG_CONFIG_HOME", Some(xc.as_path())),
("XDG_DATA_HOME", Some(xd.as_path())),
("XDG_STATE_HOME", Some(xs.as_path())),
("XDG_CACHE_HOME", Some(xk.as_path())),
("LEAN_CTX_DATA_DIR", None),
("LEAN_CTX_CONFIG_DIR", None),
("LEAN_CTX_STATE_DIR", None),
("LEAN_CTX_CACHE_DIR", None),
]);
let mixed = xc.join("lean-ctx");
touch(&mixed, "config.toml");
touch(&mixed, "events.jsonl");
touch(&mixed, "anomaly_detector.json");
touch(&mixed, "stats.json");
touch(&mixed.join("sessions"), "s.json");
let report = migrate().expect("mixed install must migrate");
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
assert!(mixed.join("config.toml").exists(), "config stays in place");
assert!(
xs.join("lean-ctx/events.jsonl").exists(),
"state → XDG_STATE"
);
assert!(
xk.join("lean-ctx/anomaly_detector.json").exists(),
"cache → XDG_CACHE"
);
assert!(xd.join("lean-ctx/stats.json").exists(), "data → XDG_DATA");
assert!(
xd.join("lean-ctx/sessions/s.json").exists(),
"data subdir → XDG_DATA"
);
assert!(
!mixed.join("events.jsonl").exists(),
"moved source file removed"
);
assert!(migrate().is_none(), "second run is a no-op (idempotent)");
}
#[cfg(unix)]
#[test]
fn migrate_respects_explicit_data_dir_override() {
let _g = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().unwrap();
let single = tmp.path().join("single");
touch(&single, "stats.json");
touch(&single, "events.jsonl");
let _env = EnvVars::apply(&[("LEAN_CTX_DATA_DIR", Some(single.as_path()))]);
assert!(
migrate().is_none(),
"explicit LEAN_CTX_DATA_DIR must not be split"
);
assert!(single.join("events.jsonl").exists(), "nothing moved");
}
}