carwash 0.4.0

Reclaim disk space across every project on your machine: finds build outputs, dependency installs and caches for 40+ ecosystems, then cleans, updates and runs tasks from one TUI
//! `carwash caches`: per-user caches outside projects.

use super::size_style;
use crate::cli::{CachesAction, CachesArgs};
use crate::context::Context;
use crate::table::{Align, Cell, Table};
use anstyle::{AnsiColor, Style};
use anyhow::{Context as _, Result, bail};
use carwash_core::cache::Fingerprint;
use carwash_core::caches::{self, GlobalCache};
use carwash_core::clean::{CleanItem, CleanOptions, DeleteMode};
use carwash_core::history::{self, Record};
use carwash_core::paths::Dirs;
use carwash_core::{ArtifactId, ArtifactKind, Cancel, fmt};
use indicatif::{ProgressBar, ProgressStyle};
use std::io::{BufRead, IsTerminal, Write};
use std::process::{Command, ExitCode, Stdio};
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;

/// Caches present on this machine, with user overrides from `caches.toml`.
pub fn present(dirs: Option<&Dirs>) -> Result<Vec<GlobalCache>> {
    let specs = match dirs.map(Dirs::caches_file) {
        Some(path) if path.exists() => {
            let text = std::fs::read_to_string(&path)
                .with_context(|| format!("cannot read {}", path.display()))?;
            caches::specs_with_overrides(&text)
                .map_err(anyhow::Error::msg)
                .with_context(|| format!("invalid caches in {}", path.display()))?
        }
        _ => caches::builtin_specs(),
    };
    let home = carwash_core::paths::home().context("cannot find the home directory")?;
    Ok(caches::discover(&specs, &home))
}

/// Measures every cache, a few at a time, reporting progress on stderr.
pub fn measure(ctx: &Context, caches: &mut [GlobalCache], show_progress: bool) {
    let bar = if show_progress && std::io::stderr().is_terminal() {
        let bar = ProgressBar::new_spinner();
        bar.set_style(
            ProgressStyle::with_template("{spinner:.cyan} {msg}").expect("valid template"),
        );
        bar.enable_steady_tick(Duration::from_millis(80));
        bar
    } else {
        ProgressBar::hidden()
    };
    let done = AtomicUsize::new(0);
    let total = caches.len();
    let slots: Vec<Mutex<&mut GlobalCache>> = caches.iter_mut().map(Mutex::new).collect();
    let next = AtomicUsize::new(0);
    std::thread::scope(|scope| {
        for _ in 0..4.min(total) {
            scope.spawn(|| {
                while let Some(slot) = slots.get(next.fetch_add(1, Ordering::Relaxed)) {
                    let mut cache = slot.lock().expect("cache slot");
                    cache.fingerprint = Fingerprint::of(&cache.path);
                    cache.size = Some(ctx.engine.measure_path(&cache.path, &Cancel::new()));
                    done.fetch_add(1, Ordering::Relaxed);
                }
            });
        }
        while done.load(Ordering::Relaxed) < total {
            bar.set_message(format!(
                "Measuring caches ({}/{total})",
                done.load(Ordering::Relaxed)
            ));
            std::thread::sleep(Duration::from_millis(80));
        }
    });
    bar.finish_and_clear();
    remember(ctx, caches);
}

/// Stores measured sizes in the size cache, where the TUI's Caches tab finds them.
fn remember(ctx: &Context, caches: &[GlobalCache]) {
    ctx.update_size_cache(|sizes| {
        for cache in caches {
            if let Some(size) = cache.size {
                sizes.insert_with(cache.path.clone(), size, cache.fingerprint);
            }
        }
    });
}

fn action(cache: &GlobalCache) -> (String, Style) {
    let dim = Style::new().dimmed();
    match (&cache.prune, cache.prune_task(), cache.deletable) {
        (Some(_), Some(task), _) => (task.command_line(), Style::new()),
        (Some(argv), None, true) => (format!("delete ({} not installed)", argv[0]), dim),
        (Some(argv), None, false) => (
            format!("needs `{}`", argv[0]),
            AnsiColor::Yellow.on_default(),
        ),
        (None, _, true) => ("delete".into(), Style::new()),
        (None, _, false) => ("none".into(), dim),
    }
}

pub fn run(ctx: &Context, args: &CachesArgs) -> Result<ExitCode> {
    let mut caches = present(ctx.dirs.as_ref())?;
    match &args.action {
        None => list(ctx, &mut caches, args.json),
        Some(CachesAction::Clean {
            ids,
            yes,
            dry_run,
            delete,
        }) => clean(ctx, &mut caches, ids, *yes, *dry_run, *delete),
    }
}

