cargo-reclaim 0.2.1

Safe Cargo cleanup for target directories, stale artifacts, and Cargo home caches
Documentation
use std::ffi::OsString;
use std::io::Write;
use std::path::PathBuf;
use std::process::ExitCode;

use cargo_reclaim::{InventoryOptions, ScannerOptions, load_config_from_path};
use serde_json::json;

use super::target_report::{
    TargetsDiscovery, TargetsReport, build_targets_report, evidence_label, human_bytes,
    problem_json, skip_json, target_json,
};
use super::{
    CliError, OutputFormat, inline_config_path, inline_ignore_path, inline_skip_path, next_path,
};

#[derive(Debug)]
pub(in crate::cli) struct ListCommand {
    discovery: ListDiscoveryCommand,
}

#[derive(Debug)]
struct ListDiscoveryCommand {
    roots: Vec<PathBuf>,
    output_format: OutputFormat,
    scanner_options: ScannerOptions,
    inventory_options: InventoryOptions,
    config_path: Option<PathBuf>,
    config_version: Option<u16>,
}

impl ListDiscoveryCommand {
    fn report_discovery(&self) -> TargetsDiscovery {
        TargetsDiscovery::new(
            self.roots.clone(),
            self.scanner_options.clone(),
            self.inventory_options.clone(),
            self.config_path.clone(),
            self.config_version,
        )
    }
}

pub(in crate::cli) fn parse_list_command(
    args: impl IntoIterator<Item = OsString>,
) -> Result<ListCommand, CliError> {
    Ok(ListCommand {
        discovery: parse_list_discovery(args)?,
    })
}

fn parse_list_discovery(
    args: impl IntoIterator<Item = OsString>,
) -> Result<ListDiscoveryCommand, CliError> {
    let mut roots = Vec::new();
    let mut output_format = OutputFormat::Terminal;
    let mut config_path = None;
    let mut scanner_options = ScannerOptions::default();
    let mut cli_follow_symlinks = false;
    let mut cli_allow_name_only_targets = false;
    let mut cli_cross_filesystems = false;
    let mut args = args.into_iter();

    while let Some(arg) = args.next() {
        if let Some(ignore_path) = inline_ignore_path(&arg)? {
            scanner_options.ignored_paths.push(ignore_path);
            continue;
        }
        if let Some(skip_path) = inline_skip_path(&arg)? {
            scanner_options.skipped_paths.push(skip_path);
            continue;
        }
        if let Some(path) = inline_config_path(&arg)? {
            config_path = Some(path);
            continue;
        }

        let Some(arg_text) = arg.as_os_str().to_str() else {
            roots.push(PathBuf::from(arg));
            continue;
        };

        match arg_text {
            "-h" | "--help" => return Err(CliError::Help(list_usage())),
            "--" => {
                roots.extend(args.map(PathBuf::from));
                break;
            }
            "--config" => {
                config_path = Some(next_path(&mut args, "--config")?);
            }
            "--ignore" => {
                scanner_options
                    .ignored_paths
                    .push(next_path(&mut args, "--ignore")?);
            }
            "--skip" => {
                scanner_options
                    .skipped_paths
                    .push(next_path(&mut args, "--skip")?);
            }
            "--allow-name-only-targets" => {
                scanner_options.allow_name_only_targets = true;
                cli_allow_name_only_targets = true;
            }
            "--follow-symlinks" => {
                scanner_options.follow_symlinks = true;
                cli_follow_symlinks = true;
            }
            "--cross-filesystems" => {
                scanner_options.cross_filesystems = true;
                cli_cross_filesystems = true;
            }
            "--json" => output_format = OutputFormat::Json,
            value if value.starts_with('-') => {
                return Err(CliError::Usage(format!("unknown list option `{value}`")));
            }
            _ => roots.push(PathBuf::from(arg)),
        }
    }

    let config = config_path
        .as_ref()
        .map(load_config_from_path)
        .transpose()?;
    let config_version = config.as_ref().map(|config| config.version);

    if roots.is_empty() {
        if let Some(config_roots) = config
            .as_ref()
            .filter(|config| !config.roots.is_empty())
            .map(|config| config.roots.clone())
        {
            roots = config_roots;
        } else {
            roots.push(PathBuf::from("."));
        }
    }

    if let Some(config) = config {
        let mut ignored_paths = config.ignored_paths;
        ignored_paths.extend(scanner_options.ignored_paths);
        scanner_options.ignored_paths = ignored_paths;
        let mut skipped_paths = config.skipped_paths;
        skipped_paths.extend(scanner_options.skipped_paths);
        scanner_options.skipped_paths = skipped_paths;

        if !cli_follow_symlinks && let Some(follow_symlinks) = config.scanner.follow_symlinks {
            scanner_options.follow_symlinks = follow_symlinks;
        }
        if !cli_allow_name_only_targets
            && let Some(allow_name_only_targets) = config.scanner.allow_name_only_targets
        {
            scanner_options.allow_name_only_targets = allow_name_only_targets;
        }
        if !cli_cross_filesystems && let Some(cross_filesystems) = config.scanner.cross_filesystems
        {
            scanner_options.cross_filesystems = cross_filesystems;
        }
    }

    let inventory_options = InventoryOptions {
        follow_symlinks: scanner_options.follow_symlinks,
        skipped_paths: scanner_options.skipped_paths.clone(),
        deep_target_scan: false,
        deep_directory_measurement: false,
    };

    Ok(ListDiscoveryCommand {
        roots,
        output_format,
        scanner_options,
        inventory_options,
        config_path,
        config_version,
    })
}

