lean_ctx/core/
data_consolidate.rs1use std::path::{Path, PathBuf};
20
21#[derive(Debug, Default)]
23pub struct ConsolidationReport {
24 pub canonical: PathBuf,
26 pub merged_from: Vec<PathBuf>,
28 pub files_moved: usize,
30 pub files_superseded: usize,
32 pub errors: Vec<String>,
34}
35
36impl ConsolidationReport {
37 fn changed(&self) -> bool {
38 self.files_moved > 0 || self.files_superseded > 0 || !self.merged_from.is_empty()
39 }
40}
41
42pub fn consolidate() -> Option<ConsolidationReport> {
46 if std::env::var_os("LEAN_CTX_DATA_DIR").is_some() {
48 return None;
49 }
50 let canonical = crate::core::data_dir::lean_ctx_data_dir().ok()?;
51 let sources: Vec<PathBuf> = crate::core::data_dir::all_data_dirs_with_stats()
52 .into_iter()
53 .filter(|d| *d != canonical)
54 .collect();
55 if sources.is_empty() {
56 return None;
57 }
58 let report = consolidate_into(&canonical, &sources);
59 report.changed().then_some(report)
60}
61
62fn consolidate_into(canonical: &Path, sources: &[PathBuf]) -> ConsolidationReport {
65 let mut report = ConsolidationReport {
66 canonical: canonical.to_path_buf(),
67 ..Default::default()
68 };
69 if let Err(e) = std::fs::create_dir_all(canonical) {
70 report.errors.push(format!("{}: {e}", canonical.display()));
71 return report;
72 }
73 crate::core::data_dir::ensure_dir_permissions(canonical);
74
75 for src in sources {
76 if src == canonical || !src.is_dir() {
77 continue;
78 }
79 merge_dir(src, canonical, &mut report);
80 let _ = std::fs::remove_dir(src);
83 report.merged_from.push(src.clone());
84 }
85 report
86}
87
88fn merge_dir(src: &Path, dst: &Path, report: &mut ConsolidationReport) {
90 let Ok(rd) = std::fs::read_dir(src) else {
91 report
92 .errors
93 .push(format!("{}: cannot read", src.display()));
94 return;
95 };
96 for entry in rd.flatten() {
97 let from = entry.path();
98 let to = dst.join(entry.file_name());
99 let is_dir = entry.file_type().is_ok_and(|t| t.is_dir());
100 if is_dir {
101 if let Err(e) = std::fs::create_dir_all(&to) {
102 report.errors.push(format!("{}: {e}", to.display()));
103 continue;
104 }
105 merge_dir(&from, &to, report);
106 let _ = std::fs::remove_dir(&from); } else {
108 merge_file(&from, &to, report);
109 }
110 }
111}
112
113fn merge_file(from: &Path, to: &Path, report: &mut ConsolidationReport) {
116 if to.exists() && !source_is_newer(from, to) {
117 let _ = std::fs::remove_file(from);
118 report.files_superseded += 1;
119 return;
120 }
121 match move_overwrite(from, to) {
122 Ok(()) => report.files_moved += 1,
123 Err(e) => report.errors.push(format!("{}: {e}", from.display())),
124 }
125}
126
127fn source_is_newer(from: &Path, to: &Path) -> bool {
130 let mtime = |p: &Path| std::fs::metadata(p).and_then(|m| m.modified()).ok();
131 match (mtime(from), mtime(to)) {
132 (Some(a), Some(b)) => a > b,
133 _ => false,
134 }
135}
136
137fn move_overwrite(from: &Path, to: &Path) -> std::io::Result<()> {
141 if std::fs::rename(from, to).is_ok() {
142 return Ok(());
143 }
144 std::fs::copy(from, to)?;
145 std::fs::remove_file(from)?;
146 Ok(())
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use filetime::{FileTime, set_file_mtime};
153
154 fn write(path: &Path, body: &str) {
155 if let Some(parent) = path.parent() {
156 std::fs::create_dir_all(parent).unwrap();
157 }
158 std::fs::write(path, body).unwrap();
159 }
160
161 fn set_mtime(path: &Path, secs: i64) {
162 set_file_mtime(path, FileTime::from_unix_time(secs, 0)).unwrap();
163 }
164
165 #[test]
166 fn moves_orphan_files_into_canonical() {
167 let tmp = tempfile::tempdir().unwrap();
168 let canonical = tmp.path().join("canonical");
169 let orphan = tmp.path().join("orphan");
170 std::fs::create_dir_all(&canonical).unwrap();
171 write(&orphan.join("stats.json"), r#"{"total_commands":3}"#);
172 write(&orphan.join("sessions").join("s1.json"), "{}");
173
174 let report = consolidate_into(&canonical, std::slice::from_ref(&orphan));
175
176 assert_eq!(report.files_moved, 2);
177 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
178 assert!(canonical.join("stats.json").exists());
179 assert!(canonical.join("sessions/s1.json").exists());
180 assert!(!orphan.exists(), "merged source dir must be removed");
182 assert_eq!(report.merged_from, vec![orphan]);
183 }
184
185 #[test]
186 fn newer_source_wins_older_canonical_kept() {
187 let tmp = tempfile::tempdir().unwrap();
188 let canonical = tmp.path().join("canonical");
189 let orphan = tmp.path().join("orphan");
190
191 write(&canonical.join("stats.json"), "OLD");
193 set_mtime(&canonical.join("stats.json"), 1_000);
194 write(&orphan.join("stats.json"), "NEW");
195 set_mtime(&orphan.join("stats.json"), 2_000);
196
197 write(&canonical.join("client-id.json"), "KEEP");
199 set_mtime(&canonical.join("client-id.json"), 5_000);
200 write(&orphan.join("client-id.json"), "STALE");
201 set_mtime(&orphan.join("client-id.json"), 1_000);
202
203 let report = consolidate_into(&canonical, std::slice::from_ref(&orphan));
204
205 assert_eq!(
206 std::fs::read_to_string(canonical.join("stats.json")).unwrap(),
207 "NEW",
208 "newer source must win"
209 );
210 assert_eq!(
211 std::fs::read_to_string(canonical.join("client-id.json")).unwrap(),
212 "KEEP",
213 "newer canonical must be preserved"
214 );
215 assert_eq!(report.files_moved, 1);
216 assert_eq!(report.files_superseded, 1);
217 assert!(!orphan.exists());
218 }
219
220 #[test]
221 fn merges_nested_dirs_without_clobbering_existing() {
222 let tmp = tempfile::tempdir().unwrap();
223 let canonical = tmp.path().join("canonical");
224 let orphan = tmp.path().join("orphan");
225
226 write(&canonical.join("vectors").join("a.bin"), "a");
227 write(&orphan.join("vectors").join("b.bin"), "b");
228
229 let report = consolidate_into(&canonical, std::slice::from_ref(&orphan));
230
231 assert!(canonical.join("vectors/a.bin").exists(), "existing kept");
232 assert!(canonical.join("vectors/b.bin").exists(), "new merged in");
233 assert_eq!(report.files_moved, 1);
234 assert!(!orphan.exists());
235 }
236
237 #[test]
238 fn no_sources_is_noop() {
239 let tmp = tempfile::tempdir().unwrap();
240 let canonical = tmp.path().join("canonical");
241 std::fs::create_dir_all(&canonical).unwrap();
242 let report = consolidate_into(&canonical, &[]);
243 assert!(!report.changed());
244 }
245}