diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..9b8964f --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustflags = ["-Zthreads=0", "-Zshare-generics=y"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdf7fc1..2cdee8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,10 @@ env: # compiled crates are reused across runs (the cold-build win for CI). RUSTC_WRAPPER: sccache SCCACHE_GHA_ENABLED: "true" + # .cargo/config.toml carries nightly-only -Z build-speed flags for local dev. CI runs + # stable, which rejects -Z, so clear rustflags here. RUSTFLAGS overrides build.rustflags + # in cargo's precedence, and these flags only affect compile speed, never the output. + RUSTFLAGS: "" jobs: fmt-and-test: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1234a42..3bcab9e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,11 @@ concurrency: env: CARGO_TERM_COLOR: always + # .cargo/config.toml carries nightly-only -Z build-speed flags for local dev. Release + # builds and `cargo publish` run on stable, which rejects -Z, so clear rustflags here. + # RUSTFLAGS overrides build.rustflags in cargo's precedence, and these flags only affect + # compile speed, never the output, so release binaries are unchanged. + RUSTFLAGS: "" jobs: changelog: diff --git a/Cargo.lock b/Cargo.lock index 3fb3637..90f83ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,6 +158,62 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "dirs" version = "6.0.0" @@ -179,6 +235,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -200,6 +262,7 @@ dependencies = [ "cliclack", "console", "dirs", + "jwalk", "similar", "sysinfo", "toml_edit", @@ -293,6 +356,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2735847566356cd2179a2a38264839308f7079fa96e6bd5a42d740460e003c56" +dependencies = [ + "crossbeam", + "rayon", +] + [[package]] name = "libc" version = "0.2.186" @@ -390,6 +463,26 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_users" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index 08d1150..cf77818 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ clap = { version = "4.6.1", features = ["derive"] } cliclack = "0.5.4" console = "0.16.3" dirs = "6.0.0" +jwalk = "0.8.1" similar = "3.1.1" # Pinned to 0.36 on purpose: it has MSRV 1.75 (below our edition-2024 floor, so no cost), # while 0.37+ raises the MSRV and 0.39 needs Rust 1.95. Only RAM and disk facts are used. @@ -24,3 +25,15 @@ toml_edit = "0.25.12" # frd recommends `strip` to the projects it tunes, so it ships its own binaries stripped too. [profile.release] strip = true + +[profile.dev] +debug = "line-tables-only" +split-debuginfo = "unpacked" + +[profile.dev.package."*"] +opt-level = 2 + +[profile.fast-build] +inherits = "dev" +debug = 0 +strip = "debuginfo" diff --git a/src/catalog.rs b/src/catalog.rs index 3e43bb0..c0717c9 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -3,10 +3,12 @@ //! the live report so the user sees why an item matters on their machine. use crate::suggestion::{ - Action, InstallSpec, RunSpec, Scope, Suggestion, Tag, TomlChange, TomlOp, TomlValue, + Action, InstallSpec, PurgeSpec, Scope, Suggestion, SweepSpec, Tag, TomlChange, TomlOp, + TomlValue, }; use crate::system::{SystemReport, have, human_bytes}; -use std::path::PathBuf; +use crate::toml_ops; +use std::path::{Path, PathBuf}; pub fn build(r: &SystemReport) -> Vec { let mut out = Vec::new(); @@ -169,27 +171,50 @@ pub fn build(r: &SystemReport) -> Vec { } // --- tools --- - if !have("cargo-sweep") { + // Gate the sweep on cargo-sweep being on PATH: offering to run a binary that is + // not installed only produces a confusing failed step. When it is missing we offer + // the install instead, and a re-run of frd then surfaces the sweep (the same + // install-then-rerun flow sccache uses above). + if have("cargo-sweep") { + out.push(sweep_sug( + "Sweep stale build artifacts", + Tag::Disk, + "Removes artifacts untouched for more than 7 days while keeping warm ones. \ + Choose how wide to sweep: just this project, or any parent up to your home dir." + .into(), + SweepSpec { + candidates: sweep_candidates(&p.root), + time_days: 7, + }, + )); + } else { out.push(install_sug( "Install cargo-sweep (disk reclaim)", Tag::Disk, "Garbage-collects stale build artifacts that Cargo never removes, by age, instead \ - of the all-or-nothing cargo clean." + of the all-or-nothing cargo clean. Re-run frd afterward to sweep." .into(), "cargo-sweep", "cargo-sweep", )); } - out.push(run_sug( - "Sweep stale artifacts in this project", - Tag::Disk, - "Removes artifacts untouched for more than 15 days while keeping warm ones. Re-run \ - with --recursive ~/dev to sweep every repo. Needs cargo-sweep." - .into(), - "cargo", - vec!["sweep".into(), "--time".into(), "15".into()], - Some(p.root.clone()), - )); + + // Offer the leftover-target purge only once builds are centralized: with + // build.target-dir set, every per-project target/ from before the switch is dead + // weight that nothing will reuse. Until then deleting them just forces a cold rebuild + // into the same scattered place. The card here covers the already-centralized case; + // main offers the same purge in the run the user accepts centralization. + if let Some(spec) = purge_spec(r) { + out.push(purge_sug( + "Reclaim leftover per-project target dirs", + Tag::Disk, + "build.target-dir is set, so new builds share one dir and the old per-project \ + target/ dirs are now dead weight. Delete them to reclaim that space. Choose how \ + wide: just this project, or any parent up to your home dir." + .into(), + spec, + )); + } if !have("cargo-machete") { out.push(install_sug( @@ -205,6 +230,18 @@ pub fn build(r: &SystemReport) -> Vec { out } +/// The leftover-target purge for this machine, present only once `build.target-dir` is +/// configured globally. Returned to main so it can offer the purge in the same run the +/// user accepts centralization, not just on the next one. The configured central dir is +/// carried in `protected` so the purge never deletes the dir builds were just pointed at. +pub fn purge_spec(r: &SystemReport) -> Option { + let central = toml_ops::global_target_dir_value(r)?; + Some(PurgeSpec { + candidates: sweep_candidates(&r.project.root), + protected: Some(central), + }) +} + fn shared_target_dir() -> String { dirs::home_dir() .unwrap_or_default() @@ -235,22 +272,60 @@ fn install_sug(title: &str, tag: Tag, why: String, crate_name: &str, bin: &str) } } -fn run_sug( - title: &str, - tag: Tag, - why: String, - program: &str, - args: Vec, - cwd: Option, -) -> Suggestion { +fn sweep_sug(title: &str, tag: Tag, why: String, spec: SweepSpec) -> Suggestion { Suggestion { title: title.into(), tag, why, - action: Action::Run(RunSpec { - program: program.into(), - args, - cwd, - }), + action: Action::Sweep(spec), + } +} + +fn purge_sug(title: &str, tag: Tag, why: String, spec: PurgeSpec) -> Suggestion { + Suggestion { + title: title.into(), + tag, + why, + action: Action::Purge(spec), + } +} + +fn sweep_candidates(root: &Path) -> Vec { + candidates_up_to(root, dirs::home_dir().as_deref()) +} + +/// The project dir then each parent up to and including `home`, narrow to wide. If +/// the project is not under `home` the chain would climb to the filesystem root, so +/// in that case we offer only the project dir and never sweep toward `/`. +fn candidates_up_to(root: &Path, home: Option<&Path>) -> Vec { + let mut dirs = Vec::new(); + for ancestor in root.ancestors() { + dirs.push(ancestor.to_path_buf()); + if Some(ancestor) == home { + return dirs; + } + } + vec![root.to_path_buf()] +} + +#[cfg(test)] +mod tests { + use super::candidates_up_to; + use std::path::{Path, PathBuf}; + + #[test] + fn candidates_run_from_project_up_to_home() { + let got = candidates_up_to(Path::new("/Users/x/dev/proj"), Some(Path::new("/Users/x"))); + let want: Vec = ["/Users/x/dev/proj", "/Users/x/dev", "/Users/x"] + .iter() + .map(PathBuf::from) + .collect(); + assert_eq!(got, want); + } + + #[test] + fn project_outside_home_offers_only_itself() { + let got = candidates_up_to(Path::new("/tmp/proj"), Some(Path::new("/Users/x"))); + assert_eq!(got, vec![PathBuf::from("/tmp/proj")]); } } diff --git a/src/doctor.rs b/src/doctor.rs index bbdee7c..e2da1ee 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -16,7 +16,7 @@ pub fn run(report: &SystemReport) -> bool { // and tool-based items, which status_of can classify as applied or pending. let (maintenance, checkable): (Vec, Vec) = catalog::build(report) .into_iter() - .partition(|s| matches!(s.action, Action::Run(_))); + .partition(|s| matches!(s.action, Action::Sweep(_) | Action::Purge(_))); println!("{}", style("Doctor: optimization checkup").bold().cyan()); let mut pending = 0usize; @@ -32,13 +32,16 @@ pub fn run(report: &SystemReport) -> bool { println!(); println!("{}", style("Maintenance (re-run anytime):").dim()); for s in &maintenance { - if let Action::Run(spec) = &s.action { - println!( - " {} {}", - style(format!("{}:", s.title)).dim(), - style(spec.display()).dim() - ); - } + let detail = match &s.action { + Action::Sweep(spec) => spec.display(), + Action::Purge(spec) => spec.display(), + _ => continue, + }; + println!( + " {} {}", + style(format!("{}:", s.title)).dim(), + style(detail).dim() + ); } } diff --git a/src/main.rs b/src/main.rs index ba0d701..ff2eebb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,11 @@ fn main() -> Result<()> { )); } + // Whether builds were already centralized at startup. If not, but the user sets + // build.target-dir during this run, we offer the leftover-target purge afterward: the + // catalog could not include it because the config was still unset when it was built. + let centralized_at_start = catalog::purge_spec(&report).is_some(); + let all = catalog::build(&report); let (pending, already): (Vec, Vec) = all .into_iter() @@ -76,7 +81,21 @@ fn main() -> Result<()> { let mut runner = runner::Runner::new(); let summary = wizard::run(&report, pending, &mut runner, args.dry_run, args.yes)?; - if report.project.target_bytes.is_some() { + // Same-run reclaim: if centralization was just accepted, the per-project target/ dirs + // are now orphaned. Offer the purge here, since the catalog was built before the write. + if !centralized_at_start && let Some(spec) = catalog::purge_spec(&report) { + let _ = log::info( + "build.target-dir is set now — the old per-project target/ dirs can be reclaimed.", + ); + if let Err(e) = wizard::run_purge(&spec, args.dry_run, args.yes) { + let _ = log::error(format!("{e:#}")); + } + } + + // Skip this when the user already accepted a sweep: re-prompting "accept the + // sweep" right after they did is the confusing part. The note still fires when + // a target/ exists and no sweep ran, which is who it is actually for. + if report.project.target_bytes.is_some() && !summary.swept { let _ = log::remark( "Existing target/ dirs are not moved automatically. Accept the sweep, or run \ cargo clean per project, to reclaim space now.", @@ -117,7 +136,7 @@ pub(crate) fn status_of(r: &SystemReport, s: &Suggestion) -> Status { Status::Pending } } - Action::Run(_) => Status::Pending, + Action::Sweep(_) | Action::Purge(_) => Status::Pending, } } diff --git a/src/suggestion.rs b/src/suggestion.rs index 097ac12..439dcf6 100644 --- a/src/suggestion.rs +++ b/src/suggestion.rs @@ -88,11 +88,46 @@ impl RunSpec { } } +/// A cargo-sweep run whose target directory the wizard resolves interactively: the +/// user picks one of `candidates` (the project dir up to the home dir) at accept +/// time, so one suggestion can sweep a single repo or a whole tree. +#[derive(Clone)] +pub struct SweepSpec { + pub candidates: Vec, + pub time_days: u32, +} + +impl SweepSpec { + /// The base command, without the directory (resolved interactively at accept + /// time). Used by the doctor's read-only maintenance listing. + pub fn display(&self) -> String { + format!("cargo sweep --time {}", self.time_days) + } +} + +/// A delete-the-leftover-targets run, offered only once `build.target-dir` is set so +/// the scattered per-project `target/` dirs are redundant. The user picks a directory +/// from `candidates` (project up to home) at accept time; `protected` is the configured +/// central target dir, never deleted even if it falls inside the chosen scope. +#[derive(Clone)] +pub struct PurgeSpec { + pub candidates: Vec, + pub protected: Option, +} + +impl PurgeSpec { + /// One-line label for the doctor's read-only maintenance listing. + pub fn display(&self) -> String { + "delete leftover per-project target/ dirs".into() + } +} + #[derive(Clone)] pub enum Action { Toml(TomlChange), Install(InstallSpec), - Run(RunSpec), + Sweep(SweepSpec), + Purge(PurgeSpec), } #[derive(Clone, Copy, PartialEq, Eq)] diff --git a/src/system.rs b/src/system.rs index 4d0d7b9..f57c42f 100644 --- a/src/system.rs +++ b/src/system.rs @@ -3,9 +3,12 @@ //! from sysinfo (native on macOS, Linux, and Windows) rather than shelling out to //! Unix-only tools, so the same probes work on every supported platform. -use std::fs; +use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::{Arc, Mutex}; + +use jwalk::WalkDir; use sysinfo::{Disks, System}; #[derive(Debug, Clone)] @@ -138,34 +141,90 @@ fn disk_total_free(path: &Path) -> (Option, Option) { } } -/// Total size in bytes of every regular file under `path`, summed iteratively so a -/// deep tree cannot overflow the stack. Symlinks are skipped to avoid double-counting -/// and cycles. None when the path is absent. Cross-platform replacement for `du -sk`. -fn dir_size_bytes(path: &Path) -> Option { +/// Total size in bytes of every regular file under `path`, walked in parallel across +/// cores via jwalk. Symlinks are skipped to avoid double-counting and cycles; hidden +/// files (e.g. target/.rustc_info.json) are counted. None when the path is absent. +/// Cross-platform and, on a large tree, ~2.5x faster than single-threaded `du`. +pub fn dir_size_bytes(path: &Path) -> Option { if !path.exists() { return None; } - let mut total: u64 = 0; - let mut stack = vec![path.to_path_buf()]; - while let Some(dir) = stack.pop() { - let Ok(entries) = fs::read_dir(&dir) else { - continue; - }; - for entry in entries.flatten() { - let Ok(file_type) = entry.file_type() else { - continue; - }; - if file_type.is_symlink() { - continue; - } - if file_type.is_dir() { - stack.push(entry.path()); - } else if let Ok(meta) = entry.metadata() { - total += meta.len(); + let total = WalkDir::new(path) + .skip_hidden(false) + .into_iter() + .filter_map(std::result::Result::ok) + .filter(|entry| entry.file_type.is_file()) + .filter_map(|entry| entry.metadata().ok()) + .map(|meta| meta.len()) + .sum(); + Some(total) +} + +/// The cargo build directories under `root`, identified by the `CACHEDIR.TAG` marker +/// cargo writes at the root of every `target/`. Descent stops at each match, so a +/// target's contents are not scanned during discovery and a target nested inside +/// another is never counted twice. The walk runs in parallel across cores. +pub fn cargo_target_dirs(root: &Path) -> Vec { + let found = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&found); + WalkDir::new(root) + .process_read_dir(move |_, dir, _, children| { + let is_target = children.iter().any(|child| { + child.as_ref().is_ok_and(|entry| { + entry.file_type.is_file() && entry.file_name == *OsStr::new("CACHEDIR.TAG") + }) + }); + if is_target { + sink.lock().unwrap().push(dir.to_path_buf()); + for child in children.iter_mut().flatten() { + child.read_children_path = None; + } } - } + }) + .into_iter() + .for_each(drop); + std::mem::take(&mut *found.lock().unwrap()) +} + +/// The cargo *build* target dirs under `root`: the [`cargo_target_dirs`] that are +/// safe to delete. Cargo writes the same `CACHEDIR.TAG` into `target/`, the registry, +/// and the git cache, so the tag alone cannot tell a rebuildable build dir from the +/// crate cache. The discriminator is `.rustc_info.json`, which only a build dir holds. +/// Used by the purge flow, where deleting the registry by mistake would throw away the +/// user's downloaded crates. +pub fn cargo_build_target_dirs(root: &Path) -> Vec { + cargo_target_dirs(root) + .into_iter() + .filter(|dir| is_cargo_build_target(dir)) + .collect() +} + +/// A directory is a deletable cargo build target when it carries cargo's own +/// `CACHEDIR.TAG` and a `.rustc_info.json` beside it. The first marker reuses cargo's +/// `validate_target_dir_tag` guard; the second separates a build dir from the registry +/// and git caches, which wear the identical tag but never hold `.rustc_info.json`. +fn is_cargo_build_target(dir: &Path) -> bool { + dir.join(".rustc_info.json").is_file() && has_cargo_cachedir_tag(dir) +} + +/// Whether `dir/CACHEDIR.TAG` is a regular file beginning with the CACHEDIR.TAG +/// signature, byte-for-byte the check cargo's own `cargo clean` performs before removing a dir. +fn has_cargo_cachedir_tag(dir: &Path) -> bool { + // NOTE(provenance): the 43-byte magic header mandated by the Cache Directory Tagging + // Spec (https://bford.info/cachedir/), not a cargo-specific value -- every conforming + // cache wears it, which is why `.rustc_info.json` is needed to single out a build dir. + // Frozen by the spec and hardcoded identically by cargo's own `validate_target_dir_tag` + // (src/cargo/ops/cargo_clean.rs), so hardcoding is canonical: the value is not derivable, + // and the `cachedir` crate would only re-export this same literal. + const SIGNATURE: &[u8] = b"Signature: 8a477f597d28d172789f06886806bc55"; + let tag = dir.join("CACHEDIR.TAG"); + // Per the CACHEDIR.TAG spec the tag must be a regular file, never a symlink. + if tag.is_symlink() || !tag.is_file() { + return false; } - Some(total) + std::fs::read(&tag) + .map(|bytes| bytes.starts_with(SIGNATURE)) + .unwrap_or(false) } fn global_cargo_config() -> PathBuf { @@ -192,3 +251,104 @@ pub fn human_bytes(n: u64) -> String { format!("{v:.1} {}", UNITS[i]) } } + +#[cfg(test)] +mod tests { + use super::{cargo_build_target_dirs, cargo_target_dirs, dir_size_bytes}; + use std::fs; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU32, Ordering}; + + /// A fresh empty directory under the system temp dir, unique per call. + fn scratch() -> PathBuf { + static SEQ: AtomicU32 = AtomicU32::new(0); + let id = SEQ.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("frd-{}-{id}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn write(path: &Path, bytes: usize) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, vec![0u8; bytes]).unwrap(); + } + + /// Marks `dir` as a cargo target by writing the CACHEDIR.TAG cargo leaves there. + fn mark_target(dir: &Path) { + write(&dir.join("CACHEDIR.TAG"), 177); + } + + /// Writes the real cargo-signed CACHEDIR.TAG, matching the bytes cargo emits. + fn write_cargo_tag(dir: &Path) { + fs::create_dir_all(dir).unwrap(); + fs::write( + dir.join("CACHEDIR.TAG"), + "Signature: 8a477f597d28d172789f06886806bc55\n\ + # This file is a cache directory tag created by cargo.\n", + ) + .unwrap(); + } + + #[test] + fn dir_size_sums_files_including_hidden() { + let root = scratch(); + write(&root.join("a.bin"), 1000); + write(&root.join("sub/b.bin"), 500); + write(&root.join(".rustc_info.json"), 24); // hidden, still counted + assert_eq!(dir_size_bytes(&root), Some(1524)); + fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn dir_size_is_none_when_absent() { + assert_eq!(dir_size_bytes(Path::new("/no/such/frd/path")), None); + } + + #[test] + fn discovery_finds_targets_and_prunes_nested() { + let root = scratch(); + // Two sibling project targets, plus a target nested inside one of them. + mark_target(&root.join("proj_a/target")); + write(&root.join("proj_a/target/debug/big.bin"), 1000); + write(&root.join("proj_a/src/lib.rs"), 50); // source, not a target + mark_target(&root.join("proj_a/target/nested/target")); + write(&root.join("proj_a/target/nested/target/x.bin"), 9); + mark_target(&root.join("proj_b/target")); + write(&root.join("proj_b/target/y.bin"), 500); + + let mut found = cargo_target_dirs(&root); + found.sort(); + assert_eq!( + found, + vec![root.join("proj_a/target"), root.join("proj_b/target")], + "nested target must be pruned, source dirs ignored" + ); + + // proj_a/target's size includes the pruned nested target's bytes (counted once). + let total: u64 = found.iter().filter_map(|t| dir_size_bytes(t)).sum(); + assert_eq!(total, (177 + 1000) + (177 + 9) + (177 + 500)); + fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn build_target_discovery_excludes_registry_like_caches() { + let root = scratch(); + // A real build target: cargo-signed tag plus the .rustc_info.json only a build dir has. + let target = root.join("proj/target"); + write_cargo_tag(&target); + write(&target.join(".rustc_info.json"), 24); + write(&target.join("debug/app"), 1000); + // A registry-like cache: same cargo CACHEDIR.TAG, but no .rustc_info.json. Must be skipped. + let registry = root.join("registry"); + write_cargo_tag(®istry); + write(®istry.join("cache/some.crate"), 5000); + + assert_eq!( + cargo_build_target_dirs(&root), + vec![target], + "the crate cache wears the same tag but is not a deletable build target" + ); + fs::remove_dir_all(&root).unwrap(); + } +} diff --git a/src/toml_ops.rs b/src/toml_ops.rs index 391cf13..2ee4c46 100644 --- a/src/toml_ops.rs +++ b/src/toml_ops.rs @@ -63,6 +63,17 @@ pub fn is_applied(report: &SystemReport, change: &TomlChange) -> bool { change.ops.iter().all(|op| is_satisfied(&doc, op)) } +/// The `build.target-dir` value from the global cargo config, if set. Its presence is +/// how the catalog knows the user has centralized their builds, which gates the offer +/// to delete the now-redundant per-project `target/` dirs; the value is the one dir +/// that purge must never delete. +pub fn global_target_dir_value(report: &SystemReport) -> Option { + let text = fs::read_to_string(&report.project.global_cargo_config).ok()?; + let doc = text.parse::().ok()?; + let path = ["build".to_string(), "target-dir".to_string()]; + get_path(&doc, &path)?.as_str().map(PathBuf::from) +} + pub fn unified(before: &str, after: &str, label: &str) -> String { let diff = TextDiff::from_lines(before, after); let mut ud = diff.unified_diff(); diff --git a/src/wizard.rs b/src/wizard.rs index 44d02b1..f455e5b 100644 --- a/src/wizard.rs +++ b/src/wizard.rs @@ -2,20 +2,23 @@ //! optimization category), a single Select decision, and apply on accept. use crate::runner::Runner; -use crate::suggestion::{Action, Suggestion}; -use crate::system::SystemReport; +use crate::suggestion::{Action, PurgeSpec, RunSpec, Suggestion, SweepSpec, Tag}; +use crate::system::{self, SystemReport, human_bytes}; use crate::toml_ops::{self, TomlPlan}; use crate::ui; use anyhow::{Result, anyhow}; -use cliclack::{log, note, select}; +use cliclack::{log, note, select, spinner}; use console::style; -use std::path::Path; +use std::path::{Path, PathBuf}; pub struct Summary { pub applied: usize, pub skipped: usize, pub failed: usize, pub quit: bool, + /// Whether a sweep was accepted this run. Lets the caller drop the + /// "accept the sweep to reclaim space" epilogue once it is moot. + pub swept: bool, } #[derive(Clone, PartialEq, Eq)] @@ -36,6 +39,7 @@ pub fn run( let mut applied = 0usize; let mut skipped = 0usize; let mut failed = 0usize; + let mut swept = false; for (i, sug) in suggestions.iter().enumerate() { // Build the TOML plan up front so the card can show the exact diff. @@ -49,8 +53,11 @@ pub fn run( let decision = if yes { Decision::Accept } else { ask()? }; match decision { - Decision::Accept => match execute(sug, plan.as_ref(), runner, dry_run) { - Ok(()) => applied += 1, + Decision::Accept => match execute(sug, plan.as_ref(), runner, dry_run, yes) { + Ok(()) => { + applied += 1; + swept |= matches!(sug.action, Action::Sweep(_)); + } Err(e) => { let _ = log::error(format!("{e:#}")); failed += 1; @@ -67,6 +74,7 @@ pub fn run( skipped, failed, quit: true, + swept, }); } } @@ -78,6 +86,7 @@ pub fn run( skipped, failed, quit: false, + swept, }) } @@ -129,8 +138,30 @@ fn show_card( style(format!("· via {}", runner.install_method_label())).dim() )); } - Action::Run(s) => { - body.push_str(&format!("\n\n{} {}", style("run").bold(), s.display())); + Action::Sweep(s) => { + let scope = if s.candidates.len() == 1 { + ui::tildify(&s.candidates[0]) + } else { + format!("a dir you pick · {} options up to ~", s.candidates.len()) + }; + body.push_str(&format!( + "\n\n{} cargo sweep --time {} {}", + style("run").bold(), + s.time_days, + style(scope).dim(), + )); + } + Action::Purge(s) => { + let scope = if s.candidates.len() == 1 { + ui::tildify(&s.candidates[0]) + } else { + format!("a dir you pick · {} options up to ~", s.candidates.len()) + }; + body.push_str(&format!( + "\n\n{} delete leftover target/ dirs in {}", + style("run").bold(), + style(scope).dim(), + )); } } @@ -168,6 +199,7 @@ fn execute( plan: Option<&TomlPlan>, runner: &mut Runner, dry_run: bool, + yes: bool, ) -> Result<()> { match &sug.action { Action::Toml(_) => { @@ -187,7 +219,8 @@ fn execute( } } Action::Install(s) => runner.install(s, sug.tag, dry_run)?, - Action::Run(s) => runner.run(s, sug.tag, dry_run)?, + Action::Sweep(s) => run_sweep(s, sug.tag, runner, dry_run, yes)?, + Action::Purge(s) => run_purge(s, dry_run, yes)?, } Ok(()) } @@ -197,3 +230,176 @@ fn filename(path: &Path) -> String { .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| path.display().to_string()) } + +/// Resolve the sweep's directory, run cargo-sweep there, and report reclaimed space +/// by sizing the target dirs within it before and after. Dry-run only echoes the +/// command; the size pass is skipped because nothing is removed. +fn run_sweep(spec: &SweepSpec, tag: Tag, runner: &Runner, dry_run: bool, yes: bool) -> Result<()> { + let dir = pick_dir(&spec.candidates, yes, "Which directory to sweep?")?; + let recursive = dir != spec.candidates[0]; + let run = sweep_runspec(&dir, spec.time_days, recursive); + + if dry_run { + return runner.run(&run, tag, true); + } + + let before = measure_targets(&dir, "Scanning target dirs"); + runner.run(&run, tag, false)?; + let after = measure_targets(&dir, "Re-scanning after sweep"); + + let freed = before.saturating_sub(after); + log::success(format!( + "{} {} → {} {}", + ui::tildify(&dir), + human_bytes(before), + human_bytes(after), + style(format!("(freed {})", human_bytes(freed))) + .green() + .bold(), + ))?; + Ok(()) +} + +/// Delete the leftover per-project target dirs under a chosen directory, now that +/// builds are centralized. Reports how many cargo build targets it finds and their +/// size, then removes them behind an explicit confirm; the configured central dir is +/// always spared. Destructive, so `--yes` reports nothing and deletes nothing. +pub(crate) fn run_purge(spec: &PurgeSpec, dry_run: bool, yes: bool) -> Result<()> { + let dir = pick_dir(&spec.candidates, yes, "Which directory to clean?")?; + + // Never delete unattended. Under --yes there is no confirm to show, so skip rather + // than walk a potentially large tree we would not act on. + if yes && !dry_run { + log::warning( + "Skipped target purge: it needs an interactive confirm; re-run without --yes.", + )?; + return Ok(()); + } + + let sp = spinner(); + sp.start("Finding cargo target dirs"); + let protected = spec + .protected + .as_deref() + .and_then(|p| std::fs::canonicalize(p).ok()); + let targets: Vec = system::cargo_build_target_dirs(&dir) + .into_iter() + .filter(|t| !is_protected(t, protected.as_deref())) + .collect(); + let total: u64 = targets + .iter() + .filter_map(|t| system::dir_size_bytes(t)) + .sum(); + sp.stop(format!( + "{} target {} found · {}", + targets.len(), + if targets.len() == 1 { + "directory" + } else { + "directories" + }, + human_bytes(total), + )); + + if targets.is_empty() { + log::info("Nothing to reclaim here.")?; + return Ok(()); + } + if dry_run { + log::warning(format!( + "dry-run: would delete {} dir(s), reclaiming {}", + targets.len(), + human_bytes(total), + ))?; + return Ok(()); + } + + let go = cliclack::confirm(format!( + "Delete {} target dir(s) and reclaim {}? Each project rebuilds from scratch next time.", + targets.len(), + human_bytes(total), + )) + .initial_value(false) + .interact()?; + if !go { + log::info("Left them in place.")?; + return Ok(()); + } + + let mut freed = 0u64; + let mut removed = 0usize; + for t in &targets { + let size = system::dir_size_bytes(t).unwrap_or(0); + match std::fs::remove_dir_all(t) { + Ok(()) => { + freed += size; + removed += 1; + } + Err(e) => { + let _ = log::warning(format!("skip {}: {e}", ui::tildify(t))); + } + } + } + log::success(format!( + "Removed {removed} target dir(s), reclaimed {}", + style(human_bytes(freed)).green().bold(), + ))?; + Ok(()) +} + +/// Whether `dir` is the configured central target dir (or sits inside it). The purge +/// must spare it even when it falls within the chosen scope, or it would wipe the very +/// dir builds were just pointed at. +fn is_protected(dir: &Path, protected: Option<&Path>) -> bool { + match (protected, std::fs::canonicalize(dir).ok()) { + (Some(p), Some(d)) => d == p || d.starts_with(p), + _ => false, + } +} + +/// Ask which candidate directory to act on. A single option or a `--yes` run takes +/// the narrowest scope (the project dir) without prompting. +fn pick_dir(candidates: &[PathBuf], yes: bool, prompt: &str) -> Result { + if yes || candidates.len() == 1 { + return Ok(candidates[0].clone()); + } + let mut menu = select(prompt); + for (i, dir) in candidates.iter().enumerate() { + let hint = if i == 0 { "this project" } else { "recursive" }; + menu = menu.item(dir.clone(), ui::tildify(dir), hint); + } + menu.interact() + .map_err(|e| anyhow!("interactive prompt needs a TTY; use --yes: {e}")) +} + +fn sweep_runspec(dir: &Path, time_days: u32, recursive: bool) -> RunSpec { + let mut args = vec!["sweep".into(), "--time".into(), time_days.to_string()]; + if recursive { + args.push("--recursive".into()); + } + RunSpec { + program: "cargo".into(), + args, + cwd: Some(dir.to_path_buf()), + } +} + +/// Total bytes of the cargo target dirs under `dir`, the only thing cargo-sweep can +/// reclaim. Sizing just the targets (not the whole selected tree) keeps a wide, +/// recursive sweep root from walking unrelated source and VCS files. Shown via a +/// spinner since a wide root can hold many targets. +fn measure_targets(dir: &Path, label: &str) -> u64 { + let sp = spinner(); + sp.start(label); + let targets = system::cargo_target_dirs(dir); + let bytes: u64 = targets + .iter() + .filter_map(|t| system::dir_size_bytes(t)) + .sum(); + sp.stop(format!( + "{label}: {} across {} target dir(s)", + human_bytes(bytes), + targets.len() + )); + bytes +}