fn list(ctx: &Context, caches: &mut [GlobalCache], json: bool) -> Result<ExitCode> {
    measure(ctx, caches, !json);
    caches.sort_by_key(|c| std::cmp::Reverse(c.size.map_or(0, |s| s.on_disk)));
    let mut out = anstream::stdout().lock();
    if json {
        serde_json::to_writer_pretty(&mut out, &caches)?;
        writeln!(out)?;
        return Ok(ExitCode::SUCCESS);
    }
    if caches.is_empty() {
        writeln!(out, "No known caches found.")?;
        return Ok(ExitCode::SUCCESS);
    }
    let mut table = Table::new(&[
        ("SIZE", Align::Right),
        ("ID", Align::Left),
        ("CACHE", Align::Left),
        ("CLEAN WITH", Align::Left),
    ]);
    let mut total = 0;
    for cache in caches.iter() {
        let bytes = cache.size.map_or(0, |s| s.on_disk);
        total += bytes;
        let (action, style) = action(cache);
        table.push(vec![
            Cell::styled(fmt::bytes(bytes), size_style(bytes)),
            Cell::styled(cache.id.clone(), Style::new().dimmed()),
            Cell::new(cache.name.clone()),
            Cell::styled(action, style),
        ]);
    }
    table.write(&mut out)?;
    let bold = Style::new().bold();
    writeln!(
        out,
        "\n{}{}{} in {} caches. Clean some with `carwash caches clean <ID>...`.",
        bold.render(),
        fmt::bytes(total),
        bold.render_reset(),
        caches.len()
    )?;
    Ok(ExitCode::SUCCESS)
}

fn clean(
    ctx: &Context,
    caches: &mut [GlobalCache],
    ids: &[String],
    yes: bool,
    dry_run: bool,
    force_delete: bool,
) -> Result<ExitCode> {
    let mut selected: Vec<GlobalCache> = Vec::new();
    for id in ids {
        let matching: Vec<&GlobalCache> = caches
            .iter()
            .filter(|c| &c.id == id || c.id.starts_with(&format!("{id}/")))
            .collect();
        if matching.is_empty() {
            bail!("no cache `{id}` on this machine (see `carwash caches`)");
        }
        selected.extend(matching.into_iter().cloned());
    }
    measure(ctx, &mut selected, true);

    let mut out = anstream::stdout().lock();
    let mut plan: Vec<(GlobalCache, Option<carwash_core::tasks::Task>)> = Vec::new();
    for cache in selected {
        let task = if force_delete {
            None
        } else {
            cache.prune_task()
        };
        if task.is_none() && !cache.deletable {
            let program = cache.prune.as_ref().map_or("its tool", |p| p[0].as_str());
            bail!(
                "{} can only be cleaned with `{program}`, which is not installed",
                cache.name
            );
        }
        let how = task
            .as_ref()
            .map_or_else(|| "delete".to_owned(), |t| t.command_line());
        writeln!(
            out,
            "{:>9}  {}  {}{how}{}",
            fmt::bytes(cache.size.map_or(0, |s| s.on_disk)),
            cache.name,
            Style::new().dimmed().render(),
            Style::new().dimmed().render_reset()
        )?;
        plan.push((cache, task));
    }
    if dry_run {
        writeln!(out, "Dry run: nothing was changed.")?;
        return Ok(ExitCode::SUCCESS);
    }
    if !yes {
        if !std::io::stdin().is_terminal() {
            bail!(
                "refusing to clean without confirmation in a non-interactive session; pass --yes"
            );
        }
        write!(out, "Clean {} caches? [y/N] ", plan.len())?;
        out.flush()?;
        let mut answer = String::new();
        std::io::stdin().lock().read_line(&mut answer)?;
        if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") {
            writeln!(out, "Nothing changed.")?;
            return Ok(ExitCode::SUCCESS);
        }
    }
    drop(out);

    let mut failures = 0;
    let mut records = Vec::new();
    let mut cleaned = Vec::new();
    for (mut cache, task) in plan {
        let before = cache.size.map_or(0, |s| s.on_disk);
        let ok = match task {
            Some(task) => {
                anstream::println!("▶ {}", task.command_line());
                let status = Command::new(&task.program)
                    .args(&task.args)
                    .current_dir(&task.cwd)
                    .stdin(Stdio::inherit())
                    .status();
                matches!(status, Ok(s) if s.success())
            }
            None => {
                let Some(parent) = cache.path.parent() else {
                    continue;
                };
                let report = ctx.engine.clean(
                    &[CleanItem {
                        id: ArtifactId(0),
                        path: cache.path.clone(),
                        expected_bytes: before,
                    }],
                    &CleanOptions {
                        mode: DeleteMode::Permanent,
                        allowed_roots: vec![parent.to_path_buf()],
                        dry_run: false,
                    },
                    &Cancel::new(),
                    &|_| {},
                );
                report.removed == 1
            }
        };
        if ok {
            cache.fingerprint = Fingerprint::of(&cache.path);
            let after = if cache.path.exists() {
                ctx.engine.measure_path(&cache.path, &Cancel::new())
            } else {
                carwash_core::Size::default()
            };
            let freed = before.saturating_sub(after.on_disk);
            anstream::println!("  freed {} from {}", fmt::bytes(freed), cache.name);
            records.push(Record::now(
                cache.path.clone(),
                freed,
                ArtifactKind::Cache,
                cache.ecosystem.clone(),
                DeleteMode::Permanent,
            ));
            cache.size = Some(after);
            cleaned.push(cache);
        } else {
            failures += 1;
            anstream::eprintln!("  failed to clean {}", cache.name);
        }
    }
    remember(ctx, &cleaned);
    if let Some(dirs) = &ctx.dirs
        && let Err(error) = history::append(&dirs.history_file(), &records)
    {
        tracing::warn!(%error, "cannot write history");
    }
    let total: u64 = records.iter().map(|r| r.bytes).sum();
    anstream::println!("Freed {} in total.", fmt::bytes(total));
    Ok(if failures == 0 {
        ExitCode::SUCCESS
    } else {
        ExitCode::FAILURE
    })
}