use std::path::{Path, PathBuf};
use super::ProjectKnowledge;
#[derive(Debug, Clone)]
pub struct OrphanedStore {
pub hash: String,
pub project_root: String,
pub dir: PathBuf,
pub size_bytes: u64,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct PruneReport {
pub removed: usize,
pub reclaimed_bytes: u64,
}
#[must_use]
pub fn find_orphaned_stores() -> Vec<OrphanedStore> {
match crate::core::data_dir::lean_ctx_data_dir() {
Ok(data_dir) => find_orphaned_stores_in(&data_dir),
Err(_) => Vec::new(),
}
}
#[must_use]
pub fn prune_orphaned_stores() -> PruneReport {
match crate::core::data_dir::lean_ctx_data_dir() {
Ok(data_dir) => prune_orphaned_stores_in(&data_dir),
Err(_) => PruneReport::default(),
}
}
#[must_use]
pub fn find_orphaned_stores_in(data_dir: &Path) -> Vec<OrphanedStore> {
let knowledge_dir = data_dir.join("knowledge");
let Ok(entries) = std::fs::read_dir(&knowledge_dir) else {
return Vec::new();
};
let mut orphans = Vec::new();
for entry in entries.flatten() {
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let Ok(content) = std::fs::read_to_string(dir.join("knowledge.json")) else {
continue;
};
let Ok(store) = serde_json::from_str::<ProjectKnowledge>(&content) else {
continue;
};
let root = store.project_root.trim();
if root.is_empty() {
continue;
}
if Path::new(root).exists() {
continue;
}
let hash = dir
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let size_bytes = dir_size(&dir);
orphans.push(OrphanedStore {
hash,
project_root: store.project_root,
dir,
size_bytes,
});
}
orphans
}
#[must_use]
pub fn prune_orphaned_stores_in(data_dir: &Path) -> PruneReport {
let mut report = PruneReport::default();
for orphan in find_orphaned_stores_in(data_dir) {
if std::fs::remove_dir_all(&orphan.dir).is_err() {
continue;
}
let mut freed = orphan.size_bytes;
let episodes = data_dir
.join("memory")
.join("episodes")
.join(format!("{}.json", orphan.hash));
if let Ok(meta) = std::fs::metadata(&episodes)
&& std::fs::remove_file(&episodes).is_ok()
{
freed = freed.saturating_add(meta.len());
}
report.removed += 1;
report.reclaimed_bytes = report.reclaimed_bytes.saturating_add(freed);
}
report
}
#[derive(Debug, Default)]
pub struct GcPlan {
pub removable: Vec<PathBuf>,
pub removable_bytes: u64,
pub kept_orphans: Vec<(String, usize)>,
pub symlinks: Vec<PathBuf>,
pub scanned: usize,
}
const BOOKKEEPING: [&str; 2] = ["knowledge.json", ".knowledge.lock"];
const IDENTITY_FIELDS: [&str; 3] = ["project_root", "project_hash", "updated_at"];
const LOCK_ONLY_GRACE: std::time::Duration = std::time::Duration::from_hours(24);
enum GcClass {
Removable,
KeptOrphan(String, usize),
Keep,
}
fn is_empty_value(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Null => true,
serde_json::Value::Array(items) => items.is_empty(),
serde_json::Value::Object(fields) => fields.is_empty(),
serde_json::Value::String(text) => text.is_empty(),
serde_json::Value::Bool(_) | serde_json::Value::Number(_) => false,
}
}
fn classify(data_dir: &Path, dir: &Path, grace: bool) -> GcClass {
if std::fs::symlink_metadata(dir).map_or(true, |meta| !meta.is_dir()) {
return GcClass::Keep;
}
let Ok(entries) = std::fs::read_dir(dir).map(|entries| entries.flatten().collect::<Vec<_>>())
else {
return GcClass::Keep;
};
let names: Vec<String> = entries
.iter()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
if names
.iter()
.any(|name| !BOOKKEEPING.contains(&name.as_str()))
|| entries
.iter()
.any(|entry| !entry.file_type().is_ok_and(|kind| kind.is_file()))
{
return GcClass::Keep;
}
let hash = dir.file_name().unwrap_or_default().to_string_lossy();
if data_dir
.join("memory")
.join("episodes")
.join(format!("{hash}.json"))
.exists()
{
return GcClass::Keep;
}
if !names.iter().any(|name| name == "knowledge.json") {
let settled = !grace
|| std::fs::metadata(dir)
.and_then(|meta| meta.modified())
.ok()
.and_then(|at| at.elapsed().ok())
.is_some_and(|age| age >= LOCK_ONLY_GRACE);
return if settled {
GcClass::Removable
} else {
GcClass::Keep
};
}
let Some(serde_json::Value::Object(store)) =
std::fs::read_to_string(dir.join("knowledge.json"))
.ok()
.and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
else {
return GcClass::Keep;
};
let root = store
.get("project_root")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.trim()
.to_owned();
if root.is_empty() || Path::new(&root).exists() {
return GcClass::Keep;
}
let holds_data = store
.iter()
.any(|(name, value)| !IDENTITY_FIELDS.contains(&name.as_str()) && !is_empty_value(value));
if holds_data {
let facts = store
.get("facts")
.and_then(serde_json::Value::as_array)
.map_or(0, Vec::len);
GcClass::KeptOrphan(root, facts)
} else {
GcClass::Removable
}
}
#[must_use]
pub fn plan_gc_in(data_dir: &Path) -> GcPlan {
let mut plan = GcPlan::default();
let Ok(entries) = std::fs::read_dir(data_dir.join("knowledge")) else {
return plan;
};
for entry in entries.flatten() {
let dir = entry.path();
let Ok(kind) = entry.file_type() else {
continue;
};
if kind.is_symlink() {
plan.symlinks.push(dir);
continue;
}
if !kind.is_dir() {
continue;
}
plan.scanned += 1;
match classify(data_dir, &dir, true) {
GcClass::Removable => {
plan.removable_bytes += dir_size(&dir);
plan.removable.push(dir);
}
GcClass::KeptOrphan(root, facts) => plan.kept_orphans.push((root, facts)),
GcClass::Keep => {}
}
}
plan.removable.sort();
plan.kept_orphans.sort();
plan.symlinks.sort();
plan
}
#[must_use]
pub fn apply_gc_in(data_dir: &Path, plan: &GcPlan) -> usize {
use fs2::FileExt;
plan.removable
.iter()
.filter(|dir| {
if std::fs::symlink_metadata(dir).map_or(true, |meta| !meta.is_dir()) {
return false;
}
let Ok(lock) =
crate::core::policy::diagnostics::open_append(&dir.join(".knowledge.lock"))
else {
return false;
};
if lock.try_lock_exclusive().is_err() {
return false;
}
let removed = matches!(classify(data_dir, dir, false), GcClass::Removable)
&& std::fs::remove_dir_all(dir).is_ok();
let _ = FileExt::unlock(&lock);
removed
})
.count()
}
fn dir_size(dir: &Path) -> u64 {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut total = 0u64;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
total = total.saturating_add(dir_size(&path));
} else if let Ok(meta) = path.metadata() {
total = total.saturating_add(meta.len());
}
}
total
}
#[cfg(test)]
mod tests {
use super::*;
fn write_store(data_dir: &Path, hash: &str, project_root: &str) {
let dir = data_dir.join("knowledge").join(hash);
std::fs::create_dir_all(&dir).unwrap();
let store = ProjectKnowledge::new(project_root);
let json = serde_json::to_string(&store).unwrap();
std::fs::write(dir.join("knowledge.json"), json).unwrap();
}
#[test]
fn detects_only_missing_root_stores() {
let tmp = tempfile::tempdir().unwrap();
let data_dir = tmp.path();
let live_root = data_dir.to_string_lossy().to_string();
write_store(data_dir, "live0000000000000", &live_root);
write_store(data_dir, "empty000000000000", "");
let missing_root = data_dir
.join("deleted-worktree")
.to_string_lossy()
.to_string();
write_store(data_dir, "dead00000000000000", &missing_root);
let orphans = find_orphaned_stores_in(data_dir);
assert_eq!(orphans.len(), 1, "only the missing-root store is an orphan");
assert_eq!(orphans[0].hash, "dead00000000000000");
assert!(orphans[0].size_bytes > 0, "orphan size should be measured");
}
#[test]
fn prune_removes_orphans_and_keeps_the_rest() {
let tmp = tempfile::tempdir().unwrap();
let data_dir = tmp.path();
let live_root = data_dir.to_string_lossy().to_string();
write_store(data_dir, "live0000000000000", &live_root);
write_store(data_dir, "empty000000000000", "");
let missing_root = data_dir.join("gone").to_string_lossy().to_string();
write_store(data_dir, "dead00000000000000", &missing_root);
let episodes_dir = data_dir.join("memory").join("episodes");
std::fs::create_dir_all(&episodes_dir).unwrap();
std::fs::write(episodes_dir.join("dead00000000000000.json"), "[]").unwrap();
let report = prune_orphaned_stores_in(data_dir);
assert_eq!(report.removed, 1, "exactly one orphan pruned");
assert!(
report.reclaimed_bytes > 0,
"reclaimed bytes should be reported"
);
assert!(
!data_dir
.join("knowledge")
.join("dead00000000000000")
.exists(),
"orphan store dir must be gone"
);
assert!(
!episodes_dir.join("dead00000000000000.json").exists(),
"orphan episodic file must be gone"
);
assert!(
data_dir
.join("knowledge")
.join("live0000000000000")
.exists(),
"live store must be kept"
);
assert!(
data_dir
.join("knowledge")
.join("empty000000000000")
.exists(),
"empty-root (legacy/global) store must be kept"
);
}
#[test]
fn prune_is_a_noop_when_nothing_is_orphaned() {
let tmp = tempfile::tempdir().unwrap();
let data_dir = tmp.path();
let live_root = data_dir.to_string_lossy().to_string();
write_store(data_dir, "live0000000000000", &live_root);
let report = prune_orphaned_stores_in(data_dir);
assert_eq!(report.removed, 0);
assert_eq!(report.reclaimed_bytes, 0);
}
#[test]
fn gc_removes_only_stores_without_knowledge() {
let tmp = tempfile::tempdir().unwrap();
let data_dir = tmp.path();
let gone = |name: &str| data_dir.join(name).to_string_lossy().to_string();
write_store(data_dir, "live0000000000000", &data_dir.to_string_lossy());
write_store(data_dir, "empty000000000000", "");
write_store(data_dir, "emptyorphan000000", &gone("tmp-a"));
write_store(data_dir, "journaled00000000", &gone("tmp-b"));
let journaled = data_dir.join("knowledge").join("journaled00000000");
std::fs::write(journaled.join("journal.jsonl"), "{}\n").unwrap();
let mut kept = ProjectKnowledge::new(&data_dir.to_string_lossy());
kept.remember(
"decision",
"db",
"Postgres",
"s1",
0.9,
&crate::core::memory_policy::MemoryPolicy::default(),
);
assert_eq!(kept.facts.len(), 1, "fixture fact");
kept.project_root = gone("removed-worktree");
let kept_dir = data_dir.join("knowledge").join("withfacts00000000");
std::fs::create_dir_all(&kept_dir).unwrap();
std::fs::write(
kept_dir.join("knowledge.json"),
serde_json::to_string(&kept).unwrap(),
)
.unwrap();
let judged = data_dir.join("knowledge").join("judged00000000000");
std::fs::create_dir_all(&judged).unwrap();
std::fs::write(
judged.join("knowledge.json"),
serde_json::json!({
"project_root": gone("tmp-c"),
"project_hash": "judged00000000000",
"facts": [], "patterns": [], "history": [],
"judged_pairs": [["a", "b"]],
})
.to_string(),
)
.unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(
data_dir.join("knowledge").join("emptyorphan000000"),
data_dir.join("knowledge").join("linked00000000000"),
)
.unwrap();
let plan = plan_gc_in(data_dir);
assert_eq!(plan.scanned, 6);
assert_eq!(
plan.removable,
vec![data_dir.join("knowledge").join("emptyorphan000000")]
);
assert_eq!(
plan.kept_orphans,
vec![(gone("removed-worktree"), 1), (gone("tmp-c"), 0)]
);
#[cfg(unix)]
assert_eq!(
plan.symlinks,
vec![data_dir.join("knowledge").join("linked00000000000")]
);
std::fs::write(
data_dir
.join("knowledge")
.join("emptyorphan000000")
.join("gotchas.json"),
"[]",
)
.unwrap();
assert_eq!(apply_gc_in(data_dir, &plan), 0);
std::fs::remove_file(
data_dir
.join("knowledge")
.join("emptyorphan000000")
.join("gotchas.json"),
)
.unwrap();
assert_eq!(apply_gc_in(data_dir, &plan), 1);
for kept in [
"live0000000000000",
"empty000000000000",
"journaled00000000",
"withfacts00000000",
"judged00000000000",
] {
assert!(data_dir.join("knowledge").join(kept).exists(), "{kept}");
}
}
}