lean_ctx/core/knowledge/
maintenance.rs1use std::path::{Path, PathBuf};
15
16use super::ProjectKnowledge;
17
18#[derive(Debug, Clone)]
20pub struct OrphanedStore {
21 pub hash: String,
22 pub project_root: String,
23 pub dir: PathBuf,
24 pub size_bytes: u64,
25}
26
27#[derive(Debug, Default, Clone, Copy)]
29pub struct PruneReport {
30 pub removed: usize,
31 pub reclaimed_bytes: u64,
32}
33
34#[must_use]
37pub fn find_orphaned_stores() -> Vec<OrphanedStore> {
38 match crate::core::data_dir::lean_ctx_data_dir() {
39 Ok(data_dir) => find_orphaned_stores_in(&data_dir),
40 Err(_) => Vec::new(),
41 }
42}
43
44#[must_use]
46pub fn prune_orphaned_stores() -> PruneReport {
47 match crate::core::data_dir::lean_ctx_data_dir() {
48 Ok(data_dir) => prune_orphaned_stores_in(&data_dir),
49 Err(_) => PruneReport::default(),
50 }
51}
52
53#[must_use]
59pub fn find_orphaned_stores_in(data_dir: &Path) -> Vec<OrphanedStore> {
60 let knowledge_dir = data_dir.join("knowledge");
61 let Ok(entries) = std::fs::read_dir(&knowledge_dir) else {
62 return Vec::new();
63 };
64
65 let mut orphans = Vec::new();
66 for entry in entries.flatten() {
67 let dir = entry.path();
68 if !dir.is_dir() {
69 continue;
70 }
71 let Ok(content) = std::fs::read_to_string(dir.join("knowledge.json")) else {
72 continue;
73 };
74 let Ok(store) = serde_json::from_str::<ProjectKnowledge>(&content) else {
75 continue;
76 };
77
78 let root = store.project_root.trim();
79 if root.is_empty() {
81 continue;
82 }
83 if Path::new(root).exists() {
85 continue;
86 }
87
88 let hash = dir
92 .file_name()
93 .unwrap_or_default()
94 .to_string_lossy()
95 .to_string();
96 let size_bytes = dir_size(&dir);
97 orphans.push(OrphanedStore {
98 hash,
99 project_root: store.project_root,
100 dir,
101 size_bytes,
102 });
103 }
104 orphans
105}
106
107#[must_use]
112pub fn prune_orphaned_stores_in(data_dir: &Path) -> PruneReport {
113 let mut report = PruneReport::default();
114 for orphan in find_orphaned_stores_in(data_dir) {
115 if std::fs::remove_dir_all(&orphan.dir).is_err() {
116 continue;
117 }
118 let mut freed = orphan.size_bytes;
119
120 let episodes = data_dir
122 .join("memory")
123 .join("episodes")
124 .join(format!("{}.json", orphan.hash));
125 if let Ok(meta) = std::fs::metadata(&episodes) {
126 if std::fs::remove_file(&episodes).is_ok() {
127 freed = freed.saturating_add(meta.len());
128 }
129 }
130
131 report.removed += 1;
132 report.reclaimed_bytes = report.reclaimed_bytes.saturating_add(freed);
133 }
134 report
135}
136
137fn dir_size(dir: &Path) -> u64 {
140 let Ok(entries) = std::fs::read_dir(dir) else {
141 return 0;
142 };
143 let mut total = 0u64;
144 for entry in entries.flatten() {
145 let path = entry.path();
146 if path.is_dir() {
147 total = total.saturating_add(dir_size(&path));
148 } else if let Ok(meta) = path.metadata() {
149 total = total.saturating_add(meta.len());
150 }
151 }
152 total
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 fn write_store(data_dir: &Path, hash: &str, project_root: &str) {
160 let dir = data_dir.join("knowledge").join(hash);
161 std::fs::create_dir_all(&dir).unwrap();
162 let store = ProjectKnowledge::new(project_root);
163 let json = serde_json::to_string(&store).unwrap();
164 std::fs::write(dir.join("knowledge.json"), json).unwrap();
165 }
166
167 #[test]
168 fn detects_only_missing_root_stores() {
169 let tmp = tempfile::tempdir().unwrap();
170 let data_dir = tmp.path();
171
172 let live_root = data_dir.to_string_lossy().to_string();
174 write_store(data_dir, "live0000000000000", &live_root);
175 write_store(data_dir, "empty000000000000", "");
177 let missing_root = data_dir
179 .join("deleted-worktree")
180 .to_string_lossy()
181 .to_string();
182 write_store(data_dir, "dead00000000000000", &missing_root);
183
184 let orphans = find_orphaned_stores_in(data_dir);
185 assert_eq!(orphans.len(), 1, "only the missing-root store is an orphan");
186 assert_eq!(orphans[0].hash, "dead00000000000000");
187 assert!(orphans[0].size_bytes > 0, "orphan size should be measured");
188 }
189
190 #[test]
191 fn prune_removes_orphans_and_keeps_the_rest() {
192 let tmp = tempfile::tempdir().unwrap();
193 let data_dir = tmp.path();
194
195 let live_root = data_dir.to_string_lossy().to_string();
196 write_store(data_dir, "live0000000000000", &live_root);
197 write_store(data_dir, "empty000000000000", "");
198 let missing_root = data_dir.join("gone").to_string_lossy().to_string();
199 write_store(data_dir, "dead00000000000000", &missing_root);
200
201 let episodes_dir = data_dir.join("memory").join("episodes");
203 std::fs::create_dir_all(&episodes_dir).unwrap();
204 std::fs::write(episodes_dir.join("dead00000000000000.json"), "[]").unwrap();
205
206 let report = prune_orphaned_stores_in(data_dir);
207 assert_eq!(report.removed, 1, "exactly one orphan pruned");
208 assert!(
209 report.reclaimed_bytes > 0,
210 "reclaimed bytes should be reported"
211 );
212
213 assert!(
214 !data_dir
215 .join("knowledge")
216 .join("dead00000000000000")
217 .exists(),
218 "orphan store dir must be gone"
219 );
220 assert!(
221 !episodes_dir.join("dead00000000000000.json").exists(),
222 "orphan episodic file must be gone"
223 );
224 assert!(
225 data_dir
226 .join("knowledge")
227 .join("live0000000000000")
228 .exists(),
229 "live store must be kept"
230 );
231 assert!(
232 data_dir
233 .join("knowledge")
234 .join("empty000000000000")
235 .exists(),
236 "empty-root (legacy/global) store must be kept"
237 );
238 }
239
240 #[test]
241 fn prune_is_a_noop_when_nothing_is_orphaned() {
242 let tmp = tempfile::tempdir().unwrap();
243 let data_dir = tmp.path();
244 let live_root = data_dir.to_string_lossy().to_string();
245 write_store(data_dir, "live0000000000000", &live_root);
246
247 let report = prune_orphaned_stores_in(data_dir);
248 assert_eq!(report.removed, 0);
249 assert_eq!(report.reclaimed_bytes, 0);
250 }
251}