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 stems: std::collections::BTreeMap<String, Vec<PathBuf>> =
std::collections::BTreeMap::new();
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();
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 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
);
}
}
}
}
#[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(dir.path()).unwrap();
assert!(is_locked(dir.path()));
drop(guard);
assert!(!is_locked(dir.path()));
}
#[test]
fn test_register_and_flush_temp_cleanup() {
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 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());
}
}