Skip to main content

lean_ctx/core/knowledge/
maintenance.rs

1//! Maintenance for the on-disk knowledge stores.
2//!
3//! A store at `<data_dir>/knowledge/<hash>/` is keyed to a `project_root`. When
4//! that root is deleted (a removed git worktree, a thrown-away temp project) the
5//! store can never be written again — its per-store eviction cap can therefore
6//! never self-heal (the memory lifecycle only runs on write) and the directory
7//! is pure accumulated bloat. This module finds and prunes those orphaned
8//! stores.
9//!
10//! Pruning is only ever invoked from the explicit `lean-ctx doctor --fix` path;
11//! the background lifecycle must never delete a store, since a missing root can
12//! also mean a temporarily-unmounted drive rather than a deleted project.
13
14use std::path::{Path, PathBuf};
15
16use super::ProjectKnowledge;
17
18/// A knowledge store whose recorded `project_root` no longer exists on disk.
19#[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/// Outcome of a prune pass.
28#[derive(Debug, Default, Clone, Copy)]
29pub struct PruneReport {
30    pub removed: usize,
31    pub reclaimed_bytes: u64,
32}
33
34/// Scan every knowledge store under the real data dir and return the orphaned
35/// ones. See [`find_orphaned_stores_in`] for the detection rules.
36#[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/// Remove every orphaned store under the real data dir. Best-effort.
45#[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/// Detect orphaned stores under an explicit `data_dir` (the testable core).
54///
55/// A store is orphaned when its `project_root` is **non-empty** and does **not**
56/// exist on disk. Stores with an empty root (the legacy/global store) and stores
57/// whose root still exists are always kept.
58#[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        // Empty root = legacy/global store: never an orphan.
80        if root.is_empty() {
81            continue;
82        }
83        // Live project: keep.
84        if Path::new(root).exists() {
85            continue;
86        }
87
88        // The directory name is the canonical store key (it also names the
89        // sibling `memory/episodes/<hash>.json` file). It equals `project_hash`
90        // for stores written by lean-ctx; trust the on-disk name regardless.
91        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/// Prune orphaned stores under an explicit `data_dir` (the testable core).
108/// Removes each orphaned `knowledge/<hash>/` directory plus the matching
109/// episodic-memory file (`memory/episodes/<hash>.json`). A failure on one store
110/// never aborts the rest.
111#[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        // Episodic memory lives outside the hash dir, keyed by the same hash.
121        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/// Recursively sum the size of every regular file under `dir`. Best-effort:
138/// unreadable entries contribute zero.
139fn 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        // Live root: the temp dir itself exists.
173        let live_root = data_dir.to_string_lossy().to_string();
174        write_store(data_dir, "live0000000000000", &live_root);
175        // Empty root: legacy/global store — must never be flagged.
176        write_store(data_dir, "empty000000000000", "");
177        // Missing root: a path that does not exist — the orphan.
178        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        // An episodic-memory file for the orphan must be cleaned up too.
202        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}