use std::collections::BTreeSet; use std::ffi::OsString; use std::path::PathBuf; use anyhow::Result; use clap::{Args, Parser, Subcommand}; use crate::age::MinAge; use crate::clean::{Grunk, Held, Plan}; use crate::config::{self, Config}; use crate::discovery; use crate::fsutil::{expand_tilde, human_size, measure}; use crate::policy::{Intent, Policy, Selection}; use crate::progress::{Reporter, plural}; use crate::reclaim::Category; /// How many items `status` lists before it stops, absent `--all`. const DEFAULT_DETAIL_LIMIT: usize = 10; #[derive(Debug, Parser)] #[command( name = "cargo-grunk", bin_name = "cargo grunk", version, about = "Manage cargo's disk usage, in the cargo home and in project target directories", long_about = "Manage cargo's disk usage.\n\n\ Grunk deletes only what can be regenerated: build output, unpacked \ crate sources, git checkouts, and downloaded archives. Sources are \ never touched. Nothing is deleted until it has gone unused for longer \ than the configured age threshold." )] pub struct Cli { #[command(subcommand)] command: Command, } #[derive(Debug, Subcommand)] enum Command { /// Show what could be reclaimed, without deleting anything. Status { #[command(flatten)] scope: Scope, /// List every item rather than just the largest few. #[arg(long)] all: bool, }, /// Delete everything that has gone unused for longer than the age threshold. Clean { #[command(flatten)] scope: Scope, /// Report what would be deleted, then delete nothing. #[arg(long)] dry_run: bool, }, /// Show the projects grunk manages and how it found each one. List, /// Search directories for cargo projects and manage the ones found. /// /// This is the only command that goes looking. It walks the directories /// given, which on a large source tree means a great many files, and writes /// what it finds into the config so that no other command has to walk /// anything. Run it when you have new projects worth managing. Scan { /// Directories to search. #[arg(required = true)] paths: Vec, /// Report what would be managed, then change nothing. #[arg(long)] dry_run: bool, }, /// Manage a project grunk would not otherwise find. Add { /// The project directory. Defaults to the current directory. #[arg(default_value = ".")] path: PathBuf, }, /// Stop managing a project that was added with `add`. Remove { /// The project directory. Defaults to the current directory. #[arg(default_value = ".")] path: PathBuf, }, } /// The arguments shared by every command that looks at reclaimable data. #[derive(Debug, Args)] struct Scope { /// Limit to a category. Repeatable; defaults to all of them. #[arg(long = "category", value_name = "CATEGORY")] categories: Vec, /// Override the configured age threshold, e.g. "3d" or "12h". #[arg(long, value_name = "DURATION")] min_age: Option, } impl Scope { fn selection(&self) -> Selection { let categories = if self.categories.is_empty() { Category::ALL.into_iter().collect() } else { self.categories.iter().copied().collect::>() }; Selection { categories, min_age: self.min_age, } } } /// Parse arguments, whether invoked as `cargo grunk ...` or `cargo-grunk ...`. /// /// Cargo runs an external subcommand with the subcommand name re-inserted as /// the first argument, so `cargo grunk clean` reaches us as /// `cargo-grunk grunk clean`. Dropping that argument lets the binary behave /// identically under both names. pub fn parse() -> Cli { let mut args: Vec = std::env::args_os().collect(); if args.get(1).is_some_and(|arg| arg == "grunk") { args.remove(1); } Cli::parse_from(args) } impl Cli { pub fn run(self) -> Result<()> { match self.command { Command::Status { scope, all } => status(&scope, all), Command::Clean { scope, dry_run } => clean(&scope, dry_run), Command::List => list(), Command::Scan { paths, dry_run } => scan(&paths, dry_run), Command::Add { path } => add(&path), Command::Remove { path } => remove(&path), } } } fn status(scope: &Scope, all: bool) -> Result<()> { let config = Config::load()?; let grunk = Grunk::new(&config)?; // A person is reading this, so measure everything, including what stays. let policy = Policy::new(&config, &scope.selection(), Intent::Survey); let plan = grunk.plan(&policy, &Reporter::to_stderr())?; print_warnings(&plan); // Only when nothing turned up at all. Data that was found and kept is not // a discovery problem, and saying so would send the reader off to fix a // config that is working. if plan.candidates.is_empty() && plan.retained.is_empty() { println!("Nothing reclaimable found."); print_discovery_hint(&grunk); return Ok(()); } print_category_table(&plan); print_details(&plan, all); if all { print_retained(&plan); } let eligible = plan.eligible_total(); let retained = plan.retained_total(); println!(); println!( "{} in {} items can be reclaimed now.", human_size(eligible.bytes), eligible.count ); if retained.count > 0 { println!("{} are held back for being too new.", describe(retained)); } Ok(()) } /// Describe a held tally, saying plainly when the bytes were never counted. /// /// A clean does not measure what it keeps, so the honest report is a count and /// a pointer at the command that will do the work — not a total that silently /// omits the unmeasured and reads as though it were exact. fn describe(held: Held) -> String { match held.bytes { Some(bytes) => format!("{} in {} items", human_size(bytes), held.count), None => format!("{} items", held.count), } } fn clean(scope: &Scope, dry_run: bool) -> Result<()> { let config = Config::load()?; let mut grunk = Grunk::new(&config)?; // Nothing here reports the size of what is kept, so do not go and find it // out: that walk is the whole cost of a clean that has nothing to do. let progress = Reporter::to_stderr(); let policy = Policy::new(&config, &scope.selection(), Intent::Clean); let plan = grunk.plan(&policy, &progress)?; print_warnings(&plan); let eligible = plan.eligible_total(); if eligible.count == 0 { let retained = plan.retained_total(); println!("Nothing to clean."); if retained.count > 0 { println!( "{} are held back for being too new; \ `cargo grunk status` measures them.", describe(retained) ); } return Ok(()); } if dry_run { print_category_table(&plan); print_details(&plan, false); println!(); println!( "Would reclaim {} in {} items. Nothing was deleted (--dry-run).", human_size(eligible.bytes), eligible.count ); return Ok(()); } let done = grunk.execute(&plan, &progress)?; // A breakdown only when there is something to break down. With one category // it says exactly what the total below says, in a shape that apes the // progress lines above — three statements of one fact. if done.by_category.len() > 1 { println!("{:<15} {:>6} {:>11}", "CATEGORY", "ITEMS", "RECLAIMED"); for (category, removed) in &done.by_category { println!( "{:<15} {:>6} {:>11}", category.to_string(), removed.count, human_size(removed.bytes) ); } println!(); } println!( "Reclaimed {} in {} items.", human_size(done.total.bytes), done.total.count ); Ok(()) } fn list() -> Result<()> { let grunk = Grunk::new(&Config::load()?)?; let projects = grunk.projects(); if projects.is_empty() { println!("No projects found."); print_discovery_hint(&grunk); return Ok(()); } let width = projects .iter() .map(|p| p.root.display().to_string().len()) .max() .unwrap_or(0); println!("{} projects:", projects.len()); for project in projects { let sources = project .found_by .iter() .map(|kind| kind.to_string()) .collect::>() .join(", "); let built = if project.target.exists() { "" } else { " (not built)" }; println!( " {: Result<()> { let config_path = Config::path()?; let grunk = Grunk::new(&Config::load()?)?; let managed: BTreeSet = grunk .projects() .iter() .map(|project| project.target.clone()) .collect(); let progress = Reporter::to_stderr(); let roots: Vec = paths.iter().map(|path| expand_tilde(path)).collect(); // The search is the slowest thing grunk does — a whole source tree, which // on a big one is a million files. Say so before starting, not after. progress.phase("Searching:"); progress.start( roots .iter() .map(|root| root.display().to_string()) .collect::>() .join(", "), ); let found = discovery::search(roots)?; progress.finish(format_args!( "{} {} found", found.projects.len(), plural(found.projects.len(), "project", "projects") )); for warning in &found.warnings { eprintln!("warning: {warning}"); } let candidates: Vec<_> = found .projects .into_iter() .filter(|project| !managed.contains(&project.target)) .collect(); if !candidates.is_empty() { progress.phase(format_args!( "Measuring {} new {}:", candidates.len(), plural(candidates.len(), "project", "projects") )); } let mut fresh: Vec<_> = candidates .into_iter() .map(|project| { // Worth the walk: the size is what tells you whether a project is // worth managing, and this is a command you run rarely. progress.start(project.root.display()); let size = measure(&project.target).map(|m| m.size).unwrap_or(0); progress.finish(human_size(size)); (project, size) }) .collect(); fresh.sort_by(|(a, sa), (b, sb)| sb.cmp(sa).then(a.root.cmp(&b.root))); if fresh.is_empty() { println!("Found no projects that are not already managed."); return Ok(()); } for (project, size) in &fresh { println!(" {:>10} {}", human_size(*size), project.root.display()); } let total: u64 = fresh.iter().map(|(_, size)| size).sum(); println!(); if dry_run { println!( "Would manage {} projects ({} in target directories). \ Nothing was changed (--dry-run).", fresh.len(), human_size(total) ); return Ok(()); } let roots: Vec = fresh .iter() .map(|(project, _)| project.root.clone()) .collect(); let added = config::add_projects(&config_path, &roots)?; println!( "Managing {} projects ({} in target directories); added to {}.", added.len(), human_size(total), config_path.display() ); Ok(()) } fn add(path: &PathBuf) -> Result<()> { let config_path = Config::path()?; if config::add_project(&config_path, path)? { println!("Added {} to {}.", path.display(), config_path.display()); } else { println!("{} is already managed.", path.display()); } Ok(()) } fn remove(path: &PathBuf) -> Result<()> { let config_path = Config::path()?; if config::remove_project(&config_path, path)? { println!("Removed {} from {}.", path.display(), config_path.display()); } else { println!( "{} was not in {}; it may be found by another source, \ which `cargo grunk list` will show.", path.display(), config_path.display() ); } Ok(()) } fn print_warnings(plan: &Plan) { for warning in &plan.warnings { eprintln!("warning: {warning}"); } } /// Point at the two ways to widen discovery, when it has come up short. fn print_discovery_hint(grunk: &Grunk) { println!(); println!( "Grunk manages projects that {} records you having installed from a \ path, plus any you have added yourself.", grunk.cargo_home().root().join(".crates.toml").display() ); println!( "`cargo grunk scan ~/src` searches a directory and manages what it \ finds; `cargo grunk add .` manages one project." ); } fn print_category_table(plan: &Plan) { println!( "{:<15} {:>6} {:>11} {:>11} {:>9} {}", "CATEGORY", "ITEMS", "RECLAIMABLE", "TOO NEW", "MIN AGE", "RESTORED BY" ); for (category, summary) in plan.by_category() { let too_new = match (summary.retained.count, summary.retained.bytes) { (0, _) => "-".into(), (_, Some(bytes)) => human_size(bytes), // Counted but not measured, which a clean says openly rather than // printing a total that omits them. (count, None) => format!("{count} items"), }; println!( "{:<15} {:>6} {:>11} {:>11} {:>9} {}", category.to_string(), summary.count(), human_size(summary.reclaimable.bytes), too_new, summary.threshold.to_string(), category.regeneration().describe(), ); } } /// List the individual items, biggest first — the answer to "where did it go?". fn print_details(plan: &Plan, all: bool) { let eligible: Vec<_> = plan.candidates.iter().collect(); if eligible.is_empty() { return; } let limit = if all { eligible.len() } else { DEFAULT_DETAIL_LIMIT.min(eligible.len()) }; println!(); println!("Largest reclaimable items:"); for candidate in &eligible[..limit] { println!( " {:>10} {:>4}d {}", human_size(candidate.item.size), candidate.age.as_secs() / 86_400, candidate.item.path.display() ); } let hidden = eligible.len() - limit; if hidden > 0 { println!(" ... and {hidden} more; --all to list them."); } } /// List what is being held back, so "too new" names names. /// /// Only under `--all`: it answers "why is this still here?", which is a /// question worth asking but not worth printing every time. fn print_retained(plan: &Plan) { if plan.retained.is_empty() { return; } println!(); println!("Held back for being too new:"); for retained in &plan.retained { match retained.measured { Some(measured) => { let age = measured .newest .elapsed() .map(|age| format!("{:>4}d", age.as_secs() / 86_400)) .unwrap_or_else(|_| " ?d".into()); println!( " {:>10} {age} {}", human_size(measured.size), retained.path.display() ); } None => println!(" {:>10} - {}", "unmeasured", retained.path.display()), } } }