pub(in crate::cli) fn run_list_command(
    command: &ListCommand,
    output: &mut impl Write,
) -> Result<ExitCode, CliError> {
    let report = build_targets_report(&command.discovery.report_discovery())?;
    match command.discovery.output_format {
        OutputFormat::Terminal => write_list_terminal(output, &report)?,
        OutputFormat::Json => write_list_json(output, &report)?,
    }
    Ok(ExitCode::SUCCESS)
}

fn write_list_terminal(output: &mut impl Write, report: &TargetsReport) -> Result<(), CliError> {
    writeln!(output, "cargo-reclaim list")?;
    writeln!(output, "read-only; no files were deleted or modified")?;
    writeln!(
        output,
        "roots: {}",
        report
            .roots
            .iter()
            .map(|path| path.display().to_string())
            .collect::<Vec<_>>()
            .join(", ")
    )?;
    if let Some(config_path) = &report.config_path {
        writeln!(
            output,
            "config: {}{}",
            config_path.display(),
            report
                .config_version
                .map(|version| format!(" (version {version})"))
                .unwrap_or_default()
        )?;
    }
    writeln!(
        output,
        "targets: {} ({})",
        report.targets.len(),
        human_bytes(report.total_size_bytes)
    )?;
    if !report.problems.is_empty() {
        writeln!(output, "problems: {}", report.problems.len())?;
    }
    if !report.skipped_paths.is_empty() {
        writeln!(output, "skipped scan paths: {}", report.skipped_paths.len())?;
    }

    if !report.targets.is_empty() {
        writeln!(output)?;
        writeln!(output, "index\tsize\tbytes\tevidence\tpath")?;
        for (index, target) in report.targets.iter().enumerate() {
            writeln!(
                output,
                "{}\t{}\t{}\t{}\t{}",
                index + 1,
                human_bytes(target.size_bytes),
                target.size_bytes,
                evidence_label(&target.evidence),
                target.path.display()
            )?;
        }
    }

    if !report.problems.is_empty() {
        writeln!(output)?;
        writeln!(output, "problems")?;
        for problem in &report.problems {
            writeln!(output, "{}\t{}", problem.message, problem.path.display())?;
        }
    }

    Ok(())
}

fn write_list_json(output: &mut impl Write, report: &TargetsReport) -> Result<(), CliError> {
    serde_json::to_writer_pretty(
        &mut *output,
        &json!({
            "command": "list",
            "schema_version": 1,
            "roots": report.roots,
            "config_path": report.config_path,
            "config_version": report.config_version,
            "totals": {
                "target_count": report.targets.len(),
                "total_size_bytes": report.total_size_bytes,
                "skipped_path_count": report.skipped_paths.len(),
                "problem_count": report.problems.len(),
            },
            "targets": report.targets.iter().map(target_json).collect::<Vec<_>>(),
            "skipped_paths": report.skipped_paths.iter().map(skip_json).collect::<Vec<_>>(),
            "problems": report.problems.iter().map(problem_json).collect::<Vec<_>>(),
        }),
    )?;
    writeln!(output)?;
    Ok(())
}

fn list_usage() -> String {
    "usage: cargo-reclaim list [OPTIONS] [ROOT ...]".to_string()
}