use std::path::{Path, PathBuf};
#[derive(Debug, Default)]
pub(crate) struct ConsolidationReport {
pub canonical: PathBuf,
pub merged_from: Vec<PathBuf>,
pub files_moved: usize,
pub files_superseded: usize,
pub errors: Vec<String>,
}
impl ConsolidationReport {
fn changed(&self) -> bool {
self.files_moved > 0 || self.files_superseded > 0 || !self.merged_from.is_empty()
}
}
pub(crate) fn consolidate() -> Option<ConsolidationReport> {
if std::env::var_os("LEAN_CTX_DATA_DIR").is_some() {
return None;
}
let canonical = crate::core::data_dir::lean_ctx_data_dir().ok()?;
let sources: Vec<PathBuf> = crate::core::data_dir::all_data_dirs_with_stats()
.into_iter()
.filter(|d| *d != canonical)
.collect();
if sources.is_empty() {
return None;
}
let report = consolidate_into(&canonical, &sources);
report.changed().then_some(report)
}
fn consolidate_into(canonical: &Path, sources: &[PathBuf]) -> ConsolidationReport {
let mut report = ConsolidationReport {
canonical: canonical.to_path_buf(),
..Default::default()
};
if let Err(e) = std::fs::create_dir_all(canonical) {
report.errors.push(format!("{}: {e}", canonical.display()));
return report;
}
crate::core::data_dir::ensure_dir_permissions(canonical);
for src in sources {
if src == canonical || !src.is_dir() {
continue;
}
merge_dir(src, canonical, &mut report);
let _ = std::fs::remove_dir(src);
report.merged_from.push(src.clone());
}
report
}
fn merge_dir(src: &Path, dst: &Path, report: &mut ConsolidationReport) {
let Ok(rd) = std::fs::read_dir(src) else {
report
.errors
.push(format!("{}: cannot read", src.display()));
return;
};
for entry in rd.flatten() {
let from = entry.path();
let to = dst.join(entry.file_name());
let is_dir = entry.file_type().is_ok_and(|t| t.is_dir());
if is_dir {
if let Err(e) = std::fs::create_dir_all(&to) {
report.errors.push(format!("{}: {e}", to.display()));
continue;
}
merge_dir(&from, &to, report);
let _ = std::fs::remove_dir(&from); } else {
merge_file(&from, &to, report);
}
}
}
fn merge_file(from: &Path, to: &Path, report: &mut ConsolidationReport) {
if to.exists() {
if is_stats_json(from) {
match merge_stats_additive(from, to) {
Ok(()) => {
let _ = std::fs::remove_file(from);
report.files_moved += 1;
return;
}
Err(e) => {
report.errors.push(format!("additive stats merge: {e}"));
}
}
}
if !source_is_newer(from, to) {
let _ = std::fs::remove_file(from);
report.files_superseded += 1;
return;
}
}
match move_overwrite(from, to) {
Ok(()) => report.files_moved += 1,
Err(e) => report.errors.push(format!("{}: {e}", from.display())),
}
}
fn is_stats_json(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == "stats.json")
}
fn merge_stats_additive(from: &Path, to: &Path) -> Result<(), String> {
use crate::core::stats::StatsStore;
let read = |p: &Path| -> Result<StatsStore, String> {
let data = std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?;
serde_json::from_str(&data).map_err(|e| format!("{}: {e}", p.display()))
};
let canonical = read(to)?;
let orphan = read(from)?;
let merged = crate::core::stats::merge_additive(&canonical, &orphan);
let json = serde_json::to_string_pretty(&merged).map_err(|e| format!("serialize: {e}"))?;
std::fs::write(to, json).map_err(|e| format!("{}: {e}", to.display()))
}
fn source_is_newer(from: &Path, to: &Path) -> bool {
let mtime = |p: &Path| std::fs::metadata(p).and_then(|m| m.modified()).ok();
match (mtime(from), mtime(to)) {
(Some(a), Some(b)) => a > b,
_ => false,
}
}
fn move_overwrite(from: &Path, to: &Path) -> std::io::Result<()> {
if std::fs::rename(from, to).is_ok() {
return Ok(());
}
std::fs::copy(from, to)?;
std::fs::remove_file(from)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use filetime::{FileTime, set_file_mtime};
fn write(path: &Path, body: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, body).unwrap();
}
fn set_mtime(path: &Path, secs: i64) {
set_file_mtime(path, FileTime::from_unix_time(secs, 0)).unwrap();
}
#[test]
fn moves_orphan_files_into_canonical() {
let tmp = tempfile::tempdir().unwrap();
let canonical = tmp.path().join("canonical");
let orphan = tmp.path().join("orphan");
std::fs::create_dir_all(&canonical).unwrap();
write(&orphan.join("stats.json"), r#"{"total_commands":3}"#);
write(&orphan.join("sessions").join("s1.json"), "{}");
let report = consolidate_into(&canonical, std::slice::from_ref(&orphan));
assert_eq!(report.files_moved, 2);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
assert!(canonical.join("stats.json").exists());
assert!(canonical.join("sessions/s1.json").exists());
assert!(!orphan.exists(), "merged source dir must be removed");
assert_eq!(report.merged_from, vec![orphan]);
}
#[test]
fn newer_source_wins_older_canonical_kept() {
let tmp = tempfile::tempdir().unwrap();
let canonical = tmp.path().join("canonical");
let orphan = tmp.path().join("orphan");
write(&canonical.join("stats.json"), "OLD");
set_mtime(&canonical.join("stats.json"), 1_000);
write(&orphan.join("stats.json"), "NEW");
set_mtime(&orphan.join("stats.json"), 2_000);
write(&canonical.join("client-id.json"), "KEEP");
set_mtime(&canonical.join("client-id.json"), 5_000);
write(&orphan.join("client-id.json"), "STALE");
set_mtime(&orphan.join("client-id.json"), 1_000);
let report = consolidate_into(&canonical, std::slice::from_ref(&orphan));
assert_eq!(
std::fs::read_to_string(canonical.join("stats.json")).unwrap(),
"NEW",
"newer source must win"
);
assert_eq!(
std::fs::read_to_string(canonical.join("client-id.json")).unwrap(),
"KEEP",
"newer canonical must be preserved"
);
assert_eq!(report.files_moved, 1);
assert_eq!(report.files_superseded, 1);
assert!(!orphan.exists());
}
#[test]
fn merges_nested_dirs_without_clobbering_existing() {
let tmp = tempfile::tempdir().unwrap();
let canonical = tmp.path().join("canonical");
let orphan = tmp.path().join("orphan");
write(&canonical.join("vectors").join("a.bin"), "a");
write(&orphan.join("vectors").join("b.bin"), "b");
let report = consolidate_into(&canonical, std::slice::from_ref(&orphan));
assert!(canonical.join("vectors/a.bin").exists(), "existing kept");
assert!(canonical.join("vectors/b.bin").exists(), "new merged in");
assert_eq!(report.files_moved, 1);
assert!(!orphan.exists());
}
#[test]
fn no_sources_is_noop() {
let tmp = tempfile::tempdir().unwrap();
let canonical = tmp.path().join("canonical");
std::fs::create_dir_all(&canonical).unwrap();
let report = consolidate_into(&canonical, &[]);
assert!(!report.changed());
}
}