use std::path::{Path, PathBuf};
use std::time::SystemTime;
use crate::disk_refs::{ProcSnapshot, ReferenceCapture};
use crate::disk_tmp::{
find_oversized_logs, find_temp_cruft, LOG_ROOTS, TEMP_ROOTS,
};
use crate::git_temp::{
clean_git_temp_files, find_git_temp_files, git_owner_for, live_git_owners,
repo_root_for_git_path,
};
use crate::policy::{expand_tilde, human_bytes};
use crate::space_report::{
active_swap_paths, check_swap_health, classify_stray, classify_swapfile, DeadSwapfile,
StrayItem, UnreferencedSwap, WrongSwap,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Disposition {
Apply,
Skip,
Report,
}
impl Disposition {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Apply => "APPLY",
Self::Skip => "SKIP",
Self::Report => "REPORT",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum TrashClass {
GitTemp,
TempAndLogs,
CachesAndNix,
Strays,
}
impl TrashClass {
pub(crate) fn label(self) -> &'static str {
match self {
Self::GitTemp => "1 orphaned git temp",
Self::TempAndLogs => "2 tmp + logs",
Self::CachesAndNix => "3 caches + nix",
Self::Strays => "4 stray dirs/bundles",
}
}
pub(crate) const ALL: [TrashClass; 4] = [
Self::GitTemp,
Self::TempAndLogs,
Self::CachesAndNix,
Self::Strays,
];
}
#[derive(Debug, Clone)]
pub(crate) struct Finding {
pub(crate) class: TrashClass,
pub(crate) path: PathBuf,
pub(crate) bytes: u64,
pub(crate) disposition: Disposition,
pub(crate) reason: String,
pub(crate) references: ReferenceCapture,
pub(crate) human_command: Option<String>,
pub(crate) qualifying_floor_hours: Option<u64>,
}
impl Finding {
pub(crate) fn new(
class: TrashClass,
path: PathBuf,
bytes: u64,
disposition: Disposition,
reason: String,
) -> Self {
Self {
class,
path,
bytes,
disposition,
reason,
references: ReferenceCapture::default(),
human_command: None,
qualifying_floor_hours: None,
}
}
pub(crate) fn with_references(mut self, references: ReferenceCapture) -> Self {
self.references = references;
self
}
pub(crate) fn with_command(mut self, command: String) -> Self {
self.human_command = Some(command);
self
}
pub(crate) fn with_qualifying_floor(mut self, hours: u64) -> Self {
self.qualifying_floor_hours = Some(hours);
self
}
pub(crate) fn render(&self) -> String {
let mut line = format!(
" • {:<6} {} ({}) — {}",
self.disposition.label(),
self.path.display(),
human_bytes(self.bytes),
self.reason
);
if let Some(command) = &self.human_command {
line.push_str(&format!("\n run: {command}"));
}
line
}
pub(crate) fn needs_attention(&self) -> bool {
matches!(self.disposition, Disposition::Report)
|| self.human_command.is_some()
|| self.disposition == Disposition::Apply
}
}
#[derive(Debug, Default)]
pub(crate) struct DiskReport {
pub(crate) findings: Vec<Finding>,
pub(crate) empty_classes: Vec<TrashClass>,
pub(crate) incomplete: Vec<String>,
}
impl DiskReport {
pub(crate) fn applyable(&self) -> impl Iterator<Item = &Finding> {
self.findings
.iter()
.filter(|f| f.disposition == Disposition::Apply)
}
pub(crate) fn applyable_bytes(&self) -> u64 {
self.applyable().map(|f| f.bytes).sum()
}
pub(crate) fn report_only_bytes(&self) -> u64 {
self.findings
.iter()
.filter(|f| f.disposition == Disposition::Report)
.map(|f| f.bytes)
.sum()
}
pub(crate) fn render(&self, mode: &str, detail_limit: usize) -> String {
let mut out = String::new();
out.push_str(&format!("dracon-system disk — {mode}\n"));
let total: u64 = self.findings.iter().map(|f| f.bytes).sum();
out.push_str(&format!(
" {} finding(s), {} total\n",
self.findings.len(),
human_bytes(total)
));
for class in TrashClass::ALL {
let mut class_findings: Vec<&Finding> =
self.findings.iter().filter(|f| f.class == class).collect();
class_findings.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.path.cmp(&b.path)));
let class_bytes: u64 = class_findings.iter().map(|f| f.bytes).sum();
let apply_bytes: u64 = class_findings
.iter()
.filter(|f| f.disposition == Disposition::Apply)
.map(|f| f.bytes)
.sum();
let skip_count = class_findings
.iter()
.filter(|f| f.disposition == Disposition::Skip)
.count();
let report_count = class_findings
.iter()
.filter(|f| f.disposition == Disposition::Report)
.count();
out.push_str(&format!("\n### {}\n", class.label()));
if class_findings.is_empty() {
if self.empty_classes.contains(&class) {
out.push_str(" (nothing found)\n");
} else {
out.push_str(" (not scanned in this run)\n");
}
continue;
}
out.push_str(&format!(
" {} path(s), {} — {} SAFE/reclaimable, {} needing a decision, {} skipped\n",
class_findings.len(),
human_bytes(class_bytes),
human_bytes(apply_bytes),
report_count,
skip_count
));
class_findings.sort_by(|a, b| {
b.needs_attention()
.cmp(&a.needs_attention())
.then_with(|| b.bytes.cmp(&a.bytes))
});
let shown = if detail_limit == 0 {
class_findings.len()
} else {
detail_limit.min(class_findings.len())
};
for finding in class_findings.iter().take(shown) {
out.push_str(&finding.render());
out.push('\n');
}
if shown < class_findings.len() {
let rest: u64 = class_findings
.iter()
.skip(shown)
.map(|f| f.bytes)
.sum();
out.push_str(&format!(
" … {} more path(s), {} (use --detail 0, or --json for all)\n",
class_findings.len() - shown,
human_bytes(rest)
));
}
}
out.push_str(&format!(
"\nSAFE classes reclaimable now: {}\n",
human_bytes(self.applyable_bytes())
));
out.push_str(&format!(
"REPORT-ONLY (needs a human decision): {}\n",
human_bytes(self.report_only_bytes())
));
if !self.incomplete.is_empty() {
out.push_str("\n⚠️ INCOMPLETE SCAN (a clean result is not implied):\n");
for note in &self.incomplete {
out.push_str(&format!(" ! {note}\n"));
}
}
out
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum CacheFamily {
CargoRegistry,
Npm,
Bun,
Pip,
GoModules,
Playwright,
Puppeteer,
Chrome,
CodexCache,
CodexPluginsCache,
HermesCache,
HermesAudioCache,
HermesImageCache,
}
impl CacheFamily {
pub(crate) fn label(self) -> &'static str {
match self {
Self::CargoRegistry => "cargo registry",
Self::Npm => "npm",
Self::Bun => "bun",
Self::Pip => "pip",
Self::GoModules => "go modules",
Self::Playwright => "playwright",
Self::Puppeteer => "puppeteer",
Self::Chrome => "chrome",
Self::CodexCache => "codex",
Self::CodexPluginsCache => "codex plugins",
Self::HermesCache => "hermes",
Self::HermesAudioCache => "hermes audio",
Self::HermesImageCache => "hermes image",
}
}
pub(crate) fn relative_path(self) -> &'static str {
match self {
Self::CargoRegistry => ".cargo/registry/cache",
Self::Npm => ".npm",
Self::Bun => ".bun/install/cache",
Self::Pip => ".cache/pip",
Self::GoModules => "go/pkg/mod/cache/download",
Self::Playwright => ".cache/ms-playwright",
Self::Puppeteer => ".cache/puppeteer",
Self::Chrome => ".cache/google-chrome",
Self::CodexCache => ".codex/cache",
Self::CodexPluginsCache => ".codex/plugins/cache",
Self::HermesCache => ".hermes/cache",
Self::HermesAudioCache => ".hermes/audio_cache",
Self::HermesImageCache => ".hermes/image_cache",
}
}
pub(crate) fn process_names(self) -> &'static [&'static str] {
match self {
Self::CargoRegistry => &["cargo", "rustc", "cargo-build", "cargo-clippy"],
Self::Npm => &["npm", "node", "npx", "pnpm", "yarn"],
Self::Bun => &["bun", "bunx"],
Self::Pip => &["pip", "pip3", "python", "python3", "uv"],
Self::GoModules => &["go", "gopls"],
Self::Playwright => &["playwright", "node", "chrome", "chromium"],
Self::Puppeteer => &["puppeteer", "node", "chrome", "chromium"],
Self::Chrome => &["chrome", "chromium", "google-chrome"],
Self::CodexCache | Self::CodexPluginsCache => &["codex"],
Self::HermesCache => &["hermes", "hermes-agent"],
Self::HermesAudioCache | Self::HermesImageCache => &["hermes", "hermes-agent"],
}
}
pub(crate) const ALL: [CacheFamily; 13] = [
Self::CargoRegistry,
Self::Npm,
Self::Bun,
Self::Pip,
Self::GoModules,
Self::Playwright,
Self::Puppeteer,
Self::Chrome,
Self::CodexCache,
Self::CodexPluginsCache,
Self::HermesCache,
Self::HermesAudioCache,
Self::HermesImageCache,
];
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CacheUser {
pub(crate) pid: u32,
pub(crate) name: String,
}
pub(crate) fn cache_users(proc_root: &Path) -> std::collections::BTreeMap<CacheFamily, Vec<CacheUser>> {
let mut users: std::collections::BTreeMap<CacheFamily, Vec<CacheUser>> =
std::collections::BTreeMap::new();
let Ok(entries) = std::fs::read_dir(proc_root) else {
return users;
};
for entry in entries.flatten() {
let Some(pid) = entry
.file_name()
.to_str()
.and_then(|name| name.parse::<u32>().ok())
else {
continue;
};
let Ok(comm) = std::fs::read_to_string(entry.path().join("comm")) else {
continue;
};
let comm = comm.trim().to_string();
for family in CacheFamily::ALL {
if family
.process_names()
.iter()
.any(|name| comm == *name || comm.starts_with(&format!("{name}-")))
{
users.entry(family).or_default().push(CacheUser {
pid,
name: comm.clone(),
});
}
}
}
for list in users.values_mut() {
list.sort_by_key(|user| user.pid);
list.dedup();
}
users
}
pub(crate) fn describe_cache_users(users: &[CacheUser]) -> Option<String> {
if users.is_empty() {
return None;
}
let mut by_name: std::collections::BTreeMap<&str, Vec<u32>> = std::collections::BTreeMap::new();
for user in users {
by_name.entry(user.name.as_str()).or_default().push(user.pid);
}
let rendered: Vec<String> = by_name
.iter()
.map(|(name, pids)| {
if pids.len() == 1 {
format!("{name} (pid {})", pids[0])
} else {
let head: Vec<String> = pids.iter().take(2).map(|p| format!("pid {p}")).collect();
format!("{name} ({} processes; {})", pids.len(), head.join(", "))
}
})
.collect();
Some(rendered.join("; "))
}
pub(crate) const HIDDEN_TMP_REPORT_BYTES: u64 = 256 * 1024 * 1024;
#[derive(Debug, Clone)]
pub(crate) struct DiskOptions {
pub(crate) rust_search_roots: String,
pub(crate) stray_scan_roots: String,
pub(crate) dead_swapfile_candidates: String,
pub(crate) expected_swap_prefixes: String,
pub(crate) nixos_swap_config: String,
pub(crate) git_temp_min_age_hours: u64,
pub(crate) tmp_min_age_hours: u64,
pub(crate) tmp_e2e_patterns: String,
pub(crate) tmp_e2e_min_age_hours: u64,
}
pub(crate) fn tmp_cruft_floor(
file_name: &str,
age_hours: u64,
e2e_patterns: &[String],
general_floor_hours: u64,
e2e_floor_hours: u64,
) -> Option<u64> {
let floor = if e2e_patterns.is_empty() {
general_floor_hours
} else if crate::tmp_e2e_match(file_name, e2e_patterns) {
e2e_floor_hours
} else {
general_floor_hours
};
(age_hours >= floor).then_some(floor)
}
pub(crate) fn tree_is_live_churn(
newest_content_age_hours: Option<u64>,
threshold_hours: u64,
) -> bool {
match newest_content_age_hours {
Some(age) => age < threshold_hours,
None => true,
}
}
pub(crate) fn audit(
options: &DiskOptions,
proc_root: &Path,
now: SystemTime,
report_only: bool,
selection: Option<&[TrashClass]>,
) -> DiskReport {
let wanted = |class: TrashClass| {
selection.is_none_or(|selected| selected.contains(&class))
};
let mut report = DiskReport::default();
let snapshot = ProcSnapshot::read(proc_root);
let git_roots: Vec<PathBuf> = split_roots(&options.rust_search_roots);
let owners = live_git_owners(proc_root);
let mut git_found = 0usize;
for root in git_roots.iter().filter(|_| wanted(TrashClass::GitTemp)) {
for candidate in find_git_temp_files(root, 4) {
git_found += 1;
let references = snapshot.capture(&candidate.path);
let owner = repo_root_for_git_path(&candidate.path)
.and_then(|repo| git_owner_for(&owners, &repo).map(|o| format!("{}(pid {})", o.name, o.pid)));
let finding = if let Some(reason) = references.blocking_reason(&candidate.path) {
Finding::new(
TrashClass::GitTemp,
candidate.path.clone(),
candidate.bytes,
Disposition::Skip,
format!("in use — {reason}"),
)
.with_references(references)
} else if let Some(owner) = owner {
Finding::new(
TrashClass::GitTemp,
candidate.path.clone(),
candidate.bytes,
Disposition::Skip,
format!("live {owner} is packing this repository"),
)
.with_references(references)
} else if !candidate.is_expired(options.git_temp_min_age_hours) {
Finding::new(
TrashClass::GitTemp,
candidate.path.clone(),
candidate.bytes,
Disposition::Skip,
format!(
"younger than git_temp_min_age_hours={}h (may be an in-flight pack)",
options.git_temp_min_age_hours
),
)
.with_references(references)
} else {
Finding::new(
TrashClass::GitTemp,
candidate.path.clone(),
candidate.bytes,
Disposition::Apply,
format!(
"orphaned scratch, older than {}h, no owning git process",
options.git_temp_min_age_hours
),
)
.with_references(references)
};
report.findings.push(finding);
}
}
if report_only {
for finding in &mut report.findings {
if finding.disposition == Disposition::Apply {
finding.reason.push_str(" — report mode frees nothing");
}
}
}
if git_found == 0 {
report.empty_classes.push(TrashClass::GitTemp);
}
let mut tmp_found = 0usize;
let e2e_patterns: Vec<String> = if options.tmp_e2e_min_age_hours == 0 {
Vec::new()
} else {
crate::parse_tmp_e2e_patterns(&options.tmp_e2e_patterns)
};
let walk_floor = if e2e_patterns.is_empty() {
options.tmp_min_age_hours
} else {
options
.tmp_min_age_hours
.min(options.tmp_e2e_min_age_hours)
};
let tmp_entries = if wanted(TrashClass::TempAndLogs) {
find_temp_cruft(&TEMP_ROOTS, walk_floor, now)
} else {
Vec::new()
};
for entry in tmp_entries {
let hidden = entry
.path
.file_name()
.map(|name| name.to_string_lossy().starts_with('.'))
.unwrap_or(false);
if hidden && entry.bytes < HIDDEN_TMP_REPORT_BYTES {
continue;
}
let basename = entry
.path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let floor = match tmp_cruft_floor(
&basename,
entry.age_hours,
&e2e_patterns,
options.tmp_min_age_hours,
options.tmp_e2e_min_age_hours,
) {
Some(floor) => floor,
None => continue,
};
let fast_path = floor == options.tmp_e2e_min_age_hours
&& floor != options.tmp_min_age_hours
&& !e2e_patterns.is_empty();
tmp_found += 1;
let references = snapshot.capture(&entry.path);
let live_churn = entry.is_dir
&& tree_is_live_churn(entry.newest_content_age_hours, floor);
let finding = if let Some(reason) = references.blocking_reason(&entry.path) {
Finding::new(
TrashClass::TempAndLogs,
entry.path.clone(),
entry.bytes,
Disposition::Skip,
format!("in use — {reason}"),
)
} else {
Finding::new(
TrashClass::TempAndLogs,
entry.path.clone(),
entry.bytes,
if entry
.path
.file_name()
.map(|name| name.to_string_lossy().starts_with('.'))
.unwrap_or(false)
|| live_churn
{
Disposition::Report
} else {
Disposition::Apply
},
if live_churn {
let content_note = match entry.newest_content_age_hours {
Some(age) => format!("newest nested content is {age}h old"),
None => "nested content is unreadable (permissions) — cannot prove the tree is idle"
.to_string(),
};
format!(
"{} top-level mtime {}h old but {content_note} — live churn, under the {}h floor; never auto-deleted",
entry.label(), entry.age_hours, floor
)
} else if fast_path {
format!(
"{} untouched for {}h (e2e fast-path floor tmp_e2e_min_age_hours={})",
entry.label(),
entry.age_hours,
options.tmp_e2e_min_age_hours
)
} else {
format!(
"{} untouched for {}h (tmp_min_age_hours={})",
entry.label(),
entry.age_hours,
options.tmp_min_age_hours
)
},
)
};
report.findings.push(
finding
.with_references(references)
.with_qualifying_floor(floor),
);
}
let logs = if wanted(TrashClass::TempAndLogs) {
find_oversized_logs(&LOG_ROOTS, options.tmp_min_age_hours, now)
} else {
Vec::new()
};
for log in logs {
tmp_found += 1;
let references = snapshot.capture(&log.path);
let finding = if let Some(reason) = references.blocking_reason(&log.path) {
Finding::new(
TrashClass::TempAndLogs,
log.path.clone(),
log.bytes,
Disposition::Skip,
format!("in use — {reason}"),
)
} else {
Finding::new(
TrashClass::TempAndLogs,
log.path.clone(),
log.bytes,
Disposition::Apply,
log.reason(),
)
};
report.findings.push(finding.with_references(references));
}
let swaps = std::fs::read_to_string("/proc/swaps").unwrap_or_default();
let active = active_swap_paths(&swaps);
let swap_candidates = if wanted(TrashClass::TempAndLogs) {
split_roots(&options.dead_swapfile_candidates)
} else {
Vec::new()
};
let mut dead_reported: Vec<PathBuf> = Vec::new();
for path in swap_candidates {
let Ok(metadata) = std::fs::metadata(&path) else {
continue;
};
if let Some(dead) = classify_swapfile(&path, metadata.len(), &active) {
tmp_found += 1;
dead_reported.push(path.clone());
report.findings.push(dead_swapfile_finding(dead));
}
}
if wanted(TrashClass::TempAndLogs) {
let expected = split_prefixes(&options.expected_swap_prefixes);
let config_path = expand_tilde(&options.nixos_swap_config);
let outcome = check_swap_health(&expected, &dead_reported, &config_path);
for wrong in outcome.wrong {
tmp_found += 1;
report
.findings
.push(wrong_swap_finding(wrong, &expected));
}
for unref in outcome.unreferenced {
if dead_reported.iter().any(|p| p == &unref.path) {
continue;
}
tmp_found += 1;
report
.findings
.push(unreferenced_swapfile_finding(unref, &config_path));
}
if !outcome.config_known {
let note = match std::fs::read_to_string(&config_path) {
Err(_) => format!(
"NixOS swap config unreadable; swap reference check skipped: {}",
config_path.display()
),
Ok(_) => format!(
"no swapDevices block in {}; swap reference check skipped",
config_path.display()
),
};
report.incomplete.push(note);
}
}
if tmp_found == 0 {
report.empty_classes.push(TrashClass::TempAndLogs);
}
let mut cache_found = 0usize;
if !wanted(TrashClass::CachesAndNix) {
} else if let Some(home) = dirs::home_dir() {
let users = cache_users(proc_root);
for family in CacheFamily::ALL {
let path = home.join(family.relative_path());
let Ok(metadata) = std::fs::symlink_metadata(&path) else {
continue;
};
if metadata.file_type().is_symlink() || !metadata.is_dir() {
continue;
}
cache_found += 1;
let bytes = crate::disk_tmp::directory_bytes(&path);
let references = snapshot.capture(&path);
let in_use = describe_cache_users(users.get(&family).map(Vec::as_slice).unwrap_or(&[]));
let finding = match (references.blocking_reason(&path), in_use) {
(Some(reason), _) => Finding::new(
TrashClass::CachesAndNix,
path.clone(),
bytes,
Disposition::Skip,
format!("in use — {reason}"),
),
(None, Some(owners)) => Finding::new(
TrashClass::CachesAndNix,
path.clone(),
bytes,
Disposition::Skip,
format!("owned by {owners}; purge from within {} after it exits", family.label()),
),
(None, None) => Finding::new(
TrashClass::CachesAndNix,
path.clone(),
bytes,
Disposition::Report,
format!(
"{} cache; no owning process. Purge with the manager's own command so the cache index stays consistent (no --apply path)",
family.label()
),
),
};
report.findings.push(finding.with_references(references));
}
} else {
report.incomplete.push("home directory unknown; cache scan skipped".to_string());
}
if cache_found == 0 {
report.empty_classes.push(TrashClass::CachesAndNix);
}
let stray_roots = if wanted(TrashClass::Strays) {
split_roots(&options.stray_scan_roots)
} else {
Vec::new()
};
let mut stray_found = 0usize;
for root in &stray_roots {
let Ok(entries) = std::fs::read_dir(root) else {
report
.incomplete
.push(format!("stray scan root unreadable: {}", root.display()));
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(metadata) = std::fs::symlink_metadata(&path) else {
continue;
};
if metadata.file_type().is_symlink() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let Some(stray) = classify_stray(&path, &name, 0, metadata.is_dir()) else {
continue;
};
let bytes = if metadata.is_dir() {
crate::disk_tmp::directory_bytes(&path)
} else {
metadata.len()
};
let stray = StrayItem { bytes, ..stray };
stray_found += 1;
let references = snapshot.capture(&path);
report
.findings
.push(stray_finding(stray).with_references(references));
}
}
if stray_found == 0 {
report.empty_classes.push(TrashClass::Strays);
}
report
}
fn split_roots(raw: &str) -> Vec<PathBuf> {
raw.split(',')
.filter_map(|s| {
let s = s.trim();
(!s.is_empty()).then(|| expand_tilde(s))
})
.collect()
}
fn split_prefixes(raw: &str) -> Vec<String> {
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}
pub(crate) fn wrong_swap_finding(wrong: WrongSwap, expected: &[String]) -> Finding {
Finding::new(
TrashClass::TempAndLogs,
wrong.path,
wrong.bytes,
Disposition::Report,
format!(
"ACTIVE swap on an unexpected device (expected under: {}); fix the placement or extend expected_swap_prefixes, never delete it blind",
expected.join(", ")
),
)
}
pub(crate) fn unreferenced_swapfile_finding(found: UnreferencedSwap, config: &Path) -> Finding {
let reason = if found.is_active {
format!(
"ACTIVE swap with no NixOS swapDevices entry in {}: a rebuild/reboot may lose it — add a device entry, never delete it while active",
config.display()
)
} else {
format!(
"not referenced by any NixOS swapDevices entry in {}: stale or unmanaged, delete only by explicit approval",
config.display()
)
};
let finding = Finding::new(
TrashClass::TempAndLogs,
found.path.clone(),
found.bytes,
Disposition::Report,
reason,
);
if found.is_active {
return finding;
}
let command = if crate::space_report::is_writable_by_current_user(&found.path) {
format!("rm -f {}", found.path.display())
} else {
format!("sudo rm -f {}", found.path.display())
};
finding.with_command(command)
}
pub(crate) fn dead_swapfile_finding(dead: DeadSwapfile) -> Finding {
let command = dead.removal_command();
Finding::new(
TrashClass::TempAndLogs,
dead.path,
dead.bytes,
Disposition::Report,
"inactive swapfile (not in /proc/swaps): deleting it is a privileged decision, never automatic"
.to_string(),
)
.with_command(command)
}
pub(crate) fn stray_finding(stray: StrayItem) -> Finding {
Finding::new(
TrashClass::Strays,
stray.path,
stray.bytes,
Disposition::Report,
format!(
"{}: never auto-deleted, needs explicit per-path approval",
stray.kind.label()
),
)
}
pub(crate) fn apply_with_floors(
report: &mut DiskReport,
proc_root: &Path,
git_temp_min_age_hours: u64,
) -> (u64, Vec<DeletedRecord>) {
let mut freed = 0u64;
let mut records = Vec::new();
let owners = live_git_owners(proc_root);
let snapshot = ProcSnapshot::read(proc_root);
let mut git_candidates = Vec::new();
let mut others: Vec<Finding> = Vec::new();
for finding in report.findings.drain(..) {
if finding.disposition != Disposition::Apply {
others.push(finding);
continue;
}
if finding.class == TrashClass::GitTemp {
let age_hours = std::fs::metadata(&finding.path)
.and_then(|metadata| metadata.modified())
.ok()
.and_then(|modified| {
SystemTime::now()
.duration_since(modified)
.ok()
.map(|delta| delta.as_secs() / 3600)
});
git_candidates.push(crate::git_temp::GitTempFile {
path: finding.path.clone(),
bytes: finding.bytes,
age_hours,
});
others.push(finding);
} else {
others.push(finding);
}
}
report.findings = others;
let owner_of = |candidate: &Path| -> Option<String> {
let repo = repo_root_for_git_path(candidate)?;
git_owner_for(&owners, &repo).map(|o| format!("{}(pid {})", o.name, o.pid))
};
let outcome = clean_git_temp_files(git_candidates, git_temp_min_age_hours, owner_of, true);
for cleaned in outcome.cleaned {
if let Some(finding) = report
.findings
.iter()
.find(|f| f.path == cleaned.path)
.cloned()
{
freed += cleaned.bytes;
records.push(DeletedRecord::from_finding(&finding));
report.findings.retain(|f| f.path != cleaned.path);
}
}
let mut remaining = Vec::new();
for finding in report.findings.drain(..) {
if finding.disposition != Disposition::Apply {
remaining.push(finding);
continue;
}
let fresh = snapshot.capture(&finding.path);
if let Some(reason) = fresh.blocking_reason(&finding.path) {
let mut skipped = finding.clone();
skipped.disposition = Disposition::Skip;
skipped.reason = format!("in use at delete time — {reason}");
skipped.references = fresh;
remaining.push(skipped);
continue;
}
if std::fs::symlink_metadata(&finding.path).is_ok_and(|m| m.is_dir()) {
let (_, newest) = crate::disk_tmp::directory_bytes_and_age(
&finding.path,
SystemTime::now(),
);
let churn = match finding.qualifying_floor_hours {
Some(floor) => tree_is_live_churn(newest, floor),
None => true,
};
if churn {
let mut skipped = finding.clone();
skipped.disposition = Disposition::Report;
let content_note = match newest {
Some(age) => format!("newest nested content is {age}h old"),
None => "nested content is unreadable (permissions) — cannot prove the tree is idle"
.to_string(),
};
let floor_text = finding
.qualifying_floor_hours
.map(|floor| format!("{floor}h"))
.unwrap_or_else(|| "unknown".to_string());
skipped.reason = format!(
"became live churn before delete ({content_note}, qualifying floor {floor_text}) — kept"
);
skipped.references = fresh;
remaining.push(skipped);
continue;
}
}
let record = DeletedRecord::from_finding(&finding).with_references(&fresh);
let bytes = finding.bytes;
let result = if std::fs::symlink_metadata(&finding.path)
.is_ok_and(|m| m.is_dir())
{
std::fs::remove_dir_all(&finding.path)
} else {
std::fs::remove_file(&finding.path)
};
match result {
Ok(()) => {
freed += bytes;
records.push(record);
}
Err(error) => {
let mut failed = finding;
failed.reason = format!("delete failed: {error}");
failed.disposition = Disposition::Skip;
remaining.push(failed);
}
}
}
report.findings = remaining;
(freed, records)
}
#[derive(Debug, Clone)]
pub(crate) struct DeletedRecord {
pub(crate) class: TrashClass,
pub(crate) path: PathBuf,
pub(crate) bytes: u64,
pub(crate) references: ReferenceCapture,
}
impl DeletedRecord {
pub(crate) fn from_finding(finding: &Finding) -> Self {
Self {
class: finding.class,
path: finding.path.clone(),
bytes: finding.bytes,
references: finding.references.clone(),
}
}
pub(crate) fn with_references(mut self, references: &ReferenceCapture) -> Self {
self.references = references.clone();
self
}
pub(crate) fn evidence_row(&self) -> String {
format!(
"| `{}` | {} ({}) | {} | {} |",
self.path.display(),
human_bytes(self.bytes),
self.bytes,
self.class.label(),
self.references.describe().replace('|', "\\|")
)
}
}
#[cfg(test)]
mod disk_tests {
use super::*;
#[test]
fn every_objective_trash_class_has_a_label() {
let labels: Vec<&str> = TrashClass::ALL.iter().map(|c| c.label()).collect();
assert_eq!(labels.len(), 4);
assert!(labels[0].starts_with('1'));
assert!(labels[3].starts_with('4'));
}
#[test]
fn the_three_dispositions_render_distinctly() {
assert_eq!(Disposition::Apply.label(), "APPLY");
assert_eq!(Disposition::Skip.label(), "SKIP");
assert_eq!(Disposition::Report.label(), "REPORT");
}
#[test]
fn a_report_names_all_four_classes_even_when_nothing_is_found() {
let report = DiskReport {
empty_classes: TrashClass::ALL.to_vec(),
..DiskReport::default()
};
let text = report.render("audit", 0);
for class in TrashClass::ALL {
assert!(
text.contains(class.label()),
"report must name class {}",
class.label()
);
}
assert!(text.contains("(nothing found)"));
}
#[test]
fn an_incomplete_scan_never_reads_as_clean() {
let mut report = DiskReport::default();
report
.incomplete
.push("stray scan root unreadable: /nope".to_string());
let text = report.render("audit", 0);
assert!(text.contains("INCOMPLETE SCAN"));
assert!(text.contains("a clean result is not implied"));
}
#[test]
fn a_dead_swapfile_is_report_only_with_the_exact_command() {
let finding = dead_swapfile_finding(DeadSwapfile {
path: PathBuf::from("/var/lib/swapfile"),
bytes: 32 * 1024 * 1024 * 1024,
needs_root: true,
});
assert_eq!(finding.disposition, Disposition::Report);
let text = finding.render();
assert!(text.contains("REPORT"));
assert!(text.contains("run: sudo rm -f /var/lib/swapfile"));
}
#[test]
fn a_wrong_swap_is_report_only_without_a_command() {
let finding = wrong_swap_finding(
WrongSwap {
path: PathBuf::from("/var/lib/swapfile"),
bytes: 32 * 1024 * 1024 * 1024,
},
&["/dev/zram".to_string(), "/mnt/data".to_string()],
);
assert_eq!(finding.disposition, Disposition::Report);
assert_eq!(finding.class, TrashClass::TempAndLogs);
let text = finding.render();
assert!(text.contains("REPORT"));
assert!(text.contains("ACTIVE swap on an unexpected device"));
assert!(text.contains("/dev/zram"));
assert!(
!text.contains("run:"),
"live swap must never get a removal command: {text}"
);
}
#[test]
fn an_unreferenced_swapfile_commands_only_when_inactive() {
let config = Path::new("/etc/nixos/configuration.nix");
let inactive = unreferenced_swapfile_finding(
UnreferencedSwap {
path: PathBuf::from("/var/lib/swapfile"),
bytes: 1024,
is_active: false,
},
config,
);
assert_eq!(inactive.disposition, Disposition::Report);
assert!(inactive.render().contains("swapDevices"));
assert!(
inactive.human_command.is_some(),
"inactive unreferenced swapfile gets the removal command"
);
let live = unreferenced_swapfile_finding(
UnreferencedSwap {
path: PathBuf::from("/mnt/data/swapfile"),
bytes: 2048,
is_active: true,
},
config,
);
let text = live.render();
assert!(text.contains("ACTIVE swap with no NixOS swapDevices entry"));
assert!(
live.human_command.is_none(),
"active swap must never get a removal command: {text}"
);
}
#[test]
fn cache_users_carry_the_owning_pid() {
let dir = tempfile::tempdir().expect("tempdir");
let pid_dir = dir.path().join("4242");
std::fs::create_dir_all(&pid_dir).expect("mkdir");
std::fs::write(pid_dir.join("comm"), "npm\n").expect("write comm");
let users = cache_users(dir.path());
let npm = users
.get(&CacheFamily::Npm)
.expect("npm must be detected as a cache user");
assert_eq!(npm[0].pid, 4242);
assert_eq!(npm[0].name, "npm");
let described = describe_cache_users(npm).expect("description");
assert!(
described.contains("pid 4242"),
"the owning PID must appear: {described}"
);
}
#[test]
fn an_idle_cache_has_no_owning_process_description() {
assert_eq!(describe_cache_users(&[]), None);
}
#[test]
fn every_named_cache_family_has_a_path_and_process_list() {
assert_eq!(CacheFamily::ALL.len(), 13);
for family in CacheFamily::ALL {
assert!(!family.relative_path().is_empty());
assert!(
!family.process_names().is_empty(),
"{} needs processes to detect",
family.label()
);
}
}
#[test]
fn bun_playwright_puppeteer_and_chrome_are_covered() {
let labels: Vec<&str> = CacheFamily::ALL.iter().map(|c| c.label()).collect();
for wanted in ["bun", "playwright", "puppeteer", "chrome"] {
assert!(
labels.contains(&wanted),
"objective class 3 names {wanted}; covered families: {labels:?}"
);
}
}
#[test]
fn e2e_names_use_the_short_floor_while_plain_names_use_the_general_floor() {
let patterns =
crate::parse_tmp_e2e_patterns(&crate::policy::default_tmp_e2e_patterns());
assert_eq!(
tmp_cruft_floor("ai-tts-pro-eval-InAhom", 7, &patterns, 24, 6),
Some(6)
);
assert_eq!(tmp_cruft_floor("iso-route", 7, &patterns, 24, 6), Some(6));
assert_eq!(tmp_cruft_floor("iso-route", 5, &patterns, 24, 6), None);
assert_eq!(tmp_cruft_floor("my-notes.md", 7, &patterns, 24, 6), None);
assert_eq!(
tmp_cruft_floor("my-notes.md", 25, &patterns, 24, 6),
Some(24)
);
assert_eq!(tmp_cruft_floor("iso-route", 7, &[], 24, 6), None);
assert_eq!(tmp_cruft_floor("iso-route", 25, &[], 24, 6), Some(24));
}
#[test]
fn an_e2e_floor_above_the_general_floor_is_not_treated_as_a_fast_path() {
let patterns = crate::parse_tmp_e2e_patterns("iso-route");
assert_eq!(tmp_cruft_floor("iso-route", 7, &patterns, 6, 24), None);
assert_eq!(
tmp_cruft_floor("iso-route", 25, &patterns, 6, 24),
Some(24)
);
assert_eq!(tmp_cruft_floor("plain", 7, &patterns, 6, 24), Some(6));
assert_eq!(
tmp_cruft_floor("iso-route", 7, &patterns, 6, 6),
Some(6)
);
}
#[test]
fn the_audit_observed_families_match_the_fast_path_patterns() {
let patterns =
crate::parse_tmp_e2e_patterns(&crate::policy::default_tmp_e2e_patterns());
for name in [
"ai-tts-decide-D6HJnh",
"ai-tts-pro-eval-InAhom",
"iso-route",
"base-copy",
"synth4",
"junk-runner-e2e.qVEdrH",
"node-compile-cache",
"cw-browser-test-UUCxMS",
] {
assert!(
crate::tmp_e2e_match(name, &patterns),
"{name} must take the fast path"
);
}
assert!(!crate::tmp_e2e_match("junk-runner-e2e-qVEdrH", &patterns));
assert!(!crate::tmp_e2e_match("my-notes.md", &patterns));
assert!(!crate::tmp_e2e_match("node_modules", &patterns));
}
#[test]
fn agent_tool_caches_are_scoped_to_cache_subdirs_never_the_dotdir() {
assert_eq!(
CacheFamily::CodexCache.relative_path(),
".codex/cache"
);
assert_eq!(
CacheFamily::CodexPluginsCache.relative_path(),
".codex/plugins/cache"
);
assert_eq!(
CacheFamily::HermesCache.relative_path(),
".hermes/cache"
);
for family in [
CacheFamily::CodexCache,
CacheFamily::CodexPluginsCache,
CacheFamily::HermesCache,
CacheFamily::HermesAudioCache,
CacheFamily::HermesImageCache,
] {
assert!(
family.relative_path().contains('/'),
"{} must stay inside a cache subdir",
family.label()
);
}
}
#[test]
fn a_tree_with_fresh_nested_content_is_never_apply() {
let dir = tempfile::tempdir().expect("tempdir");
let tree = dir.path().join("node-compile-cache");
std::fs::create_dir_all(&tree).expect("mkdir");
let old = SystemTime::now() - std::time::Duration::from_secs(17 * 3600);
std::fs::File::options()
.read(true)
.open(&tree)
.expect("open dir")
.set_modified(old)
.expect("set dir mtime");
std::fs::write(tree.join("fresh.bin"), b"live write").expect("write");
let (bytes, newest) =
crate::disk_tmp::directory_bytes_and_age(&tree, SystemTime::now());
assert!(bytes > 0);
assert_eq!(
newest,
Some(0),
"the walk must report the NEWEST nested mtime, not the dir mtime"
);
assert!(tree_is_live_churn(newest, 6));
let stale = dir.path().join("stale-tree");
std::fs::create_dir_all(&stale).expect("mkdir");
std::fs::write(stale.join("old.bin"), b"stale").expect("write");
let old_file = SystemTime::now() - std::time::Duration::from_secs(17 * 3600);
std::fs::File::options()
.write(true)
.open(stale.join("old.bin"))
.expect("open")
.set_modified(old_file)
.expect("set mtime");
let (_, stale_newest) =
crate::disk_tmp::directory_bytes_and_age(&stale, SystemTime::now());
assert_eq!(stale_newest, Some(17));
assert!(!tree_is_live_churn(stale_newest, 6));
}
#[test]
fn an_unreadable_tree_counts_as_live_churn_not_as_deletable() {
assert!(
tree_is_live_churn(None, 6),
"unknown content age must keep the tree, never authorize deletion"
);
assert!(!tree_is_live_churn(Some(6), 6), "exactly at the floor is old");
assert!(tree_is_live_churn(Some(5), 6));
assert!(!tree_is_live_churn(Some(0), 0), "a zero floor disables the gate");
}
#[test]
fn apply_only_touches_apply_class_findings() {
let dir = tempfile::tempdir().expect("tempdir");
let doomed = dir.path().join("doomed.bin");
std::fs::write(&doomed, b"x").expect("write");
let kept = dir.path().join("kept.bin");
std::fs::write(&kept, b"y").expect("write");
let mut report = DiskReport::default();
report.findings.push(Finding::new(
TrashClass::TempAndLogs,
doomed.clone(),
1,
Disposition::Apply,
"test".to_string(),
));
report.findings.push(Finding::new(
TrashClass::Strays,
kept.clone(),
1,
Disposition::Report,
"test".to_string(),
));
let (freed, records) = apply_with_floors(&mut report, Path::new("/proc"), 6);
assert_eq!(freed, 1);
assert_eq!(records.len(), 1);
assert!(!doomed.exists(), "APPLY finding is deleted");
assert!(kept.exists(), "REPORT finding is never auto-deleted");
}
#[test]
fn a_tree_that_became_live_churn_after_the_audit_is_kept_at_delete_time() {
let dir = tempfile::tempdir().expect("tempdir");
let tree = dir.path().join("reused-test-tree");
std::fs::create_dir_all(&tree).expect("mkdir");
std::fs::write(tree.join("written-now.bin"), b"live").expect("write");
let mut report = DiskReport::default();
report.findings.push(
Finding::new(
TrashClass::TempAndLogs,
tree.clone(),
5,
Disposition::Apply,
"stale at audit time".to_string(),
)
.with_references(crate::disk_refs::ReferenceCapture {
refs: Vec::new(),
scan_failed: false,
})
.with_qualifying_floor(6),
);
let (freed, records) = apply_with_floors(&mut report, Path::new("/proc"), 6);
assert_eq!(freed, 0, "live-churn tree must not be deleted");
assert!(records.is_empty());
assert!(tree.exists(), "the tree survives");
let kept = report
.findings
.iter()
.find(|f| f.path == tree)
.expect("finding retained");
assert_eq!(kept.disposition, Disposition::Report);
assert!(
kept.reason.contains("became live churn"),
"reason must explain the refusal: {}",
kept.reason
);
}
#[test]
fn the_delete_time_check_uses_the_floor_that_qualified_the_finding() {
let dir = tempfile::tempdir().expect("tempdir");
let age_12h = SystemTime::now() - std::time::Duration::from_secs(12 * 3600);
let general_tree = dir.path().join("general-floor-tree");
std::fs::create_dir_all(&general_tree).expect("mkdir");
let general_file = general_tree.join("restored.bin");
std::fs::write(&general_file, b"restored").expect("write");
std::fs::File::options()
.write(true)
.open(&general_file)
.expect("open")
.set_modified(age_12h)
.expect("set mtime");
let mut general_report = DiskReport::default();
general_report.findings.push(
Finding::new(
TrashClass::TempAndLogs,
general_tree.clone(),
8,
Disposition::Apply,
"general floor".to_string(),
)
.with_references(crate::disk_refs::ReferenceCapture {
refs: Vec::new(),
scan_failed: false,
})
.with_qualifying_floor(24),
);
let (freed_a, records_a) =
apply_with_floors(&mut general_report, Path::new("/proc"), 6);
assert_eq!(freed_a, 0, "24h-floor tree must not be deleted");
assert!(records_a.is_empty());
assert!(
general_tree.exists(),
"a general-floor tree whose content is 12h old is still newer than its 24h floor — it must survive"
);
let e2e_tree = dir.path().join("e2e-floor-tree");
std::fs::create_dir_all(&e2e_tree).expect("mkdir");
let e2e_file = e2e_tree.join("restored.bin");
std::fs::write(&e2e_file, b"restored").expect("write");
std::fs::File::options()
.write(true)
.open(&e2e_file)
.expect("open")
.set_modified(age_12h)
.expect("set mtime");
let mut e2e_report = DiskReport::default();
e2e_report.findings.push(
Finding::new(
TrashClass::TempAndLogs,
e2e_tree.clone(),
8,
Disposition::Apply,
"e2e floor".to_string(),
)
.with_references(crate::disk_refs::ReferenceCapture {
refs: Vec::new(),
scan_failed: false,
})
.with_qualifying_floor(6),
);
let (freed_b, records_b) =
apply_with_floors(&mut e2e_report, Path::new("/proc"), 6);
assert_eq!(freed_b, 8, "6h-floor tree with 12h-old content is idle");
assert_eq!(records_b.len(), 1);
assert!(!e2e_tree.exists(), "the idle tree is removed");
}
#[test]
fn a_finding_without_a_known_floor_fails_closed_and_keeps_the_tree() {
let dir = tempfile::tempdir().expect("tempdir");
let tree = dir.path().join("no-floor-tree");
std::fs::create_dir_all(&tree).expect("mkdir");
std::fs::write(tree.join("old.bin"), b"old").expect("write");
let old = SystemTime::now() - std::time::Duration::from_secs(48 * 3600);
std::fs::File::options()
.write(true)
.open(tree.join("old.bin"))
.expect("open")
.set_modified(old)
.expect("set mtime");
let mut report = DiskReport::default();
report.findings.push(
Finding::new(
TrashClass::TempAndLogs,
tree.clone(),
3,
Disposition::Apply,
"no floor recorded".to_string(),
)
.with_references(crate::disk_refs::ReferenceCapture {
refs: Vec::new(),
scan_failed: false,
}),
);
let (freed, records) = apply_with_floors(&mut report, Path::new("/proc"), 6);
assert_eq!(freed, 0, "unknown floor must not authorize deletion");
assert!(records.is_empty());
assert!(tree.exists(), "the tree survives when the floor is unknown");
}
#[test]
fn the_end_to_end_path_keeps_a_general_floor_tree_refreshed_during_the_gap() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
let now = SystemTime::now();
let age = |hours: u64| now - std::time::Duration::from_secs(hours * 3600);
let general_tree = root.join("general-end-to-end");
std::fs::create_dir_all(&general_tree).expect("mkdir");
let general_file = general_tree.join("data.bin");
std::fs::write(&general_file, b"payload").expect("write");
std::fs::File::options()
.write(true)
.open(&general_file)
.expect("open")
.set_modified(age(30))
.expect("set mtime");
std::fs::File::options()
.read(true)
.open(&general_tree)
.expect("open dir")
.set_modified(age(30))
.expect("set dir mtime");
let patterns = crate::parse_tmp_e2e_patterns(&crate::policy::default_tmp_e2e_patterns());
let entries = crate::disk_tmp::find_temp_cruft(&[root.to_str().expect("utf8")], 6, now);
let entry = entries
.iter()
.find(|e| e.path == general_tree)
.expect("the tree is scanned");
let floor = tmp_cruft_floor(
"general-end-to-end",
entry.age_hours,
&patterns,
24,
6,
)
.expect("the tree clears the walk floor");
assert_eq!(floor, 24, "a non-pattern name uses the general floor");
assert_eq!(entry.newest_content_age_hours, Some(30));
assert!(
!tree_is_live_churn(entry.newest_content_age_hours, floor),
"30h-old content clears the 24h floor: the audit marks it APPLY"
);
std::fs::File::options()
.write(true)
.open(&general_file)
.expect("open")
.set_modified(age(12))
.expect("set mtime");
let mut report = DiskReport::default();
report.findings.push(
Finding::new(
TrashClass::TempAndLogs,
general_tree.clone(),
entry.bytes,
Disposition::Apply,
"scanned at audit time".to_string(),
)
.with_references(crate::disk_refs::ReferenceCapture {
refs: Vec::new(),
scan_failed: false,
})
.with_qualifying_floor(floor),
);
let (freed, records) = apply_with_floors(&mut report, Path::new("/proc"), 6);
assert_eq!(freed, 0, "the 24h-floor tree must survive");
assert!(records.is_empty());
assert!(
general_tree.exists(),
"content refreshed to 12h is still newer than the 24h floor that qualified the finding"
);
}
#[test]
fn a_path_that_becomes_used_after_the_audit_is_skipped_at_delete_time() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("freshly-used.bin");
std::fs::write(&path, b"z").expect("write");
let mut report = DiskReport::default();
report.findings.push(
Finding::new(
TrashClass::TempAndLogs,
path.clone(),
1,
Disposition::Apply,
"test".to_string(),
)
.with_references(crate::disk_refs::ReferenceCapture {
refs: Vec::new(),
scan_failed: false,
}),
);
let (freed, records) = apply_with_floors(&mut report, Path::new("/definitely/not/proc"), 6);
assert_eq!(freed, 0, "an unreadable /proc must not authorise a delete");
assert!(records.is_empty());
assert!(path.exists());
assert_eq!(report.findings[0].disposition, Disposition::Skip);
}
#[test]
fn evidence_rows_carry_bytes_and_the_pre_delete_capture() {
let record = DeletedRecord {
class: TrashClass::GitTemp,
path: PathBuf::from("/home/u/Dev/r/.git/objects/pack/tmp_pack_a"),
bytes: 1234,
references: crate::disk_refs::ReferenceCapture::default(),
};
let row = record.evidence_row();
assert!(row.contains("tmp_pack_a"));
assert!(row.contains("1234"));
assert!(row.contains("no /proc exe, cwd or fd reference"));
}
}