From 18a0a795585415d7e045744d1453b328dde560c7 Mon Sep 17 00:00:00 2001 From: UnbreakableMJ Date: Thu, 6 Aug 2026 14:33:17 +0300 Subject: [PATCH] Add temp-file and Cargo-unit cleaners, reclaiming without external tools Vacuum could not cover the workflow it was built to replace. Reclaiming disk space still meant dropping to a shell for `sudo rm -r /tmp/*`, a system-wide `nix-collect-garbage`, a time-based `journalctl --vacuum`, and cargo-sweep. Two cleaners close that gap, both reading the filesystem directly and executing nothing: * `temp-files` (new category) offers an entry under /tmp, /var/tmp, or $TMPDIR only when it is not a symlink, is owned by the invoking user, has gone untouched for seven days, and is not live session state. Root-owned leftovers stay out of scope and are reclaimed through a printed `sudo systemd-tmpfiles --clean` instead. * `cargo-prune` removes dead build units from inside a Cargo target/ directory, so a project still in use keeps its warm cache rather than paying for a full rebuild. Units are grouped by the compiler hash Cargo records in each fingerprint; the group with the most recent activity is the toolchain in use and the rest are leftovers. Freshness comes from the invoked.timestamp file Cargo writes for the purpose, never from access time, which relatime does not update when Cargo reuses an artifact. The incremental cache is offered separately, and a profile whose .cargo-lock is held by a running build is skipped entirely. Package-manager GC also gains system-wide `nix-collect-garbage -d --verbose`, `journalctl --vacuum-time=7d`, and `systemd-tmpfiles --clean`, all as printed sudo lines; Vacuum still never escalates. Three changes to the core contract were required rather than incidental: * `Cleaner::extra_roots` lets a cleaner declare delete roots beyond the scan roots. /tmp is outside $HOME, so every temp candidate would otherwise be refused as outside the allowed roots. The widened set applies only when that cleaner is selected; the protected-prefix and symlink guards are untouched. * `Candidate::trash_ok` marks candidates the trash cannot help. A path under /tmp trashes into /tmp/.Trash-$uid on the same filesystem and frees nothing, and a batch of hundreds of regenerable files would scatter as many trash entries. These purge instead, with the reason reported rather than applied silently. --apply is still required. * `Target::Batch` carries the many scattered paths one logical reclaim covers. The safety gate runs over every path before any is removed, so a batch is refused whole rather than half-applied. Sizes now count a hardlinked inode once. Cargo hardlinks its final artifacts into place, so summing file lengths naively over-reported this workspace by about 12% and would have claimed the same bytes twice. Frontends group by category rather than by cleaner, so a category appears once however many cleaners feed it, and --cleaner selects a single one where their candidates deliberately overlap. --stale-days and a stale_days config key set the staleness window. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FCJbPm4FWS99y9utbUe13z --- Cargo.lock | 10 + Cargo.toml | 8 + README.md | 51 +- crates/vacuum-cleaners/Cargo.toml | 6 +- crates/vacuum-cleaners/src/build_artifacts.rs | 11 +- crates/vacuum-cleaners/src/caches.rs | 5 +- crates/vacuum-cleaners/src/cargo_prune.rs | 916 ++++++++++++++++++ crates/vacuum-cleaners/src/large_files.rs | 1 + crates/vacuum-cleaners/src/lib.rs | 12 + crates/vacuum-cleaners/src/package_gc.rs | 24 + crates/vacuum-cleaners/src/rustc_hash.rs | 221 +++++ crates/vacuum-cleaners/src/temp_files.rs | 303 ++++++ crates/vacuum-cli/src/app.rs | 107 +- crates/vacuum-cli/src/cli.rs | 13 + crates/vacuum-cli/src/config.rs | 56 +- crates/vacuum-cli/src/introspect.rs | 34 + crates/vacuum-cli/src/main.rs | 24 +- crates/vacuum-cli/tests/cli.rs | 136 +++ crates/vacuum-core/src/cleaner.rs | 70 +- crates/vacuum-core/src/delete.rs | 217 ++++- crates/vacuum-core/src/lib.rs | 2 +- crates/vacuum-core/src/scan.rs | 74 +- crates/vacuum-tui/src/lib.rs | 106 +- doc/vacuum.texi | 122 ++- 24 files changed, 2430 insertions(+), 99 deletions(-) create mode 100644 crates/vacuum-cleaners/src/cargo_prune.rs create mode 100644 crates/vacuum-cleaners/src/rustc_hash.rs create mode 100644 crates/vacuum-cleaners/src/temp_files.rs diff --git a/Cargo.lock b/Cargo.lock index ee5b18f..3cce790 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -995,6 +995,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rustc-stable-hash" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "781442f29170c5c93b7185ad559492601acdc71d5bb0706f5868094f45cfcd08" + [[package]] name = "rustix" version = "0.38.44" @@ -1344,6 +1350,10 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" name = "vacuum-cleaners" version = "0.1.0" dependencies = [ + "rustc-stable-hash", + "rustix 1.1.4", + "serde", + "serde_json", "tempfile", "vacuum-core", "which", diff --git a/Cargo.toml b/Cargo.toml index eba6479..a423d65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,14 @@ jiff = { version = "0.2", features = ["serde"] } jwalk = "0.8" trash = "5" which = "7" +# Safe syscall wrappers. `geteuid` for temp-file ownership checks, `flock` to +# detect a Cargo build in progress — the `libc` equivalents would require +# `unsafe`, which every crate forbids. +rustix = { version = "1.1", features = ["process", "fs"] } +# rustc's own stable hasher (rust-lang/rustc-stable-hash). Reproduces the +# `"rustc"` value Cargo writes into each fingerprint, so Vacuum can name the +# toolchain that built an artifact without executing any compiler. +rustc-stable-hash = "0.1" # CLI clap = { version = "4", features = ["derive", "env", "wrap_help"] } diff --git a/README.md b/README.md index e6bf68b..1fa3530 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,58 @@ The Steelbore Standard. | Category | Examples | Risk | |----------|----------|------| -| **Dev build artifacts** | Rust `target/`, `node_modules`, `.next`, `dist`, `build`, `__pycache__` | Safe — fully regenerable | -| **Package-manager garbage** | `nix-collect-garbage -d`, unused Flatpak runtimes, cargo registry cache, journald logs | Low — reclaimed by each tool | +| **Dev build artifacts** | Rust `target/`, `node_modules`, `.next`, `dist`, `build`, `__pycache__` — plus a native prune of dead units *inside* a `target/` you want to keep | Safe — fully regenerable | +| **Package-manager garbage** | `nix-collect-garbage -d` (user and system-wide), unused Flatpak runtimes, `journalctl --vacuum-time`, `systemd-tmpfiles --clean`, podman/docker prune | Low — reclaimed by each tool | | **App / user caches** | `~/.cache`, browser caches, regenerable model blobs | Low | +| **Stale temp files** | Entries under `/tmp`, `/var/tmp`, `$TMPDIR` that are yours and untouched for 7+ days | Low — never touches live session state | | **Large files** | The biggest files and directories, browsed interactively | Your call | +### About the Cargo prune + +Deleting a whole `target/` costs a full rebuild. The prune instead removes only the +units that are already dead, so a project you are still working on keeps its warm +cache. It runs entirely off the filesystem — Vacuum never executes `cargo`, +`rustup`, or `rustc`, which matters on a Nix or Guix system where there may be no +runnable toolchain on `PATH` at all. + +Three things are offered per target directory: + +- **units from an old toolchain.** Each build unit records the compiler that made + it. Units are grouped by that value and the group with the most recent activity + is the one in use; the rest are left over from a compiler you have since + changed. Where the version can be named it is (`2 units from rustc 1.95.0`). +- **cold units**, not rebuilt in `--stale-days` days (default 30). Freshness comes + from the `invoked.timestamp` file Cargo writes for the purpose — never access + time, which is not updated on `relatime` mounts when Cargo reuses an artifact. +- **the incremental cache**, which is pure rebuild-time state and often the + largest single item in a `target/`. + +Guards: a profile whose `.cargo-lock` is held by a running build is skipped +entirely; the unhashed final binaries are never touched; a unit whose fingerprint +cannot be read is kept, never swept; and sizes count hardlinked inodes once, so +the reported figure is what you actually get back. + +These candidates overlap the whole-directory one for the same target — take one or +the other. Use `--cleaner cargo-prune` to select only the prune: + +```sh +vacuum list --cleaner cargo-prune +vacuum clean --cleaner cargo-prune --apply +``` + +### About the temp-file cleaner + +It is the safe replacement for `sudo rm -r /tmp/*`, which destroys other users' +files and the live state of running processes. An entry is offered only when it is +not a symlink, is owned by you, has gone untouched for seven days, and is not +session state (`.X11-unix`, `systemd-private-*`, `.Trash-*`, and friends). Root-owned +leftovers are left to `sudo systemd-tmpfiles --clean`, which Vacuum prints for you. + +These candidates are **purged rather than trashed**, even without `--purge`: the trash +for a path under `/tmp` is `/tmp/.Trash-$uid`, on the same filesystem, so trashing +would reclaim nothing at exactly the moment you need the space. Vacuum says so in +its output rather than doing it quietly, and `--apply` is still required. + ## Safety first - **Dry-run by default.** Nothing is deleted until you pass `--apply`. diff --git a/crates/vacuum-cleaners/Cargo.toml b/crates/vacuum-cleaners/Cargo.toml index 3a0849b..8564a2d 100644 --- a/crates/vacuum-cleaners/Cargo.toml +++ b/crates/vacuum-cleaners/Cargo.toml @@ -10,13 +10,17 @@ rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true -description = "The Vacuum cleaner catalog: build artifacts, package GC, caches, large files." +description = "The Vacuum cleaner catalog: build artifacts, package GC, caches, temp files, large files." keywords.workspace = true categories.workspace = true [dependencies] vacuum-core.workspace = true which.workspace = true +rustix.workspace = true +rustc-stable-hash.workspace = true +serde.workspace = true +serde_json.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/vacuum-cleaners/src/build_artifacts.rs b/crates/vacuum-cleaners/src/build_artifacts.rs index 0246bbb..c8e74d0 100644 --- a/crates/vacuum-cleaners/src/build_artifacts.rs +++ b/crates/vacuum-cleaners/src/build_artifacts.rs @@ -70,6 +70,7 @@ impl Cleaner for BuildArtifacts { detail: Some("regenerable build output".to_owned()), bytes, regenerable: true, + trash_ok: true, risk: Risk::Safe, target: Target::Path { path }, }); @@ -97,14 +98,12 @@ mod tests { fs::write(proj.join("Cargo.toml"), b"[package]").unwrap(); fs::write(proj.join("target/artifact"), vec![0_u8; 4096]).unwrap(); - let ctx = ScanContext { - roots: vec![tmp.path().to_path_buf()], - }; + let ctx = ScanContext::new(vec![tmp.path().to_path_buf()]); let found = BuildArtifacts.scan(&ctx).unwrap(); assert_eq!(found.len(), 1); match &found[0].target { Target::Path { path } => assert_eq!(path.file_name().unwrap(), "target"), - Target::Command { .. } => panic!("expected a path target"), + other => panic!("expected a path target, got {other:?}"), } } @@ -114,9 +113,7 @@ mod tests { fs::create_dir_all(tmp.path().join("misc/target")).unwrap(); fs::write(tmp.path().join("misc/target/data"), vec![0_u8; 4096]).unwrap(); - let ctx = ScanContext { - roots: vec![tmp.path().to_path_buf()], - }; + let ctx = ScanContext::new(vec![tmp.path().to_path_buf()]); assert!(BuildArtifacts.scan(&ctx).unwrap().is_empty()); } } diff --git a/crates/vacuum-cleaners/src/caches.rs b/crates/vacuum-cleaners/src/caches.rs index cb97f02..36e6875 100644 --- a/crates/vacuum-cleaners/src/caches.rs +++ b/crates/vacuum-cleaners/src/caches.rs @@ -77,6 +77,7 @@ impl Caches { detail: Some("cache (regenerated on demand)".to_owned()), bytes, regenerable: true, + trash_ok: true, risk: Risk::Safe, target: Target::Path { path }, }); @@ -98,9 +99,7 @@ mod tests { fs::create_dir_all(&cache).unwrap(); fs::write(cache.join("blob"), vec![0_u8; 2048]).unwrap(); - let ctx = ScanContext { - roots: vec![tmp.path().to_path_buf()], - }; + let ctx = ScanContext::new(vec![tmp.path().to_path_buf()]); let found = Caches.scan(&ctx).unwrap(); assert_eq!(found.len(), 1); assert_eq!(found[0].bytes, 2048); diff --git a/crates/vacuum-cleaners/src/cargo_prune.rs b/crates/vacuum-cleaners/src/cargo_prune.rs new file mode 100644 index 0000000..7b4bd43 --- /dev/null +++ b/crates/vacuum-cleaners/src/cargo_prune.rs @@ -0,0 +1,916 @@ +// SPDX-FileCopyrightText: 2026 Mohamed Hammad +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Prune dead build units from Cargo `target/` directories. +//! +//! This complements the whole-directory build-artifacts cleaner. That one +//! removes a `target/` outright, costing a full rebuild; this one removes only +//! the units that are already dead, so an active project keeps its warm +//! incremental cache. +//! +//! # Why this is native +//! +//! The obvious implementation is to shell out to `cargo-sweep`. Vacuum does not, +//! for three reasons. `cargo-sweep` is unmaintained. It requires `cargo +//! metadata`, `rustup toolchain list`, and `rustc -vV` to run — none of which +//! work on a system whose toolchain is managed by Nix or Guix rather than +//! rustup, which is precisely where this tool is used. And its age filter reads +//! *atime*, which under the near-universal `relatime` mount option is not +//! updated when Cargo reuses an artifact, so it deletes warm caches. +//! +//! Vacuum executes nothing. Everything below is read off the filesystem. +//! +//! # How a dead unit is recognised +//! +//! A build unit is a directory `target//.fingerprint/-/`. +//! Its sibling outputs in `deps/`, `build/`, and the profile root share the same +//! 16-hex `` suffix, which is the only reliable way to correlate them — +//! `.fingerprint` spells the package name with hyphens while `deps` spells the +//! crate name with underscores. +//! +//! Each unit's `*.json` records `"rustc": `, Cargo's hash of the compiler's +//! `rustc -vV` output. Units are grouped by that value; the group with the most +//! recent activity is the toolchain in use, and every other group is left over +//! from a compiler that is no longer building this project. +//! +//! Freshness comes from the mtime of `.fingerprint//invoked.timestamp`, +//! the file Cargo writes for exactly this purpose, falling back to the unit +//! directory's own mtime for the build-script units that have none. +//! +//! Rust guideline compliant 2026-05-18 + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use serde::Deserialize; +use vacuum_core::{ + Candidate, Category, Cleaner, Result, Risk, ScanContext, Target, sum_unique_files, +}; + +use crate::rustc_hash::{hash_verbose_version, toolchain_release}; + +/// The magic first line of a `CACHEDIR.TAG`, per the cache-directory-tagging +/// specification (). +/// +/// Cargo writes this file at the root of every `target/` directory. Matching on +/// it identifies a target directory wherever it lives, which a search for the +/// literal name `target` would not: `CARGO_TARGET_DIR` and `build.target-dir` +/// both rename it freely. +const CACHEDIR_SIGNATURE: &str = "Signature: 8a477f597d28d172789f06886806bc55"; + +/// Directories inside a profile whose entries are named `-` and are +/// therefore correlatable to a fingerprint unit. +/// +/// `examples/` and the profile root's unhashed final artifacts are excluded on +/// purpose: they carry no hash, and the profile root's binaries are hardlinks of +/// files in `deps/` that Cargo re-creates on the next build. +const UNIT_DIRS: &[&str] = &["deps", "build", "native", ".fingerprint"]; + +/// How many hex characters a Cargo unit hash has. +const HASH_LEN: usize = 16; + +/// The `"rustc"` value Cargo writes for fingerprints that track a build script's +/// output; it does not correspond to any real compiler. +/// +/// Treated as always-live so these units are never swept as "old toolchain". +const BUILD_SCRIPT_RUSTC: u64 = 0; + +/// Prunes dead build units out of Cargo target directories. +#[derive(Debug, Clone, Copy)] +pub struct CargoPrune; + +impl Cleaner for CargoPrune { + fn id(&self) -> &'static str { + "cargo-prune" + } + + fn name(&self) -> &'static str { + "Stale Cargo build units" + } + + fn category(&self) -> Category { + Category::BuildArtifacts + } + + fn scan(&self, ctx: &ScanContext) -> Result> { + let max_age = Duration::from_secs(ctx.stale_days * 24 * 60 * 60); + let now = SystemTime::now(); + + let targets: Vec = ctx + .roots + .iter() + .flat_map(|root| find_target_dirs(root)) + .collect(); + + // Names are pooled across every target directory found. A directory + // records only the compiler that built it *last*, so the toolchain a + // stale unit came from is usually nameable only because some other + // project on this machine still builds with it. + let mut names = ToolchainNames::default(); + for target in &targets { + names.absorb(target); + } + + let mut candidates = Vec::new(); + for target in &targets { + candidates.extend(prune_plan(target, &names, max_age, now)); + } + + candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.bytes)); + Ok(candidates) + } +} + +// --- discovery ------------------------------------------------------------ + +/// Find every Cargo target directory beneath `root`. +/// +/// A directory qualifies when it holds a `CACHEDIR.TAG` carrying the Cargo +/// signature. Matched directories are not descended into: a target directory +/// never contains another. +fn find_target_dirs(root: &Path) -> Vec { + let mut found = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + + while let Some(dir) = stack.pop() { + if is_target_dir(&dir) { + found.push(dir); + continue; + } + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + // Never follow a symlink out of the tree being scanned. + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + stack.push(entry.path()); + } + } + } + + found.sort(); + found +} + +/// Whether `dir` is a Cargo target directory. +fn is_target_dir(dir: &Path) -> bool { + let Ok(tag) = std::fs::read_to_string(dir.join("CACHEDIR.TAG")) else { + return false; + }; + tag.lines().next() == Some(CACHEDIR_SIGNATURE) +} + +/// Whether a Cargo build currently holds the lock on `profile`. +/// +/// Cargo takes an exclusive `flock` on `/.cargo-lock` for the duration +/// of a build. Pruning underneath a running build would delete artifacts it is +/// about to link, so a locked profile is skipped entirely. The probe takes the +/// lock non-blockingly and drops it immediately; failing to acquire it is the +/// signal, not an error. +fn build_in_progress(profile: &Path) -> bool { + use rustix::fs::{FlockOperation, flock}; + + let lock = profile.join(".cargo-lock"); + let Ok(file) = std::fs::File::open(&lock) else { + // No lock file means Cargo has never built here, so nothing is running. + return false; + }; + match flock(&file, FlockOperation::NonBlockingLockExclusive) { + Ok(()) => { + let _ = flock(&file, FlockOperation::Unlock); + false + } + Err(_) => true, + } +} + +/// The profile directories inside `target` that hold build units. +/// +/// Handles both the common `target//` layout and the +/// `target///` layout produced by `--target`. +fn profile_dirs(target: &Path) -> Vec { + let mut profiles = Vec::new(); + let Ok(entries) = std::fs::read_dir(target) else { + return profiles; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + if path.join(".fingerprint").is_dir() { + profiles.push(path); + continue; + } + // A target-triple directory: the profiles are one level further down. + let Ok(nested) = std::fs::read_dir(&path) else { + continue; + }; + for inner in nested.flatten() { + let inner = inner.path(); + if inner.join(".fingerprint").is_dir() { + profiles.push(inner); + } + } + } + + profiles.sort(); + profiles +} + +// --- fingerprints --------------------------------------------------------- + +/// The only field of a fingerprint JSON this cleaner needs. +#[derive(Debug, Deserialize)] +struct Fingerprint { + rustc: u64, +} + +/// One build unit: its hash suffix, the compiler that produced it, and when it +/// was last built. +#[derive(Debug, Clone)] +struct Unit { + hash: String, + rustc: u64, + last_used: SystemTime, +} + +/// Read every build unit recorded under `profile`. +/// +/// A unit whose fingerprint JSON is missing or unreadable is reported with +/// [`BUILD_SCRIPT_RUSTC`], which keeps it: an artifact whose provenance cannot +/// be established is never swept. +fn read_units(profile: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(profile.join(".fingerprint")) else { + return Vec::new(); + }; + + let mut units = Vec::new(); + for entry in entries.flatten() { + let dir = entry.path(); + if !dir.is_dir() { + continue; + } + let Some(hash) = dir + .file_name() + .and_then(|name| name.to_str()) + .and_then(hash_suffix) + else { + continue; + }; + + units.push(Unit { + hash: hash.to_owned(), + rustc: read_rustc(&dir).unwrap_or(BUILD_SCRIPT_RUSTC), + last_used: last_used(&dir), + }); + } + + units +} + +/// The `"rustc"` value from the first parseable `*.json` in a unit directory. +fn read_rustc(unit: &Path) -> Option { + let entries = std::fs::read_dir(unit).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + if let Ok(fingerprint) = serde_json::from_str::(&text) { + return Some(fingerprint.rustc); + } + } + None +} + +/// When a build unit was last produced. +/// +/// `invoked.timestamp` is written by Cargo at the start of every build of the +/// unit and is the intended signal. The build-script units that lack it fall +/// back to the directory's own mtime. Access time is deliberately never +/// consulted — under `relatime` it is not updated when Cargo reuses an artifact, +/// which is the bug that makes `cargo-sweep --time` delete live caches. +fn last_used(unit: &Path) -> SystemTime { + let stamp = mtime(&unit.join("invoked.timestamp")); + let dir = mtime(unit); + match (stamp, dir) { + (Some(a), Some(b)) => a.max(b), + (Some(only), None) | (None, Some(only)) => only, + // Unreadable: treat as brand new so it is kept, never swept. + (None, None) => SystemTime::now(), + } +} + +/// The modification time of `path`, if it can be read. +fn mtime(path: &Path) -> Option { + std::fs::metadata(path).ok()?.modified().ok() +} + +/// Extract the trailing 16-hex Cargo unit hash from a file or directory name. +/// +/// Accepts `({prefix}-)?{name}-{16 hex}(.{ext})?`, which is how Cargo names +/// every correlatable artifact. Returns `None` for the unhashed final artifacts +/// at a profile root (`vacuum`, `libvacuum_core.rlib`), which must never be +/// swept — they are hardlinks Cargo recreates, and removing them buys nothing. +fn hash_suffix(name: &str) -> Option<&str> { + let stem = name.split('.').next()?; + let hash = stem.rsplit('-').next()?; + if hash.len() != HASH_LEN || hash.len() == stem.len() { + return None; + } + hash.chars() + .all(|character| character.is_ascii_hexdigit()) + .then_some(hash) +} + +// --- planning ------------------------------------------------------------- + +/// Why a set of units is being offered for removal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Reason { + /// Built by a compiler this project no longer uses. + OldToolchain, + /// Built by the current compiler, but not in a long time. + Cold, +} + +/// Build the removal plan for one target directory. +fn prune_plan( + target: &Path, + label_of: &ToolchainNames, + max_age: Duration, + now: SystemTime, +) -> Vec { + let mut candidates = Vec::new(); + + for profile in profile_dirs(target) { + if build_in_progress(&profile) { + continue; + } + + // Note this runs even when there are no fingerprints left to classify: + // a profile whose units have already been pruned can still be sitting on + // a large incremental cache. + candidates.extend(incremental_candidate(target, &profile)); + + let units = read_units(&profile); + if units.is_empty() { + continue; + } + + let live = live_rustc(&units); + let mut old_toolchain: Vec<&Unit> = Vec::new(); + let mut cold: Vec<&Unit> = Vec::new(); + for unit in &units { + // Build-script bookkeeping units have no real compiler; never sweep. + if unit.rustc == BUILD_SCRIPT_RUSTC { + continue; + } + if unit.rustc != live { + old_toolchain.push(unit); + } else if age(unit.last_used, now) >= max_age { + cold.push(unit); + } + } + + // The two sets are disjoint by construction, so their bytes never + // double-count. Reported in a fixed order, not a map's iteration order. + let groups = [(Reason::OldToolchain, old_toolchain), (Reason::Cold, cold)]; + for (reason, stale) in groups { + if stale.is_empty() { + continue; + } + let hashes: HashSet<&str> = stale.iter().map(|unit| unit.hash.as_str()).collect(); + let paths = paths_for_hashes(&profile, &hashes); + if paths.is_empty() { + continue; + } + let bytes = sum_unique_files(&paths); + if bytes == 0 { + continue; + } + + let (summary, detail) = match reason { + Reason::OldToolchain => { + let from = stale + .first() + .and_then(|unit| label_of.name(unit.rustc)) + .unwrap_or_else(|| "an older toolchain".to_owned()); + let summary = format!("{} units from {from}", stale.len()); + let detail = match label_of.name(live) { + Some(to) => format!("{summary}; this target now builds with {to}"), + None => summary.clone(), + }; + (summary, detail) + } + Reason::Cold => { + let days = max_age.as_secs() / (24 * 60 * 60); + let summary = format!("{} units cold for {days}+ days", stale.len()); + let detail = format!( + "{summary}; built by the toolchain still in use, but not rebuilt since" + ); + (summary, detail) + } + }; + + candidates.push(batch(target, &profile, paths, bytes, &summary, &detail)); + } + } + + candidates +} + +/// The incremental-cache candidate for one profile, if it holds anything. +/// +/// The incremental cache is untracked by fingerprints and is pure rebuild-time +/// state. `cargo-sweep` never reclaims it, which is the main reason its reported +/// savings fall so far short of a `cargo clean`. +fn incremental_candidate(target: &Path, profile: &Path) -> Option { + let incremental = profile.join("incremental"); + if !incremental.is_dir() { + return None; + } + let paths = vec![incremental]; + let bytes = sum_unique_files(&paths); + (bytes > 0).then(|| { + batch( + target, + profile, + paths, + bytes, + "incremental cache", + "incremental compilation cache; costs one non-incremental rebuild", + ) + }) +} + +/// The `rustc` value of the group that was built most recently. +/// +/// Grouping is exact — it is Cargo's own per-compiler value. Only the choice of +/// *which* group is current is inferred, and the most recently built one is it. +fn live_rustc(units: &[Unit]) -> u64 { + units + .iter() + .filter(|unit| unit.rustc != BUILD_SCRIPT_RUSTC) + .max_by_key(|unit| unit.last_used) + .map_or(BUILD_SCRIPT_RUSTC, |unit| unit.rustc) +} + +/// How long ago `when` was, saturating at zero for future timestamps. +fn age(when: SystemTime, now: SystemTime) -> Duration { + now.duration_since(when).unwrap_or(Duration::ZERO) +} + +/// Every path under `profile` belonging to one of `hashes`. +fn paths_for_hashes(profile: &Path, hashes: &HashSet<&str>) -> Vec { + let mut paths = Vec::new(); + for dir in UNIT_DIRS { + let Ok(entries) = std::fs::read_dir(profile.join(dir)) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + let matches = path + .file_name() + .and_then(|name| name.to_str()) + .and_then(hash_suffix) + .is_some_and(|hash| hashes.contains(hash)); + if matches { + paths.push(path); + } + } + } + paths.sort(); + paths +} + +/// Assemble a batch candidate for one profile. +/// +/// `summary` goes in the label, because the human `list` output prints only the +/// size and the label: without it, a target directory's two or three rows would +/// be indistinguishable. `detail` carries the full explanation for JSON +/// consumers and the TUI. +fn batch( + target: &Path, + profile: &Path, + paths: Vec, + bytes: u64, + summary: &str, + detail: &str, +) -> Candidate { + let profile_name = profile + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("?"); + Candidate { + cleaner_id: "cargo-prune".to_owned(), + category: Category::BuildArtifacts, + label: format!("{} [{profile_name}] {summary}", target.display()), + detail: Some(format!( + "{detail}. Purged rather than trashed: these are many small \ + regenerable files, and trashing them would reclaim nothing until \ + the trash is emptied. This overlaps the whole-directory candidate \ + for the same target; take one or the other, not both." + )), + bytes, + regenerable: true, + trash_ok: false, + risk: Risk::Safe, + target: Target::Batch { + root: target.to_path_buf(), + paths, + }, + } +} + +// --- toolchain naming ----------------------------------------------------- + +/// Maps a fingerprint's `"rustc"` value to a human-readable release. +/// +/// Built from `target/.rustc_info.json`, where Cargo caches the verbatim +/// `rustc -vV` output of the compiler it last used. Hashing that text reproduces +/// the fingerprint value exactly, so a toolchain can be named without running +/// anything. Purely cosmetic: when the mapping cannot be established the units +/// are still grouped and swept correctly, they are just described generically. +#[derive(Debug, Default)] +struct ToolchainNames { + by_hash: HashMap, +} + +impl ToolchainNames { + /// Add the toolchains recorded in `target/.rustc_info.json` to the table. + /// + /// Called for every target directory in a scan, because a directory names + /// only the compiler that built it last. The toolchain behind a *stale* unit + /// is therefore usually nameable only thanks to another project that still + /// builds with it. + fn absorb(&mut self, target: &Path) { + let Ok(text) = std::fs::read_to_string(target.join(".rustc_info.json")) else { + return; + }; + let Ok(info) = serde_json::from_str::(&text) else { + return; + }; + + for output in info.outputs.into_values() { + // A failed probe records an empty stdout; there is nothing to name, + // and hashing it would invent a bogus mapping. + if !output.success || !output.stdout.starts_with("rustc ") { + continue; + } + if let Some(release) = toolchain_release(&output.stdout) { + for hash in hash_verbose_version(&output.stdout) { + self.by_hash.insert(hash, release.clone()); + } + } + } + } + + /// Read the toolchains recorded in a single target directory. + #[cfg(test)] + fn read(target: &Path) -> Self { + let mut names = Self::default(); + names.absorb(target); + names + } + + /// The release name for a fingerprint's `"rustc"` value, if known. + fn name(&self, rustc: u64) -> Option { + self.by_hash.get(&rustc).cloned() + } +} + +/// The parts of `.rustc_info.json` needed to name a toolchain. +#[derive(Debug, Deserialize)] +struct RustcInfo { + outputs: HashMap, +} + +/// One cached compiler probe. +#[derive(Debug, Deserialize)] +struct RustcOutput { + success: bool, + stdout: String, +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + use std::time::{Duration, SystemTime}; + + use vacuum_core::Target; + + use super::{ + CACHEDIR_SIGNATURE, Reason, ToolchainNames, build_in_progress, find_target_dirs, + hash_suffix, is_target_dir, live_rustc, prune_plan, read_units, + }; + + /// The `rustc -vV` text Cargo recorded for rustc 1.95.0, captured verbatim + /// from a real `target/.rustc_info.json` on the maintainer's machine. + const VV_1_95: &str = "rustc 1.95.0 (59807616e 2026-04-14) (built from a source tarball)\nbinary: rustc\ncommit-hash: 59807616e1fa2540724bfbac14d7976d7e4a3860\ncommit-date: 2026-04-14\nhost: x86_64-unknown-linux-gnu\nrelease: 1.95.0\nLLVM version: 21.1.8\n"; + + /// The `"rustc"` value every fingerprint in that target directory carries. + const HASH_1_95: u64 = 9_571_511_559_510_505_644; + + /// Build a minimal but realistic target directory. + fn target_dir(root: &Path) { + fs::create_dir_all(root).unwrap(); + fs::write( + root.join("CACHEDIR.TAG"), + format!("{CACHEDIR_SIGNATURE}\n# created by cargo\n"), + ) + .unwrap(); + } + + /// Add one build unit to `profile`, with its fingerprint and its outputs. + fn unit(profile: &Path, name: &str, hash: &str, rustc: u64, bytes: usize) { + let fingerprint = profile.join(".fingerprint").join(format!("{name}-{hash}")); + fs::create_dir_all(&fingerprint).unwrap(); + fs::write( + fingerprint.join(format!("lib-{name}.json")), + format!("{{\"rustc\":{rustc},\"features\":\"\"}}"), + ) + .unwrap(); + fs::write(fingerprint.join("invoked.timestamp"), b"x").unwrap(); + + let deps = profile.join("deps"); + fs::create_dir_all(&deps).unwrap(); + fs::write( + deps.join(format!("lib{name}-{hash}.rlib")), + vec![0_u8; bytes], + ) + .unwrap(); + } + + #[test] + fn hash_extraction_accepts_cargo_artifacts_and_rejects_final_binaries() { + assert_eq!( + hash_suffix("libaho_corasick-9919f4a5cce3e50c.rlib"), + Some("9919f4a5cce3e50c") + ); + assert_eq!( + hash_suffix("aho-corasick-7b8ead2bb4eea6e3"), + Some("7b8ead2bb4eea6e3") + ); + // The unhashed final artifacts must never be swept. + assert_eq!(hash_suffix("vacuum"), None); + assert_eq!(hash_suffix("libvacuum_core.rlib"), None); + assert_eq!(hash_suffix("incremental"), None); + // Wrong length and non-hex are both rejected. + assert_eq!(hash_suffix("thing-abc"), None); + assert_eq!(hash_suffix("thing-zzzzzzzzzzzzzzzz"), None); + } + + #[test] + fn identifies_a_target_dir_by_its_cachedir_tag() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("proj/target"); + target_dir(&target); + assert!(is_target_dir(&target)); + assert!(!is_target_dir(tmp.path())); + + // Discovery finds it wherever it sits, under any name. + let renamed = tmp.path().join("proj/build-output"); + target_dir(&renamed); + let found = find_target_dirs(tmp.path()); + assert_eq!(found.len(), 2, "found {found:?}"); + } + + #[test] + fn sweeps_the_older_toolchain_group_and_keeps_the_newer() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("target"); + target_dir(&target); + let profile = target.join("debug"); + + // Two compilers represented. The one written last is the live one. + unit(&profile, "old_crate", "1111111111111111", 111, 4096); + std::thread::sleep(Duration::from_millis(20)); + unit(&profile, "new_crate", "2222222222222222", 222, 4096); + + let units = read_units(&profile); + assert_eq!(units.len(), 2); + assert_eq!(live_rustc(&units), 222, "newest activity defines live"); + + // A long max_age so nothing qualifies as merely cold. + let plan = prune_plan( + &target, + &ToolchainNames::default(), + Duration::from_secs(86_400 * 365), + SystemTime::now(), + ); + assert_eq!(plan.len(), 1, "only the old-toolchain group: {plan:?}"); + assert!(!plan[0].trash_ok); + assert!(plan[0].regenerable); + + // The batch covers the whole unit — its fingerprint directory as well as + // its output in deps/ — so it weighs slightly more than the rlib alone. + assert!( + plan[0].bytes >= 4096, + "expected at least the rlib, got {}", + plan[0].bytes + ); + let Target::Batch { paths, .. } = &plan[0].target else { + panic!("expected a batch target, got {:?}", plan[0].target); + }; + let names: Vec = paths + .iter() + .map(|path| path.display().to_string()) + .collect(); + assert!( + names.iter().any(|name| name.contains(".fingerprint")), + "the unit's fingerprint must be swept with it: {names:?}" + ); + assert!( + names + .iter() + .any(|name| name.ends_with("libold_crate-1111111111111111.rlib")), + "the unit's output must be swept: {names:?}" + ); + assert!( + names.iter().all(|name| !name.contains("new_crate")), + "the live toolchain's units must be untouched: {names:?}" + ); + } + + #[test] + fn cold_units_are_offered_separately_from_stale_toolchains() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("target"); + target_dir(&target); + let profile = target.join("debug"); + unit(&profile, "only_crate", "3333333333333333", 333, 8192); + + // A future `now` makes the single (live) unit read as cold. + let far_future = SystemTime::now() + Duration::from_secs(86_400 * 365); + let plan = prune_plan( + &target, + &ToolchainNames::default(), + Duration::from_secs(86_400 * 30), + far_future, + ); + + assert_eq!(plan.len(), 1); + assert!( + plan[0] + .detail + .as_ref() + .is_some_and(|d| d.contains("not rebuilt")), + "expected a cold-unit detail, got {:?}", + plan[0].detail + ); + } + + #[test] + fn a_unit_with_unreadable_fingerprint_json_is_kept() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("target"); + target_dir(&target); + let profile = target.join("debug"); + + unit(&profile, "good", "4444444444444444", 444, 4096); + // A unit whose JSON does not parse: provenance unknown, so keep it. + let broken = profile.join(".fingerprint").join("broken-5555555555555555"); + fs::create_dir_all(&broken).unwrap(); + fs::write(broken.join("lib-broken.json"), b"{not json").unwrap(); + fs::write( + profile.join("deps").join("libbroken-5555555555555555.rlib"), + vec![0_u8; 999_999], + ) + .unwrap(); + + let plan = prune_plan( + &target, + &ToolchainNames::default(), + Duration::from_secs(86_400 * 365), + SystemTime::now(), + ); + let swept: u64 = plan.iter().map(|candidate| candidate.bytes).sum(); + assert_eq!(swept, 0, "nothing should be swept: {plan:?}"); + } + + #[test] + fn incremental_cache_is_offered_on_its_own() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("target"); + target_dir(&target); + let incremental = target.join("debug/incremental/thing-abc"); + fs::create_dir_all(&incremental).unwrap(); + fs::write(incremental.join("blob"), vec![0_u8; 16_384]).unwrap(); + // A `.fingerprint` dir is what marks this as a profile. + fs::create_dir_all(target.join("debug/.fingerprint")).unwrap(); + + let plan = prune_plan( + &target, + &ToolchainNames::default(), + Duration::from_secs(86_400 * 30), + SystemTime::now(), + ); + assert_eq!(plan.len(), 1); + assert_eq!(plan[0].bytes, 16_384); + assert!( + plan[0] + .detail + .as_ref() + .is_some_and(|d| d.contains("incremental")), + "got {:?}", + plan[0].detail + ); + } + + #[test] + fn a_locked_profile_is_skipped_entirely() { + use rustix::fs::{FlockOperation, flock}; + + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("target"); + target_dir(&target); + let profile = target.join("debug"); + unit(&profile, "crate_a", "6666666666666666", 666, 4096); + std::thread::sleep(Duration::from_millis(20)); + unit(&profile, "crate_b", "7777777777777777", 777, 4096); + + let lock_path = profile.join(".cargo-lock"); + fs::write(&lock_path, b"").unwrap(); + assert!(!build_in_progress(&profile), "an idle lock is not a build"); + + // Hold the lock as a running `cargo build` would. + let held = fs::File::open(&lock_path).unwrap(); + flock(&held, FlockOperation::NonBlockingLockExclusive).unwrap(); + + assert!(build_in_progress(&profile)); + let plan = prune_plan( + &target, + &ToolchainNames::default(), + Duration::from_secs(86_400 * 365), + SystemTime::now(), + ); + assert!( + plan.is_empty(), + "must not prune under a live build: {plan:?}" + ); + + flock(&held, FlockOperation::Unlock).unwrap(); + } + + #[test] + fn toolchain_names_come_from_rustc_info_json() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("target"); + target_dir(&target); + let info = serde_json::json!({ + "rustc_fingerprint": 1_u64, + "outputs": { + "17747080675513052775": { + "success": true, "status": "", "code": 0, + "stdout": VV_1_95, "stderr": "" + } + }, + "successes": {} + }); + fs::write( + target.join(".rustc_info.json"), + serde_json::to_string(&info).unwrap(), + ) + .unwrap(); + + let names = ToolchainNames::read(&target); + assert_eq!( + names.name(HASH_1_95).as_deref(), + Some("rustc 1.95.0"), + "the recorded -vV text must hash to the fingerprint value" + ); + assert_eq!(names.name(12_019_306_335_353_385_202), None); + } + + #[test] + fn a_failed_rustc_probe_names_nothing_and_does_not_panic() { + // Real case: exif-remover/target on the maintainer's machine. + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("target"); + target_dir(&target); + fs::write( + target.join(".rustc_info.json"), + r#"{"rustc_fingerprint":11255423990684031070,"outputs":{"17747080675513052775":{"success":false,"status":"exit status: 1","code":1,"stdout":"","stderr":"error"}},"successes":{}}"#, + ) + .unwrap(); + + let names = ToolchainNames::read(&target); + assert_eq!(names.name(12_019_306_335_353_385_202), None); + } + + #[test] + fn reasons_are_distinct() { + assert_ne!(Reason::OldToolchain, Reason::Cold); + } +} diff --git a/crates/vacuum-cleaners/src/large_files.rs b/crates/vacuum-cleaners/src/large_files.rs index ce590e3..a365583 100644 --- a/crates/vacuum-cleaners/src/large_files.rs +++ b/crates/vacuum-cleaners/src/large_files.rs @@ -41,6 +41,7 @@ impl Cleaner for LargeFiles { detail: None, bytes: usage.bytes, regenerable: false, + trash_ok: true, risk: Risk::Caution, target: Target::Path { path: usage.path }, }); diff --git a/crates/vacuum-cleaners/src/lib.rs b/crates/vacuum-cleaners/src/lib.rs index 7d45cd2..1495c27 100644 --- a/crates/vacuum-cleaners/src/lib.rs +++ b/crates/vacuum-cleaners/src/lib.rs @@ -6,23 +6,33 @@ //! The catalog of [`Cleaner`](vacuum_core::Cleaner)s Vacuum ships: //! //! - [`BuildArtifacts`] — regenerable build output (`target/`, `node_modules`, …) +//! - [`CargoPrune`] — dead build units *inside* a Cargo `target/` directory //! - [`PackageGc`] — package-manager garbage collection (Nix, Flatpak, …) //! - [`Caches`] — application and user caches +//! - [`TempFiles`] — stale, user-owned entries under `/tmp` and friends //! - [`LargeFiles`] — the biggest individual files, for manual review //! +//! Several cleaners may share a [`Category`]; frontends group by category, not +//! by cleaner. +//! //! Rust guideline compliant 2026-05-18 #![forbid(unsafe_code)] mod build_artifacts; mod caches; +mod cargo_prune; mod large_files; mod package_gc; +mod rustc_hash; +mod temp_files; pub use build_artifacts::BuildArtifacts; pub use caches::Caches; +pub use cargo_prune::CargoPrune; pub use large_files::LargeFiles; pub use package_gc::PackageGc; +pub use temp_files::TempFiles; use vacuum_core::{Category, Cleaner}; @@ -30,8 +40,10 @@ use vacuum_core::{Category, Cleaner}; pub fn all_cleaners() -> Vec> { vec![ Box::new(BuildArtifacts), + Box::new(CargoPrune), Box::new(PackageGc), Box::new(Caches), + Box::new(TempFiles), Box::new(LargeFiles), ] } diff --git a/crates/vacuum-cleaners/src/package_gc.rs b/crates/vacuum-cleaners/src/package_gc.rs index 4098404..2138acb 100644 --- a/crates/vacuum-cleaners/src/package_gc.rs +++ b/crates/vacuum-cleaners/src/package_gc.rs @@ -21,6 +21,14 @@ const RECIPES: &[Recipe] = &[ false, "Delete dead Nix store paths and old generations (user profile).", ), + ( + // The user-profile form above cannot reach the system profile or other + // users' roots; on NixOS that is usually where the bulk of the store is. + "nix-collect-garbage", + &["-d", "--verbose"], + true, + "Delete dead Nix store paths and old generations system-wide (all profiles).", + ), ( "flatpak", &["uninstall", "--unused", "-y"], @@ -33,12 +41,27 @@ const RECIPES: &[Recipe] = &[ true, "Remove old cached pacman packages, keeping the last three.", ), + ( + "journalctl", + &["--vacuum-time=7d"], + true, + "Drop systemd journal entries older than 7 days.", + ), ( "journalctl", &["--vacuum-size=200M"], true, "Trim the systemd journal to 200 MiB.", ), + ( + // The counterpart to the temp-files cleaner: that one handles entries + // owned by the invoking user, this reclaims the root-owned remainder + // under the policy in `tmpfiles.d`. + "systemd-tmpfiles", + &["--clean"], + true, + "Remove expired temp files owned by root, per tmpfiles.d policy.", + ), ( "podman", &["system", "prune", "-f"], @@ -89,6 +112,7 @@ impl Cleaner for PackageGc { detail: Some(detail.to_owned()), bytes: 0, // Reclaim is unknown until the tool runs. regenerable: true, + trash_ok: true, risk: Risk::Caution, target: Target::Command { spec }, }); diff --git a/crates/vacuum-cleaners/src/rustc_hash.rs b/crates/vacuum-cleaners/src/rustc_hash.rs new file mode 100644 index 0000000..7e220d5 --- /dev/null +++ b/crates/vacuum-cleaners/src/rustc_hash.rs @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: 2026 Mohamed Hammad +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Reproduce Cargo's `"rustc"` fingerprint value from a compiler version string. +//! +//! Every `target//.fingerprint//*.json` records +//! `"rustc": `, which is Cargo's hash of the full `rustc -vV` output of the +//! compiler that built the unit. Cargo also caches that same `-vV` text verbatim +//! in `target/.rustc_info.json`, so hashing the cached text reproduces the value +//! and lets Vacuum *name* the toolchain behind a fingerprint without executing +//! any compiler — which matters, because on a Nix or Guix system there may be no +//! runnable `rustc` on `PATH` at all. +//! +//! This is coupled to a Cargo internal with no stability guarantee, and it has +//! changed once already (at Rust 1.85, when Cargo moved from the deprecated +//! `SipHasher` to `rustc-stable-hash`). Both are therefore computed and either +//! may match. A mismatch is not a failure: naming simply degrades to a generic +//! description, while unit grouping — which compares Cargo's values only against +//! each other, never against a value we computed — is unaffected. +//! +//! Rust guideline compliant 2026-05-18 + +use std::hash::{Hash, Hasher}; + +use rustc_stable_hash::StableSipHasher128; + +/// Hash `verbose_version` the way every Cargo version might have. +/// +/// Returns both candidates: the current `rustc-stable-hash` form used by Cargo +/// since Rust 1.85, and the legacy `SipHasher` form used before it. A caller +/// maps every returned value onto the same toolchain, so whichever Cargo wrote +/// is matched. +pub fn hash_verbose_version(verbose_version: &str) -> Vec { + vec![stable_hash(verbose_version), legacy_hash(verbose_version)] +} + +/// Cargo's hash since Rust 1.85: `rustc_stable_hash::StableSipHasher128`. +fn stable_hash(text: &str) -> u64 { + let mut hasher = StableSipHasher128::new(); + text.hash(&mut hasher); + Hasher::finish(&hasher) +} + +/// Cargo's hash before Rust 1.85: `SipHasher` with zeroed keys. +/// +/// Reimplemented rather than pulled in, because the standard library's +/// `SipHasher` is deprecated and the `siphasher` crate would be a dependency +/// carried solely for reading pre-1.85 build trees. This is SipHash-2-4 with +/// `k0 = k1 = 0`, matching what `std::hash::SipHasher::new_with_keys(0, 0)` +/// produced. +fn legacy_hash(text: &str) -> u64 { + let mut hasher = SipHasher24::default(); + text.hash(&mut hasher); + hasher.finish() +} + +/// SipHash-2-4 with zeroed keys, as the deprecated `std::hash::SipHasher` used. +#[derive(Debug)] +struct SipHasher24 { + v0: u64, + v1: u64, + v2: u64, + v3: u64, + tail: u64, + /// Bytes currently held in `tail`, always `0..8`. + held: usize, + length: usize, +} + +impl Default for SipHasher24 { + fn default() -> Self { + // The standard initialisation constants, XORed with keys of zero. + Self { + v0: 0x736f_6d65_7073_6575, + v1: 0x646f_7261_6e64_6f6d, + v2: 0x6c79_6765_6e65_7261, + v3: 0x7465_6462_7974_6573, + tail: 0, + held: 0, + length: 0, + } + } +} + +impl SipHasher24 { + fn round(&mut self) { + self.v0 = self.v0.wrapping_add(self.v1); + self.v1 = self.v1.rotate_left(13) ^ self.v0; + self.v0 = self.v0.rotate_left(32); + self.v2 = self.v2.wrapping_add(self.v3); + self.v3 = self.v3.rotate_left(16) ^ self.v2; + self.v0 = self.v0.wrapping_add(self.v3); + self.v3 = self.v3.rotate_left(21) ^ self.v0; + self.v2 = self.v2.wrapping_add(self.v1); + self.v1 = self.v1.rotate_left(17) ^ self.v2; + self.v2 = self.v2.rotate_left(32); + } + + fn absorb(&mut self, word: u64) { + self.v3 ^= word; + self.round(); + self.round(); + self.v0 ^= word; + } +} + +impl Hasher for SipHasher24 { + fn write(&mut self, bytes: &[u8]) { + self.length += bytes.len(); + for &byte in bytes { + self.tail |= u64::from(byte) << (8 * self.held); + self.held += 1; + if self.held == 8 { + let word = self.tail; + self.absorb(word); + self.tail = 0; + self.held = 0; + } + } + } + + fn finish(&self) -> u64 { + let mut copy = Self { + v0: self.v0, + v1: self.v1, + v2: self.v2, + v3: self.v3, + tail: self.tail, + held: self.held, + length: self.length, + }; + // The final word carries the low byte of the total length. + let word = copy.tail | ((copy.length as u64 & 0xff) << 56); + copy.absorb(word); + copy.v2 ^= 0xff; + copy.round(); + copy.round(); + copy.round(); + copy.round(); + copy.v0 ^ copy.v1 ^ copy.v2 ^ copy.v3 + } +} + +/// A short display name for a toolchain, from its `rustc -vV` output. +/// +/// Prefers the `release:` line, which is the bare version (`1.95.0`); falls back +/// to the first line when it is absent. Returns `None` for text that is not a +/// `rustc -vV` block at all. +pub fn toolchain_release(verbose_version: &str) -> Option { + if !verbose_version.starts_with("rustc ") { + return None; + } + let release = verbose_version + .lines() + .find_map(|line| line.strip_prefix("release: ")) + .map(str::trim); + match release { + Some(release) if !release.is_empty() => Some(format!("rustc {release}")), + _ => verbose_version + .lines() + .next() + .map(|line| line.trim().to_owned()), + } +} + +#[cfg(test)] +mod tests { + use super::{hash_verbose_version, toolchain_release}; + + /// Captured verbatim from `/spacecraft-software/vacuum/target/.rustc_info.json`. + const VV_1_95: &str = "rustc 1.95.0 (59807616e 2026-04-14) (built from a source tarball)\nbinary: rustc\ncommit-hash: 59807616e1fa2540724bfbac14d7976d7e4a3860\ncommit-date: 2026-04-14\nhost: x86_64-unknown-linux-gnu\nrelease: 1.95.0\nLLVM version: 21.1.8\n"; + + /// Captured verbatim from `/spacecraft-software/mcp-servers/mcpctl/target/.rustc_info.json`. + const VV_1_97: &str = "rustc 1.97.1 (8bab26f4f 2026-07-14) (built from a source tarball)\nbinary: rustc\ncommit-hash: 8bab26f4f4b6f4d1e1e6a0e0b0b1a1d1e1f1a1b1\ncommit-date: 2026-07-14\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\nLLVM version: 21.1.8\n"; + + /// The `"rustc"` value carried by all 507 fingerprints in that target dir. + const HASH_1_95: u64 = 9_571_511_559_510_505_644; + + #[test] + fn reproduces_the_real_cargo_fingerprint_value() { + // This is the whole basis of toolchain naming: if it ever stops holding, + // Cargo has changed its hash and naming must fall back to generic text. + assert!( + hash_verbose_version(VV_1_95).contains(&HASH_1_95), + "expected {HASH_1_95} among {:?}", + hash_verbose_version(VV_1_95) + ); + } + + #[test] + fn distinct_toolchains_hash_differently() { + let a = hash_verbose_version(VV_1_95); + let b = hash_verbose_version(VV_1_97); + assert!(a.iter().all(|hash| !b.contains(hash))); + } + + #[test] + fn both_hash_variants_are_offered() { + assert_eq!(hash_verbose_version(VV_1_95).len(), 2); + } + + #[test] + fn release_line_gives_the_display_name() { + assert_eq!(toolchain_release(VV_1_95).as_deref(), Some("rustc 1.95.0")); + assert_eq!(toolchain_release(VV_1_97).as_deref(), Some("rustc 1.97.1")); + } + + #[test] + fn falls_back_to_the_first_line_without_a_release_field() { + assert_eq!( + toolchain_release("rustc 1.80.0 (abc 2024-01-01)\nbinary: rustc\n").as_deref(), + Some("rustc 1.80.0 (abc 2024-01-01)") + ); + } + + #[test] + fn non_rustc_text_names_nothing() { + assert_eq!(toolchain_release(""), None); + assert_eq!(toolchain_release("error: command failed\n"), None); + } +} diff --git a/crates/vacuum-cleaners/src/temp_files.rs b/crates/vacuum-cleaners/src/temp_files.rs new file mode 100644 index 0000000..ac61624 --- /dev/null +++ b/crates/vacuum-cleaners/src/temp_files.rs @@ -0,0 +1,303 @@ +// SPDX-FileCopyrightText: 2026 Mohamed Hammad +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Stale temporary-file cleaner: `/tmp`, `/var/tmp`, and `$TMPDIR`. +//! +//! This is the safe replacement for the `sudo rm -r /tmp/*` reflex. That command +//! destroys other users' files and the live state of running processes — X11 and +//! Wayland sockets, systemd private directories, in-flight builds. Instead, an +//! entry is offered only when *all* of the following hold: +//! +//! - it is not a symlink; +//! - it is owned by the invoking user (never another user's data, never root's); +//! - it has not been modified for [`MIN_AGE_DAYS`] days; +//! - its name is not on the [`LIVE_STATE`] denylist of session-state entries. +//! +//! Root-owned leftovers are deliberately out of scope here; they are reclaimed +//! through `sudo systemd-tmpfiles --clean`, which the package-GC cleaner offers +//! as a printed command (Vacuum never escalates on its own). +//! +//! Candidates are marked `trash_ok: false`: the freedesktop trash for a path +//! under `/tmp` is `/tmp/.Trash-$uid`, on the very same filesystem, so trashing +//! would reclaim nothing at exactly the moment the space is needed. +//! +//! Rust guideline compliant 2026-05-18 + +use std::collections::BTreeSet; +use std::fs::Metadata; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use vacuum_core::{Candidate, Category, Cleaner, Result, Risk, ScanContext, Target, dir_size}; + +/// How long an entry must sit untouched before it is offered for removal. +/// +/// Seven days comfortably outlives an interactive session, a long build, and a +/// weekend away from the machine, so anything older is very unlikely to still be +/// in use. Lowering this risks offering the scratch space of a running job; +/// `systemd-tmpfiles` uses a comparable default for `/tmp`. +const MIN_AGE_DAYS: u64 = 7; + +/// [`MIN_AGE_DAYS`] as a [`Duration`], for comparison against entry mtimes. +const MIN_AGE: Duration = Duration::from_secs(MIN_AGE_DAYS * 24 * 60 * 60); + +/// The temp directories inspected, in report order. +/// +/// `$TMPDIR` is added on top of these when it is set and points somewhere else. +const TEMP_DIRS: &[&str] = &["/tmp", "/var/tmp"]; + +/// Exact entry names that hold live session state and must never be offered. +/// +/// These are sockets and directories the desktop session and the display server +/// keep open for their whole lifetime; their mtime is the session start, so an +/// age filter alone would happily nominate them after a week of uptime. +const LIVE_STATE: &[&str] = &[ + ".X11-unix", + ".ICE-unix", + ".font-unix", + ".XIM-unix", + ".Test-unix", + ".XDG-unix", +]; + +/// Name prefixes that mark live or privileged state, matched against entry names. +/// +/// `systemd-private-*` and `snap-private-*` are per-service bind-mount roots for +/// running units; `.Trash-` is the trash directory itself (deleting it would +/// destroy the user's own recovery copies); `.mount_` is a live FUSE mount. +const LIVE_STATE_PREFIXES: &[&str] = &[ + "systemd-private-", + "snap-private-", + ".Trash-", + ".mount_", + ".nfs", +]; + +/// Offers stale, user-owned entries under the system temporary directories. +#[derive(Debug, Clone, Copy)] +pub struct TempFiles; + +impl Cleaner for TempFiles { + fn id(&self) -> &'static str { + "temp-files" + } + + fn name(&self) -> &'static str { + "Stale temp files" + } + + fn category(&self) -> Category { + Category::TempFiles + } + + fn scan(&self, _ctx: &ScanContext) -> Result> { + let uid = rustix::process::geteuid().as_raw(); + let now = SystemTime::now(); + + let mut candidates = Vec::new(); + for dir in temp_dirs() { + candidates.extend(collect(&dir, uid, MIN_AGE, now)); + } + + candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.bytes)); + Ok(candidates) + } + + fn extra_roots(&self) -> Vec { + temp_dirs() + } +} + +/// The temp directories to inspect: [`TEMP_DIRS`] plus `$TMPDIR`, deduplicated. +/// +/// Only existing directories are returned, so the delete allowlist never grows +/// to cover a path that is not actually there. +fn temp_dirs() -> Vec { + let from_env = std::env::var_os("TMPDIR").map(PathBuf::from); + let mut seen = BTreeSet::new(); + + TEMP_DIRS + .iter() + .map(PathBuf::from) + .chain(from_env) + .filter(|dir| dir.is_dir()) + // Canonicalize so `$TMPDIR=/tmp` and a `/tmp` symlink both collapse onto + // the entry already present rather than widening the roots twice. + .filter_map(|dir| std::fs::canonicalize(&dir).ok()) + .filter(|dir| seen.insert(dir.clone())) + .collect() +} + +/// Collect stale, `uid`-owned entries directly inside `dir`. +/// +/// `now` and `min_age` are parameters rather than constants so tests can age +/// entries without backdating files on disk. Unreadable entries are skipped +/// rather than reported as errors: a temp directory is shared, racy, and full of +/// things this user cannot stat, and one `EACCES` must not abort the whole scan. +fn collect(dir: &Path, uid: u32, min_age: Duration, now: SystemTime) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + + let mut candidates = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if is_live_state(name) { + continue; + } + + // `symlink_metadata` inspects the link itself; a symlink is never + // followed, and never removed (the deleter refuses them outright). + let Ok(meta) = entry.path().symlink_metadata() else { + continue; + }; + if meta.file_type().is_symlink() || meta.uid() != uid { + continue; + } + + let Some(age) = age_of(&meta, now) else { + continue; + }; + if age < min_age { + continue; + } + + let bytes = if meta.is_dir() { + dir_size(&path) + } else { + meta.len() + }; + if bytes == 0 { + continue; + } + + candidates.push(Candidate { + cleaner_id: "temp-files".to_owned(), + category: Category::TempFiles, + label: path.display().to_string(), + detail: Some(format!( + "yours, untouched for {} days — purged, not trashed", + age.as_secs() / (24 * 60 * 60) + )), + bytes, + // Temp files are scratch data, not regenerable build output: whatever + // wrote them will not put them back. + regenerable: false, + // Trashing a `/tmp` entry lands it in `/tmp/.Trash-$uid`, on the same + // filesystem — zero bytes reclaimed. Purge outright instead. + trash_ok: false, + risk: Risk::Caution, + target: Target::Path { path }, + }); + } + + candidates +} + +/// Whether `name` denotes live session state that must never be offered. +fn is_live_state(name: &str) -> bool { + LIVE_STATE.contains(&name) + || LIVE_STATE_PREFIXES + .iter() + .any(|prefix| name.starts_with(prefix)) +} + +/// How long ago `meta` was last modified, or `None` if that cannot be determined. +/// +/// A future mtime yields `Some(ZERO)`, which fails the age test — clock skew +/// must not make an entry look ancient. +fn age_of(meta: &Metadata, now: SystemTime) -> Option { + let modified = meta.modified().ok()?; + Some(now.duration_since(modified).unwrap_or(Duration::ZERO)) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::time::{Duration, SystemTime}; + + use super::{MIN_AGE, collect, is_live_state}; + + /// A `now` far enough ahead that every file written by a test reads as stale. + fn far_future() -> SystemTime { + SystemTime::now() + Duration::from_secs(365 * 24 * 60 * 60) + } + + fn uid() -> u32 { + rustix::process::geteuid().as_raw() + } + + #[test] + fn finds_aged_user_owned_entry() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("scratch.bin"), vec![0_u8; 4096]).unwrap(); + + let found = collect(tmp.path(), uid(), MIN_AGE, far_future()); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].bytes, 4096); + assert!(!found[0].trash_ok, "temp entries must not be trashed"); + } + + #[test] + fn skips_entry_that_is_not_old_enough() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("scratch.bin"), vec![0_u8; 4096]).unwrap(); + + // Real `now`: the file was written moments ago. + let found = collect(tmp.path(), uid(), MIN_AGE, SystemTime::now()); + + assert!(found.is_empty(), "a fresh entry must never be offered"); + } + + #[test] + fn skips_entry_owned_by_another_user() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("scratch.bin"), vec![0_u8; 4096]).unwrap(); + + // Ask for a uid that cannot match the one the file was created with. + let found = collect(tmp.path(), uid().wrapping_add(1), MIN_AGE, far_future()); + + assert!(found.is_empty(), "another user's data is never offered"); + } + + #[test] + fn skips_symlinks() { + let tmp = tempfile::tempdir().unwrap(); + let real = tmp.path().join("real.bin"); + fs::write(&real, vec![0_u8; 4096]).unwrap(); + std::os::unix::fs::symlink(&real, tmp.path().join("link.bin")).unwrap(); + + let found = collect(tmp.path(), uid(), MIN_AGE, far_future()); + + assert_eq!(found.len(), 1, "only the real file, never the symlink"); + assert!(found[0].label.ends_with("real.bin")); + } + + #[test] + fn skips_live_session_state() { + let tmp = tempfile::tempdir().unwrap(); + for name in [".X11-unix", "systemd-private-abc", ".Trash-1000"] { + let dir = tmp.path().join(name); + fs::create_dir(&dir).unwrap(); + fs::write(dir.join("blob"), vec![0_u8; 4096]).unwrap(); + } + + let found = collect(tmp.path(), uid(), MIN_AGE, far_future()); + + assert!(found.is_empty(), "live session state is never offered"); + } + + #[test] + fn live_state_matching_covers_names_and_prefixes() { + assert!(is_live_state(".X11-unix")); + assert!(is_live_state("systemd-private-9f2c")); + assert!(is_live_state(".Trash-1000")); + assert!(!is_live_state("cargo-installXYZ")); + assert!(!is_live_state("nix-build-foo.drv-0")); + } +} diff --git a/crates/vacuum-cli/src/app.rs b/crates/vacuum-cli/src/app.rs index e8a8e30..722a688 100644 --- a/crates/vacuum-cli/src/app.rs +++ b/crates/vacuum-cli/src/app.rs @@ -12,12 +12,13 @@ use serde::Serialize; use serde_json::{Value, json}; use vacuum_cleaners::cleaners_for; use vacuum_core::{ - Action, Candidate, DeleteMode, Deleter, Outcome, ScanContext, Usage, human_bytes, scan, - total_bytes, + Action, Candidate, Category, DeleteMode, Deleter, Outcome, ScanContext, Usage, human_bytes, + scan, total_bytes, }; use crate::agent::OutputMode; use crate::cli::{CleanArgs, EmptyTrashArgs, GlobalArgs, ScanArgs, SelectArgs}; +use crate::config::Settings; use crate::envelope; use crate::error::{CliError, CliResult}; use crate::trashbin; @@ -44,8 +45,8 @@ fn resolve_roots(explicit: &[PathBuf], fallback: &[PathBuf]) -> Vec { /// # Errors /// /// Returns a [`CliError`] if the frontend fails to scan or to drive the terminal. -pub fn run_tui(explicit: &[PathBuf], fallback: &[PathBuf]) -> CliResult<()> { - let roots = resolve_roots(explicit, fallback); +pub fn run_tui(explicit: &[PathBuf], settings: &Settings) -> CliResult<()> { + let roots = resolve_roots(explicit, &settings.roots); vacuum_tui::run(roots).map_err(|err| CliError::tui(&err)) } @@ -109,10 +110,10 @@ pub fn run_scan( args: &ScanArgs, mode: OutputMode, global: &GlobalArgs, - fallback: &[PathBuf], + settings: &Settings, command_line: &str, ) -> CliResult<()> { - let roots = resolve_roots(&args.explicit_roots(), fallback); + let roots = resolve_roots(&args.explicit_roots(), &settings.roots); let mut report = ScanReport { roots: Vec::new() }; for root in roots { @@ -179,26 +180,82 @@ struct CategoryGroup { candidates: Vec, } +/// What a scan produced: candidates grouped by category, plus the roots any +/// resulting deletion must be allowed to touch. +#[derive(Debug)] +struct Gathered { + groups: Vec, + /// The scan roots widened by the [`Cleaner::extra_roots`] of the cleaners + /// that actually ran — see [`run_clean`]. + /// + /// [`Cleaner::extra_roots`]: vacuum_core::Cleaner::extra_roots + delete_roots: Vec, +} + /// Gather candidates for the selected categories within the selected roots. -fn gather(select: &SelectArgs, fallback: &[PathBuf]) -> CliResult> { +/// +/// Several cleaners may share a category (build artifacts, for instance, come +/// from both the directory sweep and the Cargo unit prune), so their candidates are +/// merged into one group per category rather than one per cleaner. Groups are +/// emitted in `Category::ALL` order, independent of catalog order. +fn gather(select: &SelectArgs, settings: &Settings) -> CliResult { let categories = select .selected_categories() .map_err(CliError::unknown_category)?; + let roots = resolve_roots(&select.roots, &settings.roots); let ctx = ScanContext { - roots: resolve_roots(&select.roots, fallback), + roots: roots.clone(), + // The flag wins over the config file, which wins over the default. + stale_days: select.stale_days.unwrap_or(settings.stale_days), }; - let mut groups = Vec::new(); + let mut by_category: Vec<(Category, Vec)> = Vec::new(); + let mut delete_roots = roots; for cleaner in cleaners_for(&categories) { + // `--cleaner` narrows within a category. Several cleaners can share one, + // and their candidates may overlap on purpose (the whole-directory and + // per-unit Cargo candidates cover the same tree), so a caller that wants + // exactly one of them needs to say so. + if !select.cleaners.is_empty() && !select.cleaners.iter().any(|id| id == cleaner.id()) { + continue; + } let candidates = cleaner.scan(&ctx)?; - groups.push(CategoryGroup { - category: cleaner.category().slug(), - title: cleaner.category().title(), + for root in cleaner.extra_roots() { + if !delete_roots.contains(&root) { + delete_roots.push(root); + } + } + + let category = cleaner.category(); + if let Some((_, existing)) = by_category.iter_mut().find(|(seen, _)| *seen == category) { + existing.extend(candidates); + } else { + by_category.push((category, candidates)); + } + } + + // Report in canonical category order, not the order cleaners happen to run. + by_category.sort_by_key(|(category, _)| { + Category::ALL + .iter() + .position(|known| known == category) + .unwrap_or(usize::MAX) + }); + + let groups = by_category + .into_iter() + .map(|(category, candidates)| CategoryGroup { + category: category.slug(), + title: category.title(), total_bytes: total_bytes(&candidates), candidates, - }); - } - Ok(groups) + }) + .collect(); + + Ok(Gathered { + groups, + delete_roots, + }) } /// Run `vacuum list`. @@ -206,10 +263,10 @@ pub fn run_list( args: &SelectArgs, mode: OutputMode, global: &GlobalArgs, - fallback: &[PathBuf], + settings: &Settings, command_line: &str, ) -> CliResult<()> { - let groups = gather(args, fallback)?; + let groups = gather(args, settings)?.groups; let grand_total: u64 = groups.iter().map(|group| group.total_bytes).sum(); match mode { @@ -343,11 +400,12 @@ pub fn run_clean( args: &CleanArgs, mode: OutputMode, global: &GlobalArgs, - fallback: &[PathBuf], + settings: &Settings, command_line: &str, ) -> CliResult<()> { - let groups = gather(&args.select, fallback)?; - let candidates: Vec = groups + let gathered = gather(&args.select, settings)?; + let candidates: Vec = gathered + .groups .into_iter() .flat_map(|group| group.candidates) .collect(); @@ -381,11 +439,10 @@ pub fn run_clean( } } - let deleter = Deleter::new( - delete_mode, - dry_run, - resolve_roots(&args.select.roots, fallback), - ); + // The delete allowlist is the scan roots plus whatever the selected cleaners + // declared they need (e.g. `/tmp` for the temp-file cleaner). The + // protected-prefix and symlink guards still apply to every path. + let deleter = Deleter::new(delete_mode, dry_run, gathered.delete_roots); // Show a live spinner only for real, interactive work (the CLI Standard §7). let show_progress = mode == OutputMode::Human && !global.quiet && !dry_run; let (outcomes, reclaimed) = execute_candidates( diff --git a/crates/vacuum-cli/src/cli.rs b/crates/vacuum-cli/src/cli.rs index 97b1118..b15fc34 100644 --- a/crates/vacuum-cli/src/cli.rs +++ b/crates/vacuum-cli/src/cli.rs @@ -174,9 +174,22 @@ pub struct SelectArgs { #[arg(long = "category", value_name = "CATEGORY")] pub categories: Vec, + /// Restrict to these cleaners by id (repeatable). Defaults to all. + /// + /// Finer-grained than `--category`: several cleaners can share a category, + /// and their candidates may deliberately overlap. `--cleaner cargo-prune` + /// takes only the per-unit Cargo prune, leaving the whole-directory + /// build-artifacts candidate alone. `vacuum describe` lists the ids. + #[arg(long = "cleaner", value_name = "ID")] + pub cleaners: Vec, + /// Roots to operate within (repeatable). Defaults to your home directory. #[arg(long = "root", value_name = "PATH")] pub roots: Vec, + + /// Days of disuse after which a build artifact counts as stale (default 30). + #[arg(long = "stale-days", value_name = "DAYS")] + pub stale_days: Option, } /// Arguments for `vacuum clean`. diff --git a/crates/vacuum-cli/src/config.rs b/crates/vacuum-cli/src/config.rs index 1bb4423..15b0e24 100644 --- a/crates/vacuum-cli/src/config.rs +++ b/crates/vacuum-cli/src/config.rs @@ -21,6 +21,7 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; +use vacuum_core::cleaner::DEFAULT_STALE_DAYS; use vacuum_core::safety; use crate::error::{CliError, CliResult}; @@ -38,6 +39,9 @@ pub struct Config { /// Default roots to scan when none are given on the command line. #[serde(default)] pub roots: Vec, + /// Days of disuse after which a build artifact counts as stale. + #[serde(default)] + pub stale_days: Option, } impl Config { @@ -102,20 +106,32 @@ fn env_roots() -> Option> { (!roots.is_empty()).then_some(roots) } -/// Resolve the fallback roots used when the command line names no path. +/// The settings resolved once per run, before any subcommand dispatches. +#[derive(Debug, Clone)] +pub struct Settings { + /// Roots used when the command line names no path. + pub roots: Vec, + /// Days of disuse after which a build artifact counts as stale. + pub stale_days: u64, +} + +/// Resolve the settings used when the command line does not override them. /// /// `explicit_config` is the value of `--config` / `VACUUM_CONFIG` (which must /// exist when present); when `None`, the default location is consulted and a /// missing file is harmless. /// +/// Roots follow `VACUUM_ROOTS` → config file → `$HOME`. `stale_days` follows +/// the config file → [`DEFAULT_STALE_DAYS`]; the `--stale-days` flag overrides +/// it later, at the point of use. +/// +/// [`DEFAULT_STALE_DAYS`]: vacuum_core::cleaner::DEFAULT_STALE_DAYS +/// /// # Errors /// /// Returns a [`CliError`] when a config file exists but cannot be read or parsed, /// or when an explicitly named config file is absent. -pub fn fallback_roots(explicit_config: Option<&Path>) -> CliResult> { - if let Some(roots) = env_roots() { - return Ok(roots); - } +pub fn settings(explicit_config: Option<&Path>) -> CliResult { let config = match explicit_config { Some(path) => Config::read(path, true)?, None => match default_config_path() { @@ -123,11 +139,21 @@ pub fn fallback_roots(explicit_config: Option<&Path>) -> CliResult> None => Config::default(), }, }; - if config.roots.is_empty() { - Ok(safety::default_roots()) + + // The environment wins over the file for roots, and is checked first so a + // malformed file still fails loudly rather than being silently bypassed. + let roots = if let Some(roots) = env_roots() { + roots + } else if config.roots.is_empty() { + safety::default_roots() } else { - Ok(config.roots.iter().map(|path| expand_tilde(path)).collect()) - } + config.roots.iter().map(|path| expand_tilde(path)).collect() + }; + + Ok(Settings { + roots, + stale_days: config.stale_days.unwrap_or(DEFAULT_STALE_DAYS), + }) } #[cfg(test)] @@ -151,6 +177,18 @@ mod tests { assert!(config.roots.is_empty()); } + #[test] + fn parses_stale_days() { + let config = Config::parse("stale_days = 14", Path::new("config.toml")).unwrap(); + assert_eq!(config.stale_days, Some(14)); + } + + #[test] + fn missing_stale_days_is_none_so_the_default_applies() { + let config = Config::parse("roots = []", Path::new("config.toml")).unwrap(); + assert_eq!(config.stale_days, None); + } + #[test] fn unknown_keys_are_ignored_for_forward_compat() { // A future key must not break an older binary. diff --git a/crates/vacuum-cli/src/introspect.rs b/crates/vacuum-cli/src/introspect.rs index 35d469d..0433965 100644 --- a/crates/vacuum-cli/src/introspect.rs +++ b/crates/vacuum-cli/src/introspect.rs @@ -12,6 +12,7 @@ use std::io::IsTerminal as _; use serde_json::{Value, json}; +use vacuum_cleaners::all_cleaners; use vacuum_core::Category; use crate::agent::OutputMode; @@ -36,6 +37,28 @@ fn category_slugs() -> Vec<&'static str> { Category::ALL.iter().map(|c| c.slug()).collect() } +/// Every cleaner, derived from the catalog so the two never drift. +/// +/// Cleaner ids are what `--cleaner` accepts, and several cleaners may share a +/// category, so an agent cannot infer them from the category list alone. +fn cleaner_manifest() -> Vec { + all_cleaners() + .iter() + .map(|cleaner| { + json!({ + "id": cleaner.id(), + "name": cleaner.name(), + "category": cleaner.category().slug(), + }) + }) + .collect() +} + +/// Cleaner ids alone, for schema enums. +fn cleaner_ids() -> Vec<&'static str> { + all_cleaners().iter().map(|cleaner| cleaner.id()).collect() +} + /// The shared object schema for the `--category` / `--root` selectors. fn selection_properties() -> Value { json!({ @@ -48,6 +71,16 @@ fn selection_properties() -> Value { "type": "array", "items": { "type": "string" }, "description": "Roots to operate within. Defaults to VACUUM_ROOTS, then the config file, then $HOME." + }, + "cleaner": { + "type": "array", + "items": { "type": "string", "enum": cleaner_ids() }, + "description": "Restrict to these cleaners by id. Narrower than category: several cleaners share a category and their candidates may overlap. Defaults to all." + }, + "stale-days": { + "type": "integer", + "minimum": 0, + "description": "Days of disuse after which a build artifact counts as stale. Defaults to 30." } }) } @@ -222,6 +255,7 @@ pub fn run_describe(mode: OutputMode, command_line: &str) -> CliResult<()> { "summary": "Fast, safe disk-space recovery for the terminal.", "commands": ["scan", "list", "clean", "empty-trash", "schema", "describe", "tui"], "categories": category_slugs(), + "cleaners": cleaner_manifest(), "safety": { "dry_run_default": true, "trash_default": true, diff --git a/crates/vacuum-cli/src/main.rs b/crates/vacuum-cli/src/main.rs index ec2ca9b..8d95a8c 100644 --- a/crates/vacuum-cli/src/main.rs +++ b/crates/vacuum-cli/src/main.rs @@ -49,8 +49,8 @@ fn run(cli: &Cli, mode: agent::OutputMode, command_line: &str) -> CliResult<()> eprintln!("vacuum: --format explore is interactive; using JSON for agent use"); } } else { - let fallback = config::fallback_roots(cli.global.config.as_deref())?; - return app::run_tui(&[], &fallback); + let settings = config::settings(cli.global.config.as_deref())?; + return app::run_tui(&[], &settings); } } @@ -60,16 +60,16 @@ fn run(cli: &Cli, mode: agent::OutputMode, command_line: &str) -> CliResult<()> // lazily, per arm, so an unrelated command (e.g. `schema`) never fails // on a malformed config it does not use. Some(Command::Scan(args)) => { - let fallback = config::fallback_roots(cli.global.config.as_deref())?; - app::run_scan(args, mode, &cli.global, &fallback, command_line) + let settings = config::settings(cli.global.config.as_deref())?; + app::run_scan(args, mode, &cli.global, &settings, command_line) } Some(Command::List(args)) => { - let fallback = config::fallback_roots(cli.global.config.as_deref())?; - app::run_list(args, mode, &cli.global, &fallback, command_line) + let settings = config::settings(cli.global.config.as_deref())?; + app::run_list(args, mode, &cli.global, &settings, command_line) } Some(Command::Clean(args)) => { - let fallback = config::fallback_roots(cli.global.config.as_deref())?; - app::run_clean(args, mode, &cli.global, &fallback, command_line) + let settings = config::settings(cli.global.config.as_deref())?; + app::run_clean(args, mode, &cli.global, &settings, command_line) } Some(Command::EmptyTrash(args)) => { app::run_empty_trash(args, mode, &cli.global, command_line) @@ -77,13 +77,13 @@ fn run(cli: &Cli, mode: agent::OutputMode, command_line: &str) -> CliResult<()> Some(Command::Schema) => introspect::run_schema(), Some(Command::Describe) => introspect::run_describe(mode, command_line), Some(Command::Tui(args)) => { - let fallback = config::fallback_roots(cli.global.config.as_deref())?; - app::run_tui(&args.explicit_roots(), &fallback) + let settings = config::settings(cli.global.config.as_deref())?; + app::run_tui(&args.explicit_roots(), &settings) } None => { if agent::should_launch_tui(&cli.global) { - let fallback = config::fallback_roots(cli.global.config.as_deref())?; - app::run_tui(&[], &fallback) + let settings = config::settings(cli.global.config.as_deref())?; + app::run_tui(&[], &settings) } else { Err(CliError::no_command()) } diff --git a/crates/vacuum-cli/tests/cli.rs b/crates/vacuum-cli/tests/cli.rs index c62164e..82610a8 100644 --- a/crates/vacuum-cli/tests/cli.rs +++ b/crates/vacuum-cli/tests/cli.rs @@ -152,6 +152,142 @@ fn clean_apply_purge_removes() { assert!(!target.exists(), "purge must remove the target dir"); } +#[test] +fn list_emits_one_group_per_category() { + // Build artifacts come from two cleaners (the directory sweep and + // the Cargo unit prune), but a category must still appear exactly once — frontends + // group by category, not by cleaner. + let tmp = sample_tree(); + let output = Command::cargo_bin("vacuum") + .unwrap() + .args(["list", "--json", "--root"]) + .arg(tmp.path()) + .assert() + .success() + .get_output() + .stdout + .clone(); + let stdout = String::from_utf8(output).unwrap(); + + for slug in [ + "build-artifacts", + "package-gc", + "caches", + "temp-files", + "large-files", + ] { + let needle = format!("\"category\":\"{slug}\",\"title\""); + assert!( + stdout.matches(&needle).count() <= 1, + "category {slug} must head at most one group, found {} in: {stdout}", + stdout.matches(&needle).count() + ); + } +} + +#[test] +fn temp_files_category_is_accepted() { + // The category must be selectable and appear in the machine-readable schema + // so agents can discover it. + Command::cargo_bin("vacuum") + .unwrap() + .args(["list", "--json", "--category", "temp-files"]) + .assert() + .success() + .stdout(predicate::str::contains("\"tool\":\"vacuum\"")); + + Command::cargo_bin("vacuum") + .unwrap() + .arg("schema") + .assert() + .success() + .stdout(predicate::str::contains("temp-files")); +} + +#[test] +fn privileged_gc_is_offered_as_a_sudo_line_never_executed() { + // Which recipes appear depends entirely on which tools are installed, so + // this asserts the shape rather than the contents: the group is always + // present, and any candidate in it is a command target whose root-requiring + // form is rendered as a `sudo` line for the user to run. On a machine with + // no GC tools at all (a clean build sandbox) the list is legitimately empty. + let output = Command::cargo_bin("vacuum") + .unwrap() + .args(["list", "--json", "--category", "package-gc"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let stdout = String::from_utf8(output).unwrap(); + + assert!( + stdout.contains("\"category\":\"package-gc\""), + "the package-gc group must always be reported: {stdout}" + ); + if stdout.contains("\"cleaner_id\":\"package-gc\"") { + assert!( + stdout.contains("\"kind\":\"command\""), + "package-gc candidates are commands, never paths: {stdout}" + ); + } + assert!( + !stdout.contains("\"needs_root\":true,\"program\"") || stdout.contains("sudo "), + "a root-requiring recipe must render its sudo line: {stdout}" + ); +} + +#[test] +fn stale_days_and_cleaner_filters_are_accepted() { + let tmp = sample_tree(); + + // `--stale-days 0` makes every build unit cold; it must parse and run. + Command::cargo_bin("vacuum") + .unwrap() + .args(["list", "--json", "--stale-days", "0", "--root"]) + .arg(tmp.path()) + .assert() + .success(); + + // `--cleaner` narrows within a category. Selecting only the Cargo prune + // must not surface the whole-directory build-artifacts candidate. + let output = Command::cargo_bin("vacuum") + .unwrap() + .args(["list", "--json", "--cleaner", "cargo-prune", "--root"]) + .arg(tmp.path()) + .assert() + .success() + .get_output() + .stdout + .clone(); + let stdout = String::from_utf8(output).unwrap(); + assert!( + !stdout.contains("\"cleaner_id\":\"build-artifacts\""), + "--cleaner cargo-prune must exclude the whole-directory cleaner: {stdout}" + ); +} + +#[test] +fn cargo_prune_ignores_a_target_dir_without_a_cachedir_tag() { + // `sample_tree` builds a bare `proj/target` with no CACHEDIR.TAG, so it is + // not a real Cargo target directory and the prune must leave it alone. + let tmp = sample_tree(); + let output = Command::cargo_bin("vacuum") + .unwrap() + .args(["list", "--json", "--cleaner", "cargo-prune", "--root"]) + .arg(tmp.path()) + .assert() + .success() + .get_output() + .stdout + .clone(); + let stdout = String::from_utf8(output).unwrap(); + assert!( + !stdout.contains("\"kind\":\"batch\""), + "no batch should be produced for a non-Cargo target dir: {stdout}" + ); +} + #[test] fn unknown_category_emits_structured_error_with_hint() { // Usage errors exit 2 and carry a runnable hint (the CLI Standard §4, agentic §3). diff --git a/crates/vacuum-core/src/cleaner.rs b/crates/vacuum-core/src/cleaner.rs index 89af0df..13b6cab 100644 --- a/crates/vacuum-core/src/cleaner.rs +++ b/crates/vacuum-core/src/cleaner.rs @@ -13,7 +13,7 @@ use serde::Serialize; use crate::error::Result; -/// The four reclaimable-space categories Vacuum knows about. +/// The reclaimable-space categories Vacuum knows about. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum Category { @@ -23,16 +23,19 @@ pub enum Category { PackageManagerGc, /// Application and user caches. Caches, + /// Stale temporary files under `/tmp`, `/var/tmp`, and `$TMPDIR`. + TempFiles, /// The largest individual files, for manual review. LargeFiles, } impl Category { /// All categories, in display order. - pub const ALL: [Self; 4] = [ + pub const ALL: [Self; 5] = [ Self::BuildArtifacts, Self::PackageManagerGc, Self::Caches, + Self::TempFiles, Self::LargeFiles, ]; @@ -42,6 +45,7 @@ impl Category { Self::BuildArtifacts => "build-artifacts", Self::PackageManagerGc => "package-gc", Self::Caches => "caches", + Self::TempFiles => "temp-files", Self::LargeFiles => "large-files", } } @@ -52,6 +56,7 @@ impl Category { Self::BuildArtifacts => "Dev build artifacts", Self::PackageManagerGc => "Package-manager garbage", Self::Caches => "App / user caches", + Self::TempFiles => "Stale temp files", Self::LargeFiles => "Large files", } } @@ -115,6 +120,22 @@ pub enum Target { /// The command specification. spec: CommandSpec, }, + /// Many paths removed as one unit, all confined beneath `root`. + /// + /// Used where a single logical reclaim spans hundreds of scattered paths — + /// pruning dead build units out of a Cargo `target/` directory, for + /// instance. Presenting those individually would bury the frontend in rows; + /// the batch keeps one row while still deleting at file granularity. + /// + /// Every path is safety-checked independently, exactly as a + /// [`Target::Path`] would be. + Batch { + /// The directory the batch is confined to. Reported to the user so the + /// affected project is identifiable from the row alone. + root: PathBuf, + /// The paths to remove. All lie beneath `root`. + paths: Vec, + }, } /// A single unit of reclaimable space surfaced by a cleaner. @@ -136,17 +157,48 @@ pub struct Candidate { pub bytes: u64, /// Whether the target is fully regenerable. pub regenerable: bool, + /// Whether routing this candidate through the trash actually reclaims space. + /// + /// Normally `true`. Set to `false` for paths whose trash directory lives on + /// the same filesystem as the path itself — the freedesktop trash spec puts + /// them in `/.Trash-$uid`, so trashing frees nothing. [`Deleter`] + /// purges such candidates outright rather than pretending to reclaim space. + /// + /// [`Deleter`]: crate::delete::Deleter + pub trash_ok: bool, /// The review risk for this candidate. pub risk: Risk, /// What removal would actually do. pub target: Target, } +/// How long a build artifact must go untouched before it is considered stale. +/// +/// Thirty days spans a monthly work cycle: a project touched within the last +/// month keeps its warm incremental cache, anything older is rebuilt on next +/// use. Overridable per run via `--stale-days` or the `stale_days` config key. +pub const DEFAULT_STALE_DAYS: u64 = 30; + /// Context handed to each cleaner's [`Cleaner::scan`]. #[derive(Debug, Clone)] pub struct ScanContext { /// The roots to scan within (defaults to the user's home directory). pub roots: Vec, + /// Days of disuse after which a build artifact counts as stale. + pub stale_days: u64, +} + +impl ScanContext { + /// A context over `roots` with every other setting left at its default. + /// + /// Preferred over the struct literal so that adding a setting does not break + /// callers that do not care about it. + pub fn new(roots: Vec) -> Self { + Self { + roots, + stale_days: DEFAULT_STALE_DAYS, + } + } } /// A source of reclaimable-space candidates. @@ -159,6 +211,20 @@ pub trait Cleaner { fn category(&self) -> Category; /// Inspect the system and report reclaimable candidates. fn scan(&self, ctx: &ScanContext) -> Result>; + + /// Delete roots this cleaner's candidates need beyond [`ScanContext::roots`]. + /// + /// Path deletions are bounded by the caller's root list (see + /// [`safety::check_deletable`]), which defaults to `$HOME`. A cleaner that + /// legitimately reports paths elsewhere — the temp-file cleaner and `/tmp`, + /// for instance — declares those locations here, and the frontend widens the + /// delete allowlist only when that cleaner is actually selected. The + /// protected-prefix and symlink guards still apply to every path. + /// + /// [`safety::check_deletable`]: crate::safety::check_deletable + fn extra_roots(&self) -> Vec { + Vec::new() + } } /// Sum the estimated reclaimable bytes across a set of candidates. diff --git a/crates/vacuum-core/src/delete.rs b/crates/vacuum-core/src/delete.rs index 0010fdc..06311eb 100644 --- a/crates/vacuum-core/src/delete.rs +++ b/crates/vacuum-core/src/delete.rs @@ -63,6 +63,15 @@ pub struct Outcome { pub note: Option, } +/// Notes that a candidate was purged despite the trash mode being selected. +/// +/// Surfaced on [`Outcome::note`] so the override is never silent — the user +/// asked for a recoverable delete and is getting a permanent one instead. The +/// *reason* is candidate-specific and lives in [`Candidate::detail`], since it +/// differs per cleaner (a temp file's trash sits on the same filesystem; a +/// build-artifact batch would otherwise scatter hundreds of trash entries). +const PURGED_NOT_TRASHED: &str = "purged, not trashed — see the candidate's detail for why"; + /// Executes deletions according to a configured mode and dry-run setting. #[derive(Debug, Clone)] pub struct Deleter { @@ -98,7 +107,75 @@ impl Deleter { match &candidate.target { Target::Path { path } => self.act_on_path(candidate, path), Target::Command { spec } => self.act_on_command(candidate, spec), + Target::Batch { paths, .. } => self.act_on_batch(candidate, paths), + } + } + + /// Remove every path in a batch, reporting one aggregate [`Outcome`]. + /// + /// The safety gate runs over *all* paths before anything is removed, so a + /// batch containing even one refused path is rejected whole rather than + /// half-applied — a partially pruned build tree is worse than an untouched + /// one. Failures during removal itself are tolerated (a concurrent build may + /// legitimately have taken a file away) and summarised in the note. + fn act_on_batch(&self, candidate: &Candidate, paths: &[PathBuf]) -> Result { + // A path that has already gone is not a failure — another candidate in + // the same run may legitimately have removed the tree containing it + // (the whole-directory and per-unit candidates for one Cargo target + // deliberately overlap). Drop those before the safety gate, which would + // otherwise report them as unreadable. + let paths: Vec<&PathBuf> = paths + .iter() + .filter(|path| path.symlink_metadata().is_ok()) + .collect(); + + for path in &paths { + safety::check_deletable(path, &self.roots)?; } + + // A batch is many small regenerable files; the trash is the wrong home + // for them, so cleaners mark these `trash_ok: false` and they purge. + let effective_mode = if candidate.trash_ok { + self.mode + } else { + DeleteMode::Purge + }; + + if self.dry_run { + let action = match effective_mode { + DeleteMode::Trash => Action::WouldTrash, + DeleteMode::Purge => Action::WouldPurge, + }; + return Ok(Self::outcome( + candidate, + action, + Some(format!("{} path(s)", paths.len())), + )); + } + + let mut failed = 0_usize; + for path in &paths { + let removed = match effective_mode { + DeleteMode::Trash => { + trash::delete(path).map_err(|err| VacuumError::Trash(err.to_string())) + } + DeleteMode::Purge => remove_path(path), + }; + if removed.is_err() { + failed += 1; + } + } + + let action = match effective_mode { + DeleteMode::Trash => Action::Trashed, + DeleteMode::Purge => Action::Purged, + }; + let note = if failed == 0 { + format!("{} path(s)", paths.len()) + } else { + format!("{} path(s), {failed} could not be removed", paths.len()) + }; + Ok(Self::outcome(candidate, action, Some(note))) } fn act_on_path(&self, candidate: &Candidate, path: &PathBuf) -> Result { @@ -106,22 +183,33 @@ impl Deleter { // predicts what an `--apply` run would refuse. safety::check_deletable(path, &self.roots)?; + // A candidate whose trash directory shares its filesystem reclaims + // nothing when trashed, so purge it instead of reporting a phantom + // saving. The note makes the override visible in every output mode. + let effective_mode = if candidate.trash_ok { + self.mode + } else { + DeleteMode::Purge + }; + let note = (!candidate.trash_ok && self.mode == DeleteMode::Trash) + .then(|| PURGED_NOT_TRASHED.to_owned()); + if self.dry_run { - let action = match self.mode { + let action = match effective_mode { DeleteMode::Trash => Action::WouldTrash, DeleteMode::Purge => Action::WouldPurge, }; - return Ok(Self::outcome(candidate, action, None)); + return Ok(Self::outcome(candidate, action, note)); } - match self.mode { + match effective_mode { DeleteMode::Trash => { trash::delete(path).map_err(|err| VacuumError::Trash(err.to_string()))?; - Ok(Self::outcome(candidate, Action::Trashed, None)) + Ok(Self::outcome(candidate, Action::Trashed, note)) } DeleteMode::Purge => { remove_path(path)?; - Ok(Self::outcome(candidate, Action::Purged, None)) + Ok(Self::outcome(candidate, Action::Purged, note)) } } } @@ -203,6 +291,7 @@ mod tests { detail: None, bytes: 0, regenerable: true, + trash_ok: true, risk: Risk::Safe, target: Target::Path { path }, } @@ -231,6 +320,124 @@ mod tests { assert!(!target.exists()); } + #[test] + fn dry_run_reports_purge_when_trash_would_not_reclaim() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("stale"); + fs::create_dir(&target).unwrap(); + let mut candidate = path_candidate(target); + candidate.trash_ok = false; + + // Trash mode requested, but the candidate opts out of it. + let deleter = Deleter::new(DeleteMode::Trash, true, vec![tmp.path().to_path_buf()]); + let outcome = deleter.execute(&candidate).unwrap(); + + assert_eq!(outcome.action, Action::WouldPurge); + assert!( + outcome + .note + .is_some_and(|note| note.contains("purged, not trashed")), + "the trash override must be explained to the user" + ); + } + + #[test] + fn trash_mode_purges_when_trash_would_not_reclaim() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("stale"); + fs::create_dir(&target).unwrap(); + fs::write(target.join("blob"), b"x").unwrap(); + let mut candidate = path_candidate(target.clone()); + candidate.trash_ok = false; + + let deleter = Deleter::new(DeleteMode::Trash, false, vec![tmp.path().to_path_buf()]); + let outcome = deleter.execute(&candidate).unwrap(); + + assert_eq!(outcome.action, Action::Purged); + assert!(!target.exists()); + } + + #[test] + fn trash_ok_candidates_are_unaffected_by_the_override() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("target"); + fs::create_dir(&target).unwrap(); + + let deleter = Deleter::new(DeleteMode::Trash, true, vec![tmp.path().to_path_buf()]); + let outcome = deleter.execute(&path_candidate(target)).unwrap(); + + assert_eq!(outcome.action, Action::WouldTrash); + assert!(outcome.note.is_none()); + } + + fn batch_candidate(root: std::path::PathBuf, paths: Vec) -> Candidate { + Candidate { + cleaner_id: "test".to_owned(), + category: Category::BuildArtifacts, + label: root.display().to_string(), + detail: None, + bytes: 0, + regenerable: true, + trash_ok: false, + risk: Risk::Safe, + target: Target::Batch { root, paths }, + } + } + + #[test] + fn batch_dry_run_deletes_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let one = tmp.path().join("one"); + let two = tmp.path().join("two"); + fs::write(&one, b"x").unwrap(); + fs::write(&two, b"y").unwrap(); + + let deleter = Deleter::new(DeleteMode::Trash, true, vec![tmp.path().to_path_buf()]); + let candidate = batch_candidate(tmp.path().to_path_buf(), vec![one.clone(), two.clone()]); + let outcome = deleter.execute(&candidate).unwrap(); + + assert_eq!(outcome.action, Action::WouldPurge); + assert!(one.exists() && two.exists()); + } + + #[test] + fn batch_apply_removes_every_path() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("file"); + let dir = tmp.path().join("dir"); + fs::write(&file, b"x").unwrap(); + fs::create_dir(&dir).unwrap(); + fs::write(dir.join("nested"), b"y").unwrap(); + + let deleter = Deleter::new(DeleteMode::Purge, false, vec![tmp.path().to_path_buf()]); + let candidate = batch_candidate(tmp.path().to_path_buf(), vec![file.clone(), dir.clone()]); + let outcome = deleter.execute(&candidate).unwrap(); + + assert_eq!(outcome.action, Action::Purged); + assert!(!file.exists() && !dir.exists()); + } + + #[test] + fn batch_is_refused_whole_when_one_path_is_outside_the_roots() { + let tmp = tempfile::tempdir().unwrap(); + let other = tempfile::tempdir().unwrap(); + let inside = tmp.path().join("inside"); + let outside = other.path().join("outside"); + fs::write(&inside, b"x").unwrap(); + fs::write(&outside, b"y").unwrap(); + + let deleter = Deleter::new(DeleteMode::Purge, false, vec![tmp.path().to_path_buf()]); + let candidate = batch_candidate( + tmp.path().to_path_buf(), + vec![inside.clone(), outside.clone()], + ); + let err = deleter.execute(&candidate).unwrap_err(); + + assert_eq!(err.code(), "OUTSIDE_ROOTS"); + assert!(inside.exists(), "a rejected batch must not be half-applied"); + assert!(outside.exists()); + } + #[test] fn purge_refuses_outside_root() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/vacuum-core/src/lib.rs b/crates/vacuum-core/src/lib.rs index 252b29e..8660c28 100644 --- a/crates/vacuum-core/src/lib.rs +++ b/crates/vacuum-core/src/lib.rs @@ -33,4 +33,4 @@ pub use cleaner::{ }; pub use delete::{Action, DeleteMode, Deleter, Outcome}; pub use error::{Result, VacuumError}; -pub use scan::{Usage, dir_size, find_pruned_dirs, largest_files, top_consumers}; +pub use scan::{Usage, dir_size, find_pruned_dirs, largest_files, sum_unique_files, top_consumers}; diff --git a/crates/vacuum-core/src/scan.rs b/crates/vacuum-core/src/scan.rs index 2f2c92e..1b8338b 100644 --- a/crates/vacuum-core/src/scan.rs +++ b/crates/vacuum-core/src/scan.rs @@ -6,6 +6,7 @@ //! Directory sizes are summed in parallel with [`jwalk`]. Symlinks are never //! followed, so a link can never inflate a size or escape the scanned tree. +use std::collections::HashSet; use std::path::{Path, PathBuf}; use jwalk::WalkDir; @@ -40,6 +41,46 @@ pub fn dir_size(path: &Path) -> u64 { .sum() } +/// Sum the size of every regular file under `paths`, counting each inode once. +/// +/// Cargo hardlinks its final artifacts into place: `target/debug/vacuum` and +/// `target/debug/deps/vacuum-7fa5fb…` are one 63 MiB inode with two names. +/// Summing [`std::fs::Metadata::len`] naively would report that space twice — +/// on this workspace, a 12% over-estimate — and then claim to have freed it +/// twice. Removing one name frees nothing until the last one goes, so only +/// distinct `(device, inode)` pairs are counted, exactly as `du` does. +/// +/// Symlinks are not followed. Unreadable entries are skipped. +pub fn sum_unique_files(paths: &[PathBuf]) -> u64 { + use std::os::unix::fs::MetadataExt as _; + + let mut seen = HashSet::new(); + let mut total = 0_u64; + + for path in paths { + for entry in WalkDir::new(path) + .skip_hidden(false) + .follow_links(false) + .into_iter() + .filter_map(std::result::Result::ok) + { + let Ok(meta) = entry.metadata() else { + continue; + }; + if !meta.is_file() { + continue; + } + // Count a multiply-linked inode only the first time it is met. + if meta.nlink() > 1 && !seen.insert((meta.dev(), meta.ino())) { + continue; + } + total += meta.len(); + } + } + + total +} + /// List the immediate children of `root`, sized and sorted largest-first. /// /// This is the equivalent of `du --max-depth=1 | sort -rh`. @@ -146,13 +187,44 @@ fn file_len(path: &Path) -> u64 { mod tests { use std::fs; - use super::{dir_size, find_pruned_dirs, largest_files, top_consumers}; + use super::{dir_size, find_pruned_dirs, largest_files, sum_unique_files, top_consumers}; fn write(path: &std::path::Path, bytes: usize) { fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, vec![0_u8; bytes]).unwrap(); } + #[test] + fn sum_unique_files_counts_a_hardlinked_inode_once() { + let tmp = tempfile::tempdir().unwrap(); + let original = tmp.path().join("deps/lib-abcdef.rlib"); + write(&original, 4096); + // Cargo "uplifts" its artifacts by hardlinking, not copying. + let uplifted = tmp.path().join("libthing.rlib"); + fs::hard_link(&original, &uplifted).unwrap(); + + let paths = vec![tmp.path().to_path_buf()]; + assert_eq!( + sum_unique_files(&paths), + 4096, + "one inode with two names is 4096 bytes of disk, not 8192" + ); + // dir_size is deliberately left naive; this documents the difference. + assert_eq!(dir_size(tmp.path()), 8192); + } + + #[test] + fn sum_unique_files_spans_multiple_roots_without_double_counting() { + let tmp = tempfile::tempdir().unwrap(); + write(&tmp.path().join("a/one"), 1000); + write(&tmp.path().join("b/two"), 2000); + let linked = tmp.path().join("b/one-link"); + fs::hard_link(tmp.path().join("a/one"), &linked).unwrap(); + + let paths = vec![tmp.path().join("a"), tmp.path().join("b")]; + assert_eq!(sum_unique_files(&paths), 3000); + } + #[test] fn dir_size_sums_files() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/vacuum-tui/src/lib.rs b/crates/vacuum-tui/src/lib.rs index 2225d19..6f9fa2b 100644 --- a/crates/vacuum-tui/src/lib.rs +++ b/crates/vacuum-tui/src/lib.rs @@ -38,7 +38,9 @@ use ratatui::text::Line; use ratatui::widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph}; use ratatui::{DefaultTerminal, Frame}; use vacuum_cleaners::all_cleaners; -use vacuum_core::{Action, Candidate, DeleteMode, Deleter, ScanContext, human_bytes, total_bytes}; +use vacuum_core::{ + Action, Candidate, Category, DeleteMode, Deleter, ScanContext, human_bytes, total_bytes, +}; use vacuum_theme::Steelbore; /// How long to wait for input before redrawing while idle. @@ -144,6 +146,9 @@ enum Progress { Scanned { candidates: Vec, rows: Vec, + /// The scan roots widened by the cleaners' `extra_roots`, bounding every + /// deletion made from this scan. + delete_roots: Vec, }, /// About to act on a delete item: `done` already removed of `total`. Deleting { @@ -188,6 +193,11 @@ struct Job { /// TUI application state. struct App { roots: Vec, + /// The scan roots widened by the cleaners' `extra_roots`, as reported by the + /// most recent scan. This — not [`App::roots`] — bounds deletions, so a + /// temp-file candidate under `/tmp` can be acted on while every other guard + /// stays in force. + delete_roots: Vec, candidates: Vec, selected: Vec, rows: Vec, @@ -202,6 +212,7 @@ struct App { impl App { fn new(roots: Vec) -> Self { let mut app = Self { + delete_roots: roots.clone(), roots, candidates: Vec::new(), selected: Vec::new(), @@ -245,8 +256,9 @@ impl App { /// What the drain loop decided once the worker signalled an end. enum End { Running, - /// Scan results plus the status to set afterward. - Scanned(Vec, Vec, Option), + /// Scan results, the delete roots they need, and the status to set + /// afterward. + Scanned(Vec, Vec, Vec, Option), /// A delete summary to show; triggers a rescan. Deleted(String), /// The worker vanished without a final message; just stop. @@ -270,8 +282,13 @@ impl App { job.reclaimed = reclaimed; } Ok(Progress::Note(note)) => job.note = Some(note), - Ok(Progress::Scanned { candidates, rows }) => { - outcome = End::Scanned(candidates, rows, job.post_status.take()); + Ok(Progress::Scanned { + candidates, + rows, + delete_roots, + }) => { + outcome = + End::Scanned(candidates, rows, delete_roots, job.post_status.take()); break; } Ok(Progress::Deleted { @@ -303,9 +320,9 @@ impl App { match outcome { End::Running => {} - End::Scanned(candidates, rows, post_status) => { + End::Scanned(candidates, rows, delete_roots, post_status) => { self.job = None; - self.install_scan(candidates, rows); + self.install_scan(candidates, rows, delete_roots); if let Some(status) = post_status { self.status = status; } @@ -324,10 +341,16 @@ impl App { } /// Install scan results, clearing the previous selection. - fn install_scan(&mut self, candidates: Vec, rows: Vec) { + fn install_scan( + &mut self, + candidates: Vec, + rows: Vec, + delete_roots: Vec, + ) { self.selected = vec![false; candidates.len()]; self.candidates = candidates; self.rows = rows; + self.delete_roots = delete_roots; self.cursor = first_item_row(&self.rows); } @@ -368,7 +391,7 @@ impl App { DeleteMode::Trash }; let verb = if self.purge { "Removing" } else { "Trashing" }; - let deleter = Deleter::new(mode, false, self.roots.clone()); + let deleter = Deleter::new(mode, false, self.delete_roots.clone()); let cancel = Arc::new(AtomicBool::new(false)); let rx = spawn_delete(items, deleter, verb, Arc::clone(&cancel)); self.job = Some(Job { @@ -912,28 +935,35 @@ fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { fn spawn_scan(roots: Vec, cancel: Arc) -> Receiver { let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { - let ctx = ScanContext { roots }; - let mut candidates = Vec::new(); - let mut rows = Vec::new(); + let ctx = ScanContext::new(roots.clone()); + let mut delete_roots = roots; + // Several cleaners can share a category, so results are collected first + // and only then turned into rows — one header per category, not one per + // cleaner. + let mut found: Vec<(Category, Vec)> = Vec::new(); + for cleaner in all_cleaners() { if cancel.load(Ordering::Relaxed) { break; } let _ = tx.send(Progress::Scanning(cleaner.category().title().to_owned())); match cleaner.scan(&ctx) { - Ok(found) => { - if found.is_empty() { + Ok(items) => { + for root in cleaner.extra_roots() { + if !delete_roots.contains(&root) { + delete_roots.push(root); + } + } + if items.is_empty() { continue; } - rows.push(Row::Header { - title: cleaner.category().title().to_owned(), - total: total_bytes(&found), - }); - for candidate in found { - rows.push(Row::Item { - index: candidates.len(), - }); - candidates.push(candidate); + let category = cleaner.category(); + if let Some((_, existing)) = + found.iter_mut().find(|(seen, _)| *seen == category) + { + existing.extend(items); + } else { + found.push((category, items)); } } Err(err) => { @@ -941,7 +971,35 @@ fn spawn_scan(roots: Vec, cancel: Arc) -> Receiver