use crate::manifest::{Manifest, Project}; use anyhow::{Context, Result}; use chrono::Utc; use std::path::Path; pub fn target_size_bytes(path: &Path) -> Result { if !path.exists() { return Ok(0); } let out = std::process::Command::new("du") .args(["-sb", path.to_str().unwrap()]) .output() .context("running du")?; let line = String::from_utf8_lossy(&out.stdout); let bytes = line.split_whitespace() .next() .and_then(|s| s.parse::().ok()) .unwrap_or(0); Ok(bytes) } pub fn total_used_gb(manifest: &Manifest) -> Result { let mut total = 0u64; for p in &manifest.projects { total += target_size_bytes(&p.hot_target_path)?; } Ok(total as f64 / 1_073_741_824.0) } pub fn gc_stale_targets(manifest: &Manifest, stale_hours: u64) -> Result> { let now = Utc::now(); let threshold = chrono::Duration::hours(stale_hours as i64); let mut evicted = Vec::new(); for p in &manifest.projects { let is_stale = match p.last_active { None => true, Some(t) => (now - t) > threshold, }; if is_stale && p.hot_target_path.exists() { std::fs::remove_dir_all(&p.hot_target_path) .with_context(|| format!("removing hot target for {}", p.name))?; evicted.push(p.name.clone()); } } Ok(evicted) } pub fn gc_by_space(manifest: &mut Manifest, max_gb: f64) -> Result> { let mut evicted = Vec::new(); loop { let used = total_used_gb(manifest)?; if used <= max_gb { break; } let lru_name = manifest.projects.iter() .filter(|p| p.hot_target_path.exists()) .min_by_key(|p| p.last_active) .map(|p| p.name.clone()); match lru_name { None => break, Some(name) => { if let Some(p) = manifest.projects.iter().find(|p| p.name == name) { if p.hot_target_path.exists() { std::fs::remove_dir_all(&p.hot_target_path)?; evicted.push(name); } } } } } Ok(evicted) } #[cfg(test)] mod tests { use super::*; use crate::manifest::{Manifest, Project}; use tempfile::TempDir; fn make_project(dir: &TempDir, name: &str) -> Project { let path = dir.path().join(name); std::fs::create_dir_all(&path).unwrap(); std::fs::write(path.join("dummy"), vec![0u8; 1024 * 1024]).unwrap(); Project { name: name.into(), warm_path: format!("/slab/projects/{}", name).into(), hot_target_path: path.clone(), last_build: None, last_active: None, last_sync: None, } } #[test] fn test_project_target_size_bytes() { let dir = TempDir::new().unwrap(); let p = make_project(&dir, "test-proj"); let size = target_size_bytes(&p.hot_target_path).unwrap(); assert!(size >= 1024 * 1024, "expected at least 1MB, got {}", size); } #[test] fn test_gc_evicts_stale_project() { let dir = TempDir::new().unwrap(); let mut manifest = Manifest::default(); manifest.projects.push(make_project(&dir, "stale-proj")); manifest.projects[0].last_active = Some(chrono::Utc::now() - chrono::Duration::hours(100)); let evicted = gc_stale_targets(&manifest, 48).unwrap(); assert_eq!(evicted.len(), 1); assert_eq!(evicted[0], "stale-proj"); assert!(!dir.path().join("stale-proj").exists()); } #[test] fn test_gc_skips_active_project() { let dir = TempDir::new().unwrap(); let mut manifest = Manifest::default(); manifest.projects.push(make_project(&dir, "active-proj")); manifest.projects[0].last_active = Some(chrono::Utc::now() - chrono::Duration::hours(2)); let evicted = gc_stale_targets(&manifest, 48).unwrap(); assert!(evicted.is_empty()); assert!(dir.path().join("active-proj").exists()); } }