use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use tracing::{debug, info, warn};
pub const LEINDEX_MARKER_FILE: &str = ".leindex-artifact-marker";
pub const DEFAULT_MAX_AGE_DAYS: u64 = 7;
#[derive(Debug, Default)]
pub struct GcReport {
pub scanned: usize,
pub removed: usize,
pub bytes_freed: u64,
pub failed: Vec<(PathBuf, String)>,
}
impl std::fmt::Display for GcReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "GC Report:")?;
writeln!(f, " Scanned: {} artifact(s)", self.scanned)?;
writeln!(f, " Removed: {} artifact(s)", self.removed)?;
if self.bytes_freed > 0 {
let mb = self.bytes_freed as f64 / 1024.0 / 1024.0;
writeln!(f, " Freed: {:.2} MB", mb)?;
}
if !self.failed.is_empty() {
writeln!(f, " Failed: {} artifact(s)", self.failed.len())?;
for (path, reason) in &self.failed {
writeln!(f, " {} - {}", path.display(), reason)?;
}
}
Ok(())
}
}
pub fn artifact_scan_roots() -> Vec<PathBuf> {
let tmp = std::env::temp_dir();
let mut roots = vec![tmp.join("leindex")];
if let Ok(entries) = fs::read_dir(&tmp) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_lossy = name.to_string_lossy();
if name_lossy.starts_with("lephase-") {
roots.push(entry.path());
}
}
}
roots
}
pub fn is_leindex_artifact(dir: &Path) -> bool {
dir.join(LEINDEX_MARKER_FILE).exists()
}
pub fn write_artifact_marker(dir: &Path) {
let marker_path = dir.join(LEINDEX_MARKER_FILE);
if marker_path.exists() {
return;
}
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let content = format!(
"leindex-artifact\ncreated={}\nversion={}\n",
timestamp,
env!("CARGO_PKG_VERSION")
);
if let Err(e) = fs::write(&marker_path, content) {
warn!(
"Failed to write artifact marker at {}: {}",
marker_path.display(),
e
);
}
}
pub fn dir_size(path: &Path) -> u64 {
walkdir_size(path)
}
fn walkdir_size(path: &Path) -> u64 {
let mut total: u64 = 0;
let mut stack = vec![path.to_path_buf()];
while let Some(current) = stack.pop() {
let entries = match fs::read_dir(¤t) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if meta.is_dir() {
stack.push(entry.path());
} else {
total += meta.len();
}
}
}
total
}
fn is_locked(dir: &Path) -> bool {
match crate::cli::leindex::ProjectWriteLock::try_acquire(dir) {
Ok(Some(_)) => false,
Ok(None) => true,
Err(_) => true,
}
}
pub fn run_gc(max_age: Duration) -> GcReport {
let mut report = GcReport::default();
let cutoff = SystemTime::now() - max_age;
for root in artifact_scan_roots() {
if !root.exists() {
continue;
}
if root
.file_name()
.map(|n| n.to_string_lossy().starts_with("lephase-"))
.unwrap_or(false)
{
maybe_remove_artifact(&root, &cutoff, &mut report);
continue;
}
let entries = match fs::read_dir(&root) {
Ok(e) => e,
Err(err) => {
debug!("Cannot read {}: {}", root.display(), err);
continue;
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if path.file_name().map(|n| n == ".leindex").unwrap_or(false) {
debug!("Skipping in-project .leindex at {}", path.display());
continue;
}
maybe_remove_artifact(&path, &cutoff, &mut report);
}
}
report
}
fn maybe_remove_artifact(dir: &Path, cutoff: &SystemTime, report: &mut GcReport) {
if !is_leindex_artifact(dir) && !is_leindex_artifact_by_pattern(dir) {
return;
}
report.scanned += 1;
let age = artifact_age(dir);
if age >= *cutoff {
debug!(
"Artifact {} is not stale yet (age: {:?})",
dir.display(),
SystemTime::now().duration_since(age).unwrap_or_default()
);
return;
}
if is_locked(dir) {
debug!("Skipping locked artifact: {}", dir.display());
return;
}
let size = dir_size(dir);
match fs::remove_dir_all(dir) {
Ok(()) => {
info!(
"Removed stale artifact: {} ({:.2} MB)",
dir.display(),
size as f64 / 1024.0 / 1024.0
);
report.removed += 1;
report.bytes_freed += size;
}
Err(e) => {
warn!("Failed to remove stale artifact {}: {}", dir.display(), e);
report.failed.push((dir.to_path_buf(), e.to_string()));
}
}
}
pub fn is_leindex_artifact_by_pattern(dir: &Path) -> bool {
let name = dir
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default();
if name.contains('-') {
if dir
.parent()
.map(|p| p.file_name().map(|n| n == "leindex").unwrap_or(false))
.unwrap_or(false)
{
return dir.join("leindex.db").exists();
}
}
if name.starts_with("lephase-") {
return true;
}
false
}
pub fn artifact_age(dir: &Path) -> SystemTime {
let marker = dir.join(LEINDEX_MARKER_FILE);
if let Ok(meta) = fs::metadata(&marker) {
if let Ok(modified) = meta.modified() {
return modified;
}
}
fs::metadata(dir)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH)
}
pub fn startup_gc() {
let max_age = Duration::from_secs(DEFAULT_MAX_AGE_DAYS * 24 * 3600);
let report = run_gc(max_age);
if report.removed > 0 {
info!(
"Startup GC: removed {} stale artifact(s), freed {:.2} MB",
report.removed,
report.bytes_freed as f64 / 1024.0 / 1024.0
);
}
}
#[derive(Debug, Default)]
pub struct DaemonSweepReport {
pub scanned: usize,
pub removed: usize,
pub failed: Vec<(PathBuf, String)>,
}
impl std::fmt::Display for DaemonSweepReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "Daemon sweep report:")?;
writeln!(f, " Stems scanned: {}", self.scanned)?;
writeln!(f, " Files removed: {}", self.removed)?;
if !self.failed.is_empty() {
writeln!(f, " Failed: {} file(s)", self.failed.len())?;
for (path, reason) in &self.failed {
writeln!(f, " {} - {}", path.display(), reason)?;
}
}
Ok(())
}
}
pub(crate) fn pid_is_alive(pid: u32) -> Option<bool> {
#[cfg(target_os = "linux")]
{
let proc_dir = std::path::PathBuf::from(format!("/proc/{pid}"));
if !proc_dir.exists() {
return Some(false);
}
let cmdline = std::fs::read(format!("/proc/{pid}/cmdline")).ok();
Some(cmdline.is_some_and(|raw| {
let command = String::from_utf8_lossy(&raw);
command
.split('\0')
.any(|arg| arg.contains("leindex") || arg.contains("mcp"))
}))
}
#[cfg(not(target_os = "linux"))]
{
let _ = pid;
None
}
}
fn is_daemon_sidecar(name: &str) -> bool {
matches!(
name,
"lock" | "pid" | "sock" | "status" | "start" | "start.next" | "pid.next" | "status.next"
)
}
pub fn sweep_stale_daemon_artifacts(max_age: Duration, dry_run: bool) -> DaemonSweepReport {
let Some(home) = crate::config::resolve_leindex_home() else {
return DaemonSweepReport::default();
};
sweep_run_dir(&home.join("run"), max_age, dry_run)
}
fn sweep_run_dir(run_dir: &Path, max_age: Duration, dry_run: bool) -> DaemonSweepReport {
let mut report = DaemonSweepReport::default();
let entries = match fs::read_dir(run_dir) {
Ok(entries) => entries,
Err(_) => return report, };
let cutoff = SystemTime::now() - max_age;
let mut file_paths: Vec<PathBuf> = Vec::new();
let mut daemon_endpoint_path: Option<PathBuf> = None;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(file_name) = path.file_name() else {
continue;
};
let file_name = file_name.to_string_lossy().to_string();
if file_name == "daemon.endpoint" {
daemon_endpoint_path = Some(path);
continue;
}
file_paths.push(path);
}
if let Some(ep_path) = daemon_endpoint_path {
report.scanned += 1;
if is_daemon_endpoint_stale(&ep_path, &cutoff) {
remove_sidecar(&ep_path, dry_run, &mut report);
}
}
let mut stems: std::collections::BTreeMap<String, Vec<PathBuf>> =
std::collections::BTreeMap::new();
for path in file_paths {
let Some(file_name) = path.file_name() else {
continue;
};
let file_name = file_name.to_string_lossy();
let Some((stem, ext)) = file_name.rsplit_once('.') else {
continue;
};
if !is_daemon_sidecar(ext) {
continue;
}
if !(stem.starts_with("leindex-embed-") || stem.starts_with("leindex-mcp-")) {
continue;
}
stems.entry(stem.to_string()).or_default().push(path);
}
for (stem, mut files) in stems {
files.sort();
report.scanned += 1;
let (live, has_pid) = stem_liveness(&files);
if live {
debug!("Keeping live daemon sidecars for {}", stem);
continue;
}
for path in files {
if !sidecar_is_stale(&path, has_pid, &cutoff) {
continue;
}
remove_sidecar(&path, dry_run, &mut report);
}
}
report
}
fn is_daemon_endpoint_stale(path: &Path, cutoff: &SystemTime) -> bool {
match fs::read(path) {
Ok(bytes) => {
#[derive(serde::Deserialize)]
struct EpPid {
pid: u32,
}
match serde_json::from_slice::<EpPid>(&bytes) {
Ok(ep) => match pid_is_alive(ep.pid) {
Some(false) => true, Some(true) => false, None => {
sidecar_is_stale(path, false, cutoff)
}
},
Err(_) => {
true
}
}
}
Err(_) => true, }
}
fn stem_liveness(files: &[PathBuf]) -> (bool, bool) {
let mut live = false;
let mut has_pid = false;
for path in files {
if path.extension().is_none_or(|ext| ext != "pid") {
continue;
}
let Ok(pid_str) = fs::read_to_string(path) else {
continue;
};
let Ok(pid) = pid_str.trim().parse::<u32>() else {
continue;
};
has_pid = true;
if pid_is_alive(pid) == Some(true) {
live = true;
}
}
(live, has_pid)
}
fn sidecar_is_stale(path: &Path, has_pid: bool, cutoff: &SystemTime) -> bool {
if has_pid {
return true;
}
fs::metadata(path)
.and_then(|m| m.modified())
.map(|mtime| mtime < *cutoff)
.unwrap_or(false)
}
fn remove_sidecar(path: &Path, dry_run: bool, report: &mut DaemonSweepReport) {
if dry_run {
debug!("Would remove stale daemon sidecar {}", path.display());
report.removed += 1;
return;
}
match fs::remove_file(path) {
Ok(()) => {
info!("Removed stale daemon sidecar {}", path.display());
report.removed += 1;
}
Err(e) => {
warn!("Failed to remove daemon sidecar {}: {}", path.display(), e);
report.failed.push((path.to_path_buf(), e.to_string()));
}
}
}
static AT_EXIT_PATHS: std::sync::OnceLock<std::sync::Mutex<Vec<PathBuf>>> =
std::sync::OnceLock::new();
pub fn register_at_exit_cleanup(storage_path: PathBuf) {
if storage_path
.file_name()
.map(|n| n == ".leindex")
.unwrap_or(false)
{
debug!(
"Skipping at-exit cleanup registration for in-project storage: {}",
storage_path.display()
);
return;
}
let tmp = std::env::temp_dir();
if !storage_path.starts_with(&tmp) {
debug!(
"Skipping at-exit cleanup for non-temp storage: {}",
storage_path.display()
);
return;
}
let registry = AT_EXIT_PATHS.get_or_init(|| std::sync::Mutex::new(Vec::new()));
let mut paths = registry
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if !paths.contains(&storage_path) {
paths.push(storage_path.clone());
debug!(
"Registered at-exit cleanup for temp storage: {}",
storage_path.display()
);
}
}
pub fn flush_registered_temp_cleanups() {
let Some(registry) = AT_EXIT_PATHS.get() else {
return;
};
let paths = {
let mut guard = registry
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
std::mem::take(&mut *guard)
};
for path in paths {
best_effort_cleanup(&path);
}
}
pub fn best_effort_cleanup(path: &Path) {
if path.exists() && path.starts_with(std::env::temp_dir()) && !is_locked(path) {
match fs::remove_dir_all(path) {
Ok(()) => {
eprintln!("[leindex] Cleaned up temp storage: {}", path.display());
}
Err(e) => {
eprintln!(
"[leindex] Warning: failed to clean up temp storage {}: {}",
path.display(),
e
);
}
}
}
}
pub fn retention_report_cli(project: Option<&Path>) -> anyhow::Result<RetentionReportOutput> {
use crate::storage::cas::CasStore;
use crate::storage::generation::GENERATIONS_DIR;
use crate::storage::generation::retention::retention_report;
let project_path = project
.map(Path::to_path_buf)
.unwrap_or_else(|| std::env::current_dir().unwrap());
let canonical = project_path
.canonicalize()
.map_err(|e| anyhow::anyhow!("failed to canonicalize project path: {e}"))?;
let storage_root = crate::cli::leindex::resolve_existing_storage_path(&canonical)
.unwrap_or_else(|| canonical.join(".leindex"));
let cas_dir = storage_root.join("cas");
let gens_dir = storage_root.join(GENERATIONS_DIR);
let jobs_dir = storage_root.join("jobs");
let generation_report = if cas_dir.exists() {
let cas = CasStore::open(&cas_dir).map_err(|e| {
anyhow::anyhow!("failed to open CAS store at {}: {e}", cas_dir.display())
})?;
retention_report(&cas, &gens_dir, &jobs_dir)
.map_err(|e| anyhow::anyhow!("retention report failed: {e}"))?
} else if gens_dir.exists() {
crate::storage::generation::retention::retain_generations_no_cas(
&gens_dir,
&jobs_dir,
crate::storage::generation::retention::DEFAULT_MAX_GENERATIONS,
true,
)
.map_err(|e| anyhow::anyhow!("legacy retention report failed: {e}"))?
} else {
crate::storage::generation::GenerationRetentionReport::default()
};
#[cfg(feature = "onnx")]
let cache_stats = report_embed_cache_stats();
#[cfg(feature = "onnx")]
{
Ok(RetentionReportOutput {
generation_report,
cache_stats,
})
}
#[cfg(not(feature = "onnx"))]
{
Ok(RetentionReportOutput { generation_report })
}
}
pub fn retention_gc_cli(
project: Option<&Path>,
max_generations: usize,
dry_run: bool,
) -> anyhow::Result<RetentionReportOutput> {
use crate::storage::cas::CasStore;
use crate::storage::generation::GENERATIONS_DIR;
use crate::storage::generation::retention::{
RetentionConfig, retain_after_publish, retain_generations_no_cas,
};
let project_path = project
.map(Path::to_path_buf)
.unwrap_or_else(|| std::env::current_dir().unwrap());
let canonical = project_path
.canonicalize()
.map_err(|e| anyhow::anyhow!("failed to canonicalize project path: {e}"))?;
let storage_root = crate::cli::leindex::resolve_existing_storage_path(&canonical)
.unwrap_or_else(|| canonical.join(".leindex"));
let _write_guard = crate::cli::leindex::ProjectWriteLock::acquire_bounded(&storage_root)
.map_err(|e| anyhow::anyhow!("failed to acquire project write lock: {e}"))?;
let cas_dir = storage_root.join("cas");
let gens_dir = storage_root.join(GENERATIONS_DIR);
let jobs_dir = storage_root.join("jobs");
let generation_report = if cas_dir.exists() {
let mut cas = CasStore::open(&cas_dir).map_err(|e| {
anyhow::anyhow!("failed to open CAS store at {}: {e}", cas_dir.display())
})?;
let cfg = RetentionConfig {
max_generations: max_generations.max(1),
..RetentionConfig::default()
};
if dry_run {
crate::storage::generation::retention::retention_report(&cas, &gens_dir, &jobs_dir)
.map_err(|e| anyhow::anyhow!("retention report failed: {e}"))?
} else {
retain_after_publish(&mut cas, &gens_dir, &jobs_dir, &cfg)
.map_err(|e| anyhow::anyhow!("retention sweep failed: {e}"))?
}
} else {
if !gens_dir.exists() {
anyhow::bail!(
"no generation store found at {} (expected `generations/` or `cas/`)",
storage_root.display()
);
}
retain_generations_no_cas(&gens_dir, &jobs_dir, max_generations, dry_run)
.map_err(|e| anyhow::anyhow!("legacy retention sweep failed: {e}"))?
};
#[cfg(feature = "onnx")]
let cache_stats = report_embed_cache_stats();
#[cfg(feature = "onnx")]
{
Ok(RetentionReportOutput {
generation_report,
cache_stats,
})
}
#[cfg(not(feature = "onnx"))]
{
Ok(RetentionReportOutput { generation_report })
}
}
#[cfg(feature = "onnx")]
fn report_embed_cache_stats() -> Option<crate::embed::cache::CacheStatsReport> {
let home = crate::config::resolve_leindex_home()?;
let cache_root = home.join("embed-cache");
if !cache_root.exists() {
return None;
}
let cache = crate::embed::cache::GlobalEmbeddingCache::open(&cache_root).ok()?;
cache.cache_stats().ok()
}
#[derive(Debug)]
pub struct RetentionReportOutput {
pub generation_report: crate::storage::generation::GenerationRetentionReport,
#[cfg(feature = "onnx")]
pub cache_stats: Option<crate::embed::cache::CacheStatsReport>,
}
impl std::fmt::Display for RetentionReportOutput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.generation_report)?;
#[cfg(feature = "onnx")]
{
if let Some(ref cache) = self.cache_stats {
write!(f, "{}", cache)?;
} else {
writeln!(f, " Embedding Cache: (not initialized)")?;
}
}
#[cfg(not(feature = "onnx"))]
{
writeln!(f, " Embedding Cache: (onnx feature not compiled)")?;
}
Ok(())
}
}
#[derive(Debug, Default)]
pub struct ProjectCleanupReport {
pub generations: crate::storage::generation::GenerationRetentionReport,
pub staging_files_removed: usize,
#[cfg(feature = "onnx")]
pub cache: Option<crate::embed::cache::CacheCompactionReport>,
}
impl std::fmt::Display for ProjectCleanupReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.generations)?;
if self.staging_files_removed > 0 {
writeln!(f, " Staging files removed: {}", self.staging_files_removed)?;
}
#[cfg(feature = "onnx")]
{
if let Some(ref cache) = self.cache {
writeln!(
f,
" Embed cache: {} rows removed, {} bytes reclaimed",
cache.rows_removed, cache.reclaimed_bytes
)?;
}
}
Ok(())
}
}
pub fn cleanup_project_store(
storage_root: &Path,
dry_run: bool,
) -> anyhow::Result<ProjectCleanupReport> {
use crate::storage::cas::CasStore;
use crate::storage::generation::GENERATIONS_DIR;
let cas_dir = storage_root.join("cas");
let gens_dir = storage_root.join(GENERATIONS_DIR);
let jobs_dir = storage_root.join("jobs");
if let Some(report) = empty_store_cleanup_report(storage_root, &cas_dir, &gens_dir, dry_run) {
return Ok(report);
}
let mut cas = if cas_dir.exists() {
Some(CasStore::open(&cas_dir).map_err(|e| {
anyhow::anyhow!("failed to open CAS store at {}: {e}", cas_dir.display())
})?)
} else {
None
};
let gen_report = run_generation_retention(cas.as_mut(), &gens_dir, &jobs_dir, dry_run)?;
let staging_files_removed = remove_abandoned_staging_files(&cas_dir.join(".staging"), dry_run);
#[cfg(feature = "onnx")]
let cache_compaction = compact_embed_cache(dry_run);
let report = ProjectCleanupReport {
generations: gen_report,
staging_files_removed,
#[cfg(feature = "onnx")]
cache: cache_compaction,
};
Ok(report)
}
fn empty_store_cleanup_report(
storage_root: &Path,
cas_dir: &Path,
gens_dir: &Path,
dry_run: bool,
) -> Option<ProjectCleanupReport> {
if !storage_root.exists() || (!cas_dir.exists() && !gens_dir.exists()) {
#[cfg(feature = "onnx")]
{
return Some(ProjectCleanupReport {
generations: crate::storage::generation::GenerationRetentionReport::default(),
cache: compact_embed_cache(dry_run),
..Default::default()
});
}
#[cfg(not(feature = "onnx"))]
{
let _ = dry_run;
return Some(ProjectCleanupReport {
generations: crate::storage::generation::GenerationRetentionReport::default(),
..Default::default()
});
}
}
None
}
fn run_generation_retention(
cas: Option<&mut crate::storage::cas::CasStore>,
gens_dir: &Path,
jobs_dir: &Path,
dry_run: bool,
) -> anyhow::Result<crate::storage::generation::GenerationRetentionReport> {
use crate::storage::generation::retention::{RetentionConfig, retain_after_publish};
let cfg = RetentionConfig::default();
if let Some(cas) = cas {
if dry_run {
crate::storage::generation::retention::retention_report(cas, gens_dir, jobs_dir)
.map_err(|e| anyhow::anyhow!("retention report failed: {e}"))
} else {
retain_after_publish(cas, gens_dir, jobs_dir, &cfg)
.map_err(|e| anyhow::anyhow!("retention sweep failed: {e}"))
}
} else {
crate::storage::generation::retention::retain_generations_no_cas(
gens_dir,
jobs_dir,
cfg.max_generations,
dry_run,
)
.map_err(|e| anyhow::anyhow!("legacy retention sweep failed: {e}"))
}
}
fn remove_abandoned_staging_files(staging_dir: &Path, dry_run: bool) -> usize {
if !staging_dir.exists() {
return 0;
}
let mut removed = 0usize;
if let Ok(entries) = fs::read_dir(staging_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "partial") {
if !dry_run {
if let Err(e) = fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
warn!(
"cleanup: failed to remove staging file {}: {}",
path.display(),
e
);
}
} else {
removed += 1;
}
} else {
removed += 1;
}
}
}
}
removed
}
#[cfg(feature = "onnx")]
fn compact_embed_cache(dry_run: bool) -> Option<crate::embed::cache::CacheCompactionReport> {
use crate::embed::cache::GlobalEmbeddingCache;
let home = crate::config::resolve_leindex_home()?;
let cache_root = home.join("embed-cache");
if !cache_root.exists() {
return None;
}
let mut cache = GlobalEmbeddingCache::open(&cache_root).ok()?;
if dry_run {
let stats = cache.cache_stats().ok()?;
Some(crate::embed::cache::CacheCompactionReport {
reclaimed_bytes: 0,
rows_removed: 0,
rows_retained: stats.row_count as u64,
staging_files_removed: 0,
})
} else {
cache.gc().ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_marker_write_and_detect() {
let dir = tempfile::tempdir().unwrap();
let artifact = dir.path().join("test-artifact-abc123");
fs::create_dir_all(&artifact).unwrap();
assert!(!is_leindex_artifact(&artifact));
write_artifact_marker(&artifact);
assert!(is_leindex_artifact(&artifact));
let marker_content = fs::read_to_string(artifact.join(LEINDEX_MARKER_FILE)).unwrap();
assert!(marker_content.starts_with("leindex-artifact"));
assert!(marker_content.contains("created="));
}
#[test]
fn test_marker_idempotent() {
let dir = tempfile::tempdir().unwrap();
let artifact = dir.path().join("test-idempotent");
fs::create_dir_all(&artifact).unwrap();
write_artifact_marker(&artifact);
let first = fs::read_to_string(artifact.join(LEINDEX_MARKER_FILE)).unwrap();
write_artifact_marker(&artifact);
let second = fs::read_to_string(artifact.join(LEINDEX_MARKER_FILE)).unwrap();
assert_eq!(
first, second,
"Marker should not be overwritten if it exists"
);
}
#[test]
fn test_gc_skips_non_stale_artifacts() {
let _report = run_gc(Duration::from_secs(0));
}
#[test]
fn test_is_locked_on_writable_dir() {
let dir = tempfile::tempdir().unwrap();
assert!(!is_locked(dir.path()));
}
#[test]
fn test_is_locked_detects_held_write_lock() {
let dir = tempfile::tempdir().unwrap();
let guard = crate::cli::leindex::ProjectWriteLock::acquire_bounded(dir.path()).unwrap();
assert!(is_locked(dir.path()));
drop(guard);
assert!(!is_locked(dir.path()));
}
use std::sync::Mutex;
static TEST_CLEANUP_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn test_register_and_flush_temp_cleanup() {
let _g = TEST_CLEANUP_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("storage");
fs::create_dir_all(&path).unwrap();
fs::write(path.join("leindex.db"), b"x").unwrap();
register_at_exit_cleanup(path.clone());
flush_registered_temp_cleanups();
assert!(
!path.exists(),
"flush should remove registered temp storage"
);
}
#[test]
fn test_register_skips_in_project_dir() {
let _g = TEST_CLEANUP_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let in_project = dir.path().join(".leindex");
fs::create_dir_all(&in_project).unwrap();
register_at_exit_cleanup(in_project.clone());
flush_registered_temp_cleanups();
assert!(in_project.exists(), "in-project .leindex is never cleaned");
}
#[test]
fn test_dir_size() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("file1.txt"), b"hello world").unwrap();
fs::write(dir.path().join("file2.txt"), b"foo bar baz").unwrap();
let size = dir_size(dir.path());
assert_eq!(size, 11 + 11); }
#[test]
fn test_artifact_age_uses_marker() {
let dir = tempfile::tempdir().unwrap();
let artifact = dir.path().join("age-test");
fs::create_dir_all(&artifact).unwrap();
write_artifact_marker(&artifact);
let age = artifact_age(&artifact);
let elapsed = SystemTime::now().duration_since(age).unwrap_or_default();
assert!(elapsed.as_secs() < 10, "Artifact age should be recent");
}
#[test]
fn test_artifact_age_falls_back_to_dir_mtime() {
let dir = tempfile::tempdir().unwrap();
let artifact = dir.path().join("no-marker");
fs::create_dir_all(&artifact).unwrap();
let age = artifact_age(&artifact);
let elapsed = SystemTime::now().duration_since(age).unwrap_or_default();
assert!(
elapsed.as_secs() < 10,
"Artifact age should fall back to dir mtime"
);
}
#[test]
fn test_gc_report_display() {
let report = GcReport {
scanned: 10,
removed: 3,
bytes_freed: 1024 * 1024 * 50, failed: vec![(PathBuf::from("/tmp/locked"), "Permission denied".into())],
};
let output = report.to_string();
assert!(output.contains("Scanned: 10"));
assert!(output.contains("Removed: 3"));
assert!(output.contains("50.00 MB"));
assert!(output.contains("Failed: 1"));
}
#[test]
fn test_is_leindex_artifact_by_pattern() {
let dir = tempfile::tempdir().unwrap();
let lephase = dir.path().join("lephase-phase1-abc");
fs::create_dir_all(&lephase).unwrap();
assert!(is_leindex_artifact_by_pattern(&lephase));
let random = dir.path().join("random-dir");
fs::create_dir_all(&random).unwrap();
assert!(!is_leindex_artifact_by_pattern(&random));
}
#[test]
fn test_never_removes_in_project_leindex() {
let dir = tempfile::tempdir().unwrap();
let leindex_dir = dir.path().join(".leindex");
fs::create_dir_all(&leindex_dir).unwrap();
fs::write(leindex_dir.join("leindex.db"), b"important data").unwrap();
assert_eq!(leindex_dir.file_name().unwrap(), ".leindex");
}
#[test]
fn test_run_gc_on_empty_dirs() {
let report = run_gc(Duration::from_secs(0));
let _ = report.scanned;
}
#[test]
fn test_best_effort_cleanup_skips_non_temp() {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/home/user"));
let non_temp = home.join(".leindex-test-cleanup-should-not-delete");
assert!(!non_temp.starts_with(std::env::temp_dir()));
}
#[test]
fn test_sweep_ignores_unrelated_files() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("not-a-sidecar.txt"), b"x").unwrap();
fs::write(dir.path().join("other-app.pid"), b"12345").unwrap();
let report = sweep_run_dir(dir.path(), Duration::from_secs(0), false);
assert_eq!(report.scanned, 0);
assert_eq!(report.removed, 0);
assert!(dir.path().join("not-a-sidecar.txt").exists());
assert!(dir.path().join("other-app.pid").exists());
}
#[test]
fn test_sweep_keeps_live_pid_stem() {
let dir = tempfile::tempdir().unwrap();
let stem = "leindex-embed-aaaaaaaaaaaaaaaa";
fs::write(
dir.path().join(format!("{stem}.pid")),
format!("{}\n", std::process::id()),
)
.unwrap();
fs::write(dir.path().join(format!("{stem}.status")), "ready\n").unwrap();
let report = sweep_run_dir(dir.path(), Duration::from_secs(0), false);
assert_eq!(report.removed, 0, "live-pid stem must not be swept");
assert!(dir.path().join(format!("{stem}.pid")).exists());
assert!(dir.path().join(format!("{stem}.status")).exists());
}
#[cfg(target_os = "linux")]
#[test]
fn test_sweep_sweeps_recycled_unrelated_pid_stem() {
let dir = tempfile::tempdir().unwrap();
let stem = "leindex-embed-eeeeeeeeeeeeeeee";
std::fs::write(dir.path().join(format!("{stem}.pid")), "1\n").unwrap();
std::fs::write(dir.path().join(format!("{stem}.status")), "ready\n").unwrap();
let report = sweep_run_dir(dir.path(), Duration::from_secs(0), false);
assert_eq!(
report.removed, 2,
"recycled unrelated pid must not protect the stem"
);
assert!(!dir.path().join(format!("{stem}.pid")).exists());
}
#[test]
fn test_sweep_removes_dead_pid_stem() {
let dir = tempfile::tempdir().unwrap();
let dead_pid = 1 << 22; let stem = "leindex-embed-bbbbbbbbbbbbbbbb";
fs::write(
dir.path().join(format!("{stem}.pid")),
format!("{dead_pid}\n"),
)
.unwrap();
fs::write(dir.path().join(format!("{stem}.sock")), b"").unwrap();
fs::write(dir.path().join(format!("{stem}.status")), "ready\n").unwrap();
let report = sweep_run_dir(dir.path(), Duration::from_secs(0), false);
assert_eq!(report.removed, 3);
assert!(!dir.path().join(format!("{stem}.pid")).exists());
assert!(!dir.path().join(format!("{stem}.sock")).exists());
assert!(!dir.path().join(format!("{stem}.status")).exists());
}
#[test]
fn test_sweep_dry_run_counts_without_removing() {
let dir = tempfile::tempdir().unwrap();
let stem = "leindex-mcp-cccccccccccccccc";
fs::write(dir.path().join(format!("{stem}.lock")), b"").unwrap();
let report = sweep_run_dir(dir.path(), Duration::from_secs(0), true);
assert_eq!(report.removed, 1, "dry run must still count");
assert!(
dir.path().join(format!("{stem}.lock")).exists(),
"dry run removes nothing"
);
}
#[test]
fn test_sweep_keeps_malformed_pid_stem_recent_sidecars() {
let dir = tempfile::tempdir().unwrap();
let stem = "leindex-embed-eeeeeeeeeeeeeeee";
fs::write(dir.path().join(format!("{stem}.pid")), b"not-a-pid").unwrap();
fs::write(dir.path().join(format!("{stem}.status")), "ready\n").unwrap();
let report = sweep_run_dir(dir.path(), Duration::from_secs(7 * 24 * 3600), false);
assert_eq!(
report.removed, 0,
"malformed pid must fall back to mtime, not sweep recent sidecars"
);
assert!(dir.path().join(format!("{stem}.pid")).exists());
assert!(dir.path().join(format!("{stem}.status")).exists());
}
#[test]
fn test_sweep_removes_old_nonpid_sidecar() {
let dir = tempfile::tempdir().unwrap();
let stem = "leindex-mcp-dddddddddddddddd";
fs::write(dir.path().join(format!("{stem}.lock")), b"").unwrap();
fs::write(dir.path().join(format!("{stem}.start")), "12345\n").unwrap();
let report = sweep_run_dir(dir.path(), Duration::from_secs(0), false);
assert_eq!(report.removed, 2);
assert!(!dir.path().join(format!("{stem}.lock")).exists());
assert!(!dir.path().join(format!("{stem}.start")).exists());
}
#[test]
fn test_sweep_removes_dead_daemon_endpoint() {
let dir = tempfile::tempdir().unwrap();
let ep = serde_json::json!({
"socket_path": "/tmp/d.sock",
"pid": 999_999, "pid_start_time_ms": 0,
"protocol_version": 1,
"leindex_version": "test",
});
fs::write(
dir.path().join("daemon.endpoint"),
serde_json::to_vec(&ep).unwrap(),
)
.unwrap();
let report = sweep_run_dir(dir.path(), Duration::from_secs(0), false);
assert!(
report.removed >= 1,
"dead-pid daemon.endpoint must be swept"
);
assert!(!dir.path().join("daemon.endpoint").exists());
}
#[test]
fn test_sweep_keeps_live_daemon_endpoint() {
let dir = tempfile::tempdir().unwrap();
let ep = serde_json::json!({
"socket_path": "/tmp/d.sock",
"pid": std::process::id(),
"pid_start_time_ms": 0,
"protocol_version": 1,
"leindex_version": "test",
});
fs::write(
dir.path().join("daemon.endpoint"),
serde_json::to_vec(&ep).unwrap(),
)
.unwrap();
let report = sweep_run_dir(dir.path(), Duration::from_secs(0), false);
let _ = report;
}
#[test]
fn test_sweep_removes_malformed_daemon_endpoint() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("daemon.endpoint"), b"{broken json").unwrap();
let report = sweep_run_dir(dir.path(), Duration::from_secs(0), false);
assert!(
report.removed >= 1,
"malformed daemon.endpoint must be swept"
);
assert!(!dir.path().join("daemon.endpoint").exists());
}
fn build_generation_store_fixture(
generations: &[u64],
current: u64,
) -> (
tempfile::TempDir,
PathBuf,
std::sync::Arc<std::sync::Mutex<crate::storage::cas::CasStore>>,
Vec<([u8; 32], Vec<u8>)>,
) {
use crate::storage::cas::CasStore;
use crate::storage::generation::lease::{CURRENT_FILE, GENERATIONS_DIR, MANIFEST_FILE};
use crate::storage::generation::manifest::{
LayerKind, MANIFEST_VERSION, Manifest, ModelIdentity,
};
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let cas_dir = root.join("cas");
let gens_dir = root.join(GENERATIONS_DIR);
let jobs_dir = root.join("jobs");
std::fs::create_dir_all(&cas_dir).unwrap();
std::fs::create_dir_all(&gens_dir).unwrap();
std::fs::create_dir_all(&jobs_dir).unwrap();
let cas = CasStore::open(&cas_dir).unwrap();
let mut layer_data: Vec<([u8; 32], Vec<u8>)> = Vec::new();
for &gen_num in generations {
let mut layers = std::collections::HashMap::new();
for kind in [
LayerKind::Db,
LayerKind::Tfidf,
LayerKind::Neural,
LayerKind::Pdg,
LayerKind::Symbols,
] {
let payload = format!("layer-{kind:?}-gen-{gen_num}").into_bytes();
let hash = cas.put(&payload).expect("cas put must succeed");
layers.insert(kind, hash);
if gen_num == current {
layer_data.push((hash, payload));
}
}
let manifest = Manifest {
version: MANIFEST_VERSION,
generation: gen_num,
model_identity: ModelIdentity {
name: "test-model".to_string(),
digest: "test-digest".to_string(),
dimensions: 384,
},
graph_fingerprint: [0u8; 32],
search_fingerprint: [0u8; 32],
layers,
};
let manifest_bytes = manifest.to_bytes().unwrap();
let gen_dir = gens_dir.join(gen_num.to_string());
std::fs::create_dir_all(&gen_dir).unwrap();
std::fs::write(gen_dir.join(MANIFEST_FILE), &manifest_bytes).unwrap();
}
std::fs::write(root.join(CURRENT_FILE), current.to_string()).unwrap();
cas.persist().unwrap();
let cas = std::sync::Arc::new(std::sync::Mutex::new(cas));
(dir, root, cas, layer_data)
}
#[test]
fn test_cleanup_never_removes_current_generation() {
let (_dir, root, _cas, _layer_data) = build_generation_store_fixture(&[1, 2], 2);
let report = cleanup_project_store(&root, false).unwrap();
assert!(
std::fs::exists(root.join("generations/2/manifest")).unwrap(),
"current generation manifest must survive cleanup"
);
assert!(
report.generations.generations_retained >= 1,
"current gen must be counted as retained"
);
}
#[test]
fn test_cleanup_never_removes_previous_generation() {
let (_dir, root, _cas, _layer_data) = build_generation_store_fixture(&[1, 2], 2);
let report = cleanup_project_store(&root, false).unwrap();
assert!(
std::fs::exists(root.join("generations/1/manifest")).unwrap(),
"previous (rollback) generation must survive cleanup"
);
assert_eq!(report.generations.generations_retained, 2);
}
#[test]
fn test_cleanup_prunes_legacy_full_copy_store() {
use crate::storage::generation::lease::{CURRENT_FILE, GENERATIONS_DIR};
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let gens_dir = root.join(GENERATIONS_DIR);
std::fs::create_dir_all(&gens_dir).unwrap();
std::fs::create_dir_all(root.join("jobs")).unwrap();
for gen_num in 1u64..=5 {
let gen_dir = gens_dir.join(gen_num.to_string());
std::fs::create_dir_all(&gen_dir).unwrap();
std::fs::write(gen_dir.join("leindex.db"), format!("db-{gen_num}")).unwrap();
}
std::fs::write(root.join(CURRENT_FILE), "5").unwrap();
let report = cleanup_project_store(&root, false).unwrap();
assert_eq!(
report.generations.generations_removed, 3,
"legacy gens outside the current+previous window must be pruned"
);
for kept in [4u64, 5] {
assert!(
gens_dir.join(kept.to_string()).exists(),
"legacy gen {kept} must survive (window)"
);
}
for removed in [1u64, 2, 3] {
assert!(
!gens_dir.join(removed.to_string()).exists(),
"legacy gen {removed} must be reclaimed"
);
}
}
#[test]
fn test_cleanup_removes_stale_generations() {
let (_dir, root, _cas, _layer_data) = build_generation_store_fixture(&[1, 2, 3, 4], 4);
let report = cleanup_project_store(&root, false).unwrap();
assert!(
!std::fs::exists(root.join("generations/1")).unwrap(),
"stale generation 1 must be removed"
);
assert!(
!std::fs::exists(root.join("generations/2")).unwrap(),
"stale generation 2 must be removed"
);
assert!(std::fs::exists(root.join("generations/3")).unwrap());
assert!(std::fs::exists(root.join("generations/4")).unwrap());
assert_eq!(report.generations.generations_removed, 2);
}
#[test]
fn test_cleanup_never_removes_leased_generation() {
let (_dir, root, cas, _layer_data) = build_generation_store_fixture(&[1, 2, 3, 4, 5], 5);
{
let manifest_bytes = std::fs::read(root.join("generations/1/manifest")).unwrap();
let manifest =
crate::storage::generation::manifest::Manifest::from_bytes(&manifest_bytes)
.unwrap();
let mut store = cas.lock().unwrap();
for hash in manifest.layer_hashes() {
store.incr(&hash);
}
store.record_generation_hold(1);
store.persist().unwrap();
}
let _report = cleanup_project_store(&root, false).unwrap();
assert!(
std::fs::exists(root.join("generations/1")).unwrap(),
"leased generation 1 must survive cleanup"
);
assert!(!std::fs::exists(root.join("generations/2")).unwrap());
assert!(!std::fs::exists(root.join("generations/3")).unwrap());
}
#[test]
fn test_cleanup_removes_orphaned_cas_blobs() {
use crate::storage::cas::CasStore;
let (_dir, root, _cas, _layer_data) = build_generation_store_fixture(&[1], 1);
let cas_dir = root.join("cas");
let cas = CasStore::open(&cas_dir).unwrap();
let orphan_payload = b"orphan-data-not-referenced";
let orphan_hash = cas.put(orphan_payload).expect("cas put should succeed");
cas.persist().unwrap();
assert!(cas.exists(&orphan_hash));
drop(cas);
let report = cleanup_project_store(&root, false).unwrap();
let cas = CasStore::open(&cas_dir).unwrap();
assert!(
!cas.exists(&orphan_hash),
"orphaned CAS blob must be removed by cleanup"
);
assert!(report.generations.cas.blobs_removed > 0);
}
#[test]
fn test_cleanup_preserves_pinned_cas_blobs() {
use crate::storage::cas::CasStore;
let (_dir, root, _cas, layer_data) = build_generation_store_fixture(&[1, 2], 2);
let report = cleanup_project_store(&root, false).unwrap();
let cas = CasStore::open(root.join("cas")).unwrap();
for (hash, _payload) in &layer_data {
assert!(
cas.exists(hash),
"CAS blob from current generation must survive cleanup"
);
}
assert_eq!(report.generations.cas.blobs_removed, 0);
}
#[test]
fn test_cleanup_removes_abandoned_staging() {
let (_dir, root, _cas, _layer_data) = build_generation_store_fixture(&[1], 1);
let staging_dir = root.join("cas/.staging");
std::fs::create_dir_all(&staging_dir).unwrap();
std::fs::write(staging_dir.join("abandoneddigest.partial"), b"partial data").unwrap();
std::fs::write(staging_dir.join("anothercrash.partial"), b"more partial").unwrap();
let report = cleanup_project_store(&root, false).unwrap();
assert_eq!(report.staging_files_removed, 2);
let entries: Vec<_> = std::fs::read_dir(&staging_dir).unwrap().collect();
assert!(entries.is_empty(), "all abandoned staging files removed");
}
#[test]
fn test_cleanup_report_accurate() {
let (_dir, root, _cas, _layer_data) =
build_generation_store_fixture(&[1, 2, 3, 4, 5, 6], 6);
let report = cleanup_project_store(&root, false).unwrap();
assert_eq!(report.generations.generations_retained, 2);
assert_eq!(report.generations.generations_removed, 4);
}
}