1use 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 && std::fs::remove_file(&episodes).is_ok()
127 {
128 freed = freed.saturating_add(meta.len());
129 }
130
131 report.removed += 1;
132 report.reclaimed_bytes = report.reclaimed_bytes.saturating_add(freed);
133 }
134 report
135}
136
137#[derive(Debug, Default)]
145pub struct GcPlan {
146 pub removable: Vec<PathBuf>,
150 pub removable_bytes: u64,
151 pub kept_orphans: Vec<(String, usize)>,
153 pub symlinks: Vec<PathBuf>,
156 pub scanned: usize,
158}
159
160const BOOKKEEPING: [&str; 2] = ["knowledge.json", ".knowledge.lock"];
162
163const IDENTITY_FIELDS: [&str; 3] = ["project_root", "project_hash", "updated_at"];
166
167const LOCK_ONLY_GRACE: std::time::Duration = std::time::Duration::from_hours(24);
170
171enum GcClass {
173 Removable,
175 KeptOrphan(String, usize),
177 Keep,
179}
180
181fn is_empty_value(value: &serde_json::Value) -> bool {
183 match value {
184 serde_json::Value::Null => true,
185 serde_json::Value::Array(items) => items.is_empty(),
186 serde_json::Value::Object(fields) => fields.is_empty(),
187 serde_json::Value::String(text) => text.is_empty(),
188 serde_json::Value::Bool(_) | serde_json::Value::Number(_) => false,
189 }
190}
191
192fn classify(data_dir: &Path, dir: &Path, grace: bool) -> GcClass {
196 if std::fs::symlink_metadata(dir).map_or(true, |meta| !meta.is_dir()) {
197 return GcClass::Keep;
198 }
199 let Ok(entries) = std::fs::read_dir(dir).map(|entries| entries.flatten().collect::<Vec<_>>())
200 else {
201 return GcClass::Keep;
202 };
203 let names: Vec<String> = entries
207 .iter()
208 .map(|entry| entry.file_name().to_string_lossy().into_owned())
209 .collect();
210 if names
211 .iter()
212 .any(|name| !BOOKKEEPING.contains(&name.as_str()))
213 || entries
214 .iter()
215 .any(|entry| !entry.file_type().is_ok_and(|kind| kind.is_file()))
216 {
217 return GcClass::Keep;
218 }
219 let hash = dir.file_name().unwrap_or_default().to_string_lossy();
220 if data_dir
221 .join("memory")
222 .join("episodes")
223 .join(format!("{hash}.json"))
224 .exists()
225 {
226 return GcClass::Keep;
227 }
228 if !names.iter().any(|name| name == "knowledge.json") {
229 let settled = !grace
230 || std::fs::metadata(dir)
231 .and_then(|meta| meta.modified())
232 .ok()
233 .and_then(|at| at.elapsed().ok())
234 .is_some_and(|age| age >= LOCK_ONLY_GRACE);
235 return if settled {
236 GcClass::Removable
237 } else {
238 GcClass::Keep
239 };
240 }
241 let Some(serde_json::Value::Object(store)) =
242 std::fs::read_to_string(dir.join("knowledge.json"))
243 .ok()
244 .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
245 else {
246 return GcClass::Keep;
247 };
248 let root = store
249 .get("project_root")
250 .and_then(serde_json::Value::as_str)
251 .unwrap_or_default()
252 .trim()
253 .to_owned();
254 if root.is_empty() || Path::new(&root).exists() {
256 return GcClass::Keep;
257 }
258 let holds_data = store
259 .iter()
260 .any(|(name, value)| !IDENTITY_FIELDS.contains(&name.as_str()) && !is_empty_value(value));
261 if holds_data {
262 let facts = store
263 .get("facts")
264 .and_then(serde_json::Value::as_array)
265 .map_or(0, Vec::len);
266 GcClass::KeptOrphan(root, facts)
267 } else {
268 GcClass::Removable
269 }
270}
271
272#[must_use]
274pub fn plan_gc_in(data_dir: &Path) -> GcPlan {
275 let mut plan = GcPlan::default();
276 let Ok(entries) = std::fs::read_dir(data_dir.join("knowledge")) else {
277 return plan;
278 };
279 for entry in entries.flatten() {
280 let dir = entry.path();
281 let Ok(kind) = entry.file_type() else {
282 continue;
283 };
284 if kind.is_symlink() {
285 plan.symlinks.push(dir);
286 continue;
287 }
288 if !kind.is_dir() {
289 continue;
290 }
291 plan.scanned += 1;
292 match classify(data_dir, &dir, true) {
293 GcClass::Removable => {
294 plan.removable_bytes += dir_size(&dir);
295 plan.removable.push(dir);
296 }
297 GcClass::KeptOrphan(root, facts) => plan.kept_orphans.push((root, facts)),
298 GcClass::Keep => {}
299 }
300 }
301 plan.removable.sort();
302 plan.kept_orphans.sort();
303 plan.symlinks.sort();
304 plan
305}
306
307#[must_use]
313pub fn apply_gc_in(data_dir: &Path, plan: &GcPlan) -> usize {
314 use fs2::FileExt;
315 plan.removable
316 .iter()
317 .filter(|dir| {
318 if std::fs::symlink_metadata(dir).map_or(true, |meta| !meta.is_dir()) {
319 return false;
320 }
321 let Ok(lock) =
322 crate::core::policy::diagnostics::open_append(&dir.join(".knowledge.lock"))
323 else {
324 return false;
325 };
326 if lock.try_lock_exclusive().is_err() {
327 return false;
328 }
329 let removed = matches!(classify(data_dir, dir, false), GcClass::Removable)
330 && std::fs::remove_dir_all(dir).is_ok();
331 let _ = FileExt::unlock(&lock);
332 removed
333 })
334 .count()
335}
336
337fn dir_size(dir: &Path) -> u64 {
340 let Ok(entries) = std::fs::read_dir(dir) else {
341 return 0;
342 };
343 let mut total = 0u64;
344 for entry in entries.flatten() {
345 let path = entry.path();
346 if path.is_dir() {
347 total = total.saturating_add(dir_size(&path));
348 } else if let Ok(meta) = path.metadata() {
349 total = total.saturating_add(meta.len());
350 }
351 }
352 total
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 fn write_store(data_dir: &Path, hash: &str, project_root: &str) {
360 let dir = data_dir.join("knowledge").join(hash);
361 std::fs::create_dir_all(&dir).unwrap();
362 let store = ProjectKnowledge::new(project_root);
363 let json = serde_json::to_string(&store).unwrap();
364 std::fs::write(dir.join("knowledge.json"), json).unwrap();
365 }
366
367 #[test]
368 fn detects_only_missing_root_stores() {
369 let tmp = tempfile::tempdir().unwrap();
370 let data_dir = tmp.path();
371
372 let live_root = data_dir.to_string_lossy().to_string();
374 write_store(data_dir, "live0000000000000", &live_root);
375 write_store(data_dir, "empty000000000000", "");
377 let missing_root = data_dir
379 .join("deleted-worktree")
380 .to_string_lossy()
381 .to_string();
382 write_store(data_dir, "dead00000000000000", &missing_root);
383
384 let orphans = find_orphaned_stores_in(data_dir);
385 assert_eq!(orphans.len(), 1, "only the missing-root store is an orphan");
386 assert_eq!(orphans[0].hash, "dead00000000000000");
387 assert!(orphans[0].size_bytes > 0, "orphan size should be measured");
388 }
389
390 #[test]
391 fn prune_removes_orphans_and_keeps_the_rest() {
392 let tmp = tempfile::tempdir().unwrap();
393 let data_dir = tmp.path();
394
395 let live_root = data_dir.to_string_lossy().to_string();
396 write_store(data_dir, "live0000000000000", &live_root);
397 write_store(data_dir, "empty000000000000", "");
398 let missing_root = data_dir.join("gone").to_string_lossy().to_string();
399 write_store(data_dir, "dead00000000000000", &missing_root);
400
401 let episodes_dir = data_dir.join("memory").join("episodes");
403 std::fs::create_dir_all(&episodes_dir).unwrap();
404 std::fs::write(episodes_dir.join("dead00000000000000.json"), "[]").unwrap();
405
406 let report = prune_orphaned_stores_in(data_dir);
407 assert_eq!(report.removed, 1, "exactly one orphan pruned");
408 assert!(
409 report.reclaimed_bytes > 0,
410 "reclaimed bytes should be reported"
411 );
412
413 assert!(
414 !data_dir
415 .join("knowledge")
416 .join("dead00000000000000")
417 .exists(),
418 "orphan store dir must be gone"
419 );
420 assert!(
421 !episodes_dir.join("dead00000000000000.json").exists(),
422 "orphan episodic file must be gone"
423 );
424 assert!(
425 data_dir
426 .join("knowledge")
427 .join("live0000000000000")
428 .exists(),
429 "live store must be kept"
430 );
431 assert!(
432 data_dir
433 .join("knowledge")
434 .join("empty000000000000")
435 .exists(),
436 "empty-root (legacy/global) store must be kept"
437 );
438 }
439
440 #[test]
441 fn prune_is_a_noop_when_nothing_is_orphaned() {
442 let tmp = tempfile::tempdir().unwrap();
443 let data_dir = tmp.path();
444 let live_root = data_dir.to_string_lossy().to_string();
445 write_store(data_dir, "live0000000000000", &live_root);
446
447 let report = prune_orphaned_stores_in(data_dir);
448 assert_eq!(report.removed, 0);
449 assert_eq!(report.reclaimed_bytes, 0);
450 }
451
452 #[test]
455 fn gc_removes_only_stores_without_knowledge() {
456 let tmp = tempfile::tempdir().unwrap();
457 let data_dir = tmp.path();
458 let gone = |name: &str| data_dir.join(name).to_string_lossy().to_string();
459 write_store(data_dir, "live0000000000000", &data_dir.to_string_lossy());
460 write_store(data_dir, "empty000000000000", "");
461 write_store(data_dir, "emptyorphan000000", &gone("tmp-a"));
462 write_store(data_dir, "journaled00000000", &gone("tmp-b"));
463 let journaled = data_dir.join("knowledge").join("journaled00000000");
464 std::fs::write(journaled.join("journal.jsonl"), "{}\n").unwrap();
465 let mut kept = ProjectKnowledge::new(&data_dir.to_string_lossy());
466 kept.remember(
467 "decision",
468 "db",
469 "Postgres",
470 "s1",
471 0.9,
472 &crate::core::memory_policy::MemoryPolicy::default(),
473 );
474 assert_eq!(kept.facts.len(), 1, "fixture fact");
475 kept.project_root = gone("removed-worktree");
477 let kept_dir = data_dir.join("knowledge").join("withfacts00000000");
478 std::fs::create_dir_all(&kept_dir).unwrap();
479 std::fs::write(
480 kept_dir.join("knowledge.json"),
481 serde_json::to_string(&kept).unwrap(),
482 )
483 .unwrap();
484
485 let judged = data_dir.join("knowledge").join("judged00000000000");
487 std::fs::create_dir_all(&judged).unwrap();
488 std::fs::write(
489 judged.join("knowledge.json"),
490 serde_json::json!({
491 "project_root": gone("tmp-c"),
492 "project_hash": "judged00000000000",
493 "facts": [], "patterns": [], "history": [],
494 "judged_pairs": [["a", "b"]],
495 })
496 .to_string(),
497 )
498 .unwrap();
499 #[cfg(unix)]
501 std::os::unix::fs::symlink(
502 data_dir.join("knowledge").join("emptyorphan000000"),
503 data_dir.join("knowledge").join("linked00000000000"),
504 )
505 .unwrap();
506
507 let plan = plan_gc_in(data_dir);
508 assert_eq!(plan.scanned, 6);
509 assert_eq!(
510 plan.removable,
511 vec![data_dir.join("knowledge").join("emptyorphan000000")]
512 );
513 assert_eq!(
514 plan.kept_orphans,
515 vec![(gone("removed-worktree"), 1), (gone("tmp-c"), 0)]
516 );
517 #[cfg(unix)]
518 assert_eq!(
519 plan.symlinks,
520 vec![data_dir.join("knowledge").join("linked00000000000")]
521 );
522
523 std::fs::write(
526 data_dir
527 .join("knowledge")
528 .join("emptyorphan000000")
529 .join("gotchas.json"),
530 "[]",
531 )
532 .unwrap();
533 assert_eq!(apply_gc_in(data_dir, &plan), 0);
534 std::fs::remove_file(
535 data_dir
536 .join("knowledge")
537 .join("emptyorphan000000")
538 .join("gotchas.json"),
539 )
540 .unwrap();
541 assert_eq!(apply_gc_in(data_dir, &plan), 1);
542 for kept in [
543 "live0000000000000",
544 "empty000000000000",
545 "journaled00000000",
546 "withfacts00000000",
547 "judged00000000000",
548 ] {
549 assert!(data_dir.join("knowledge").join(kept).exists(), "{kept}");
550 }
551 }
552}