cli.rs
raw
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<PathBuf>,
/// 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<Category>,
/// Override the configured age threshold, e.g. "3d" or "12h".
#[arg(long, value_name = "DURATION")]
min_age: Option<MinAge>,
}
impl Scope {
fn selection(&self) -> Selection {
let categories = if self.categories.is_empty() {
Category::ALL.into_iter().collect()
} else {
self.categories.iter().copied().collect::<BTreeSet<_>>()
};
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<OsString> = 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::<Vec<_>>()
.join(", ");
let built = if project.target.exists() {
""
} else {
" (not built)"
};
println!(
" {:<width$} {sources}{built}",
project.root.display(),
width = width
);
}
Ok(())
}
/// Search for projects and add the ones not already managed.
///
/// Already-managed projects are skipped rather than reported, so running this
/// again over the same tree only tells you about what is new.
fn scan(paths: &[PathBuf], dry_run: bool) -> Result<()> {
let config_path = Config::path()?;
let grunk = Grunk::new(&Config::load()?)?;
let managed: BTreeSet<PathBuf> = grunk
.projects()
.iter()
.map(|project| project.target.clone())
.collect();
let progress = Reporter::to_stderr();
let roots: Vec<PathBuf> = 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::<Vec<_>>()
.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<PathBuf> = 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()),
}
}
}