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