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()),
        }
    }
}