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 {
let test_file = dir.join(".leindex-gc-lock-test");
match fs::write(&test_file, b"test") {
Ok(_) => {
let _ = fs::remove_file(&test_file);
false
}
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
);
}
}
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;
}
static CLEANUP_REGISTERED: std::sync::Once = std::sync::Once::new();
CLEANUP_REGISTERED.call_once(|| {
});
}
pub fn best_effort_cleanup(path: &Path) {
if path.exists() && path.starts_with(std::env::temp_dir()) {
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_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()));
}
}