cargo-reclaim 0.2.1

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

use cargo_reclaim::{
    ApplyEntryResult, ApplyEntryStatus, ApplyReport, SchedulerPlatform, default_state_dir,
    execute_persisted_plan_apply, load_plan_from_path, validate_persisted_plan_for_apply,
};

use super::{CliError, OutputFormat, next_path};

const TERMINAL_NOTABLE_ENTRY_LIMIT: usize = 20;

#[derive(Debug)]
pub(super) struct ApplyCommand {
    pub(super) plan_path: PathBuf,
    pub(super) output_format: OutputFormat,
    pub(super) execute: bool,
}

pub(super) fn parse_apply_command(
    args: impl IntoIterator<Item = OsString>,
) -> Result<ApplyCommand, CliError> {
    let mut plan_path = None;
    let mut output_format = OutputFormat::Terminal;
    let mut execute = false;
    let mut args = args.into_iter();

    while let Some(arg) = args.next() {
        let Some(arg_text) = arg.as_os_str().to_str() else {
            return Err(CliError::Usage(
                "apply options must be valid UTF-8".to_string(),
            ));
        };

        match arg_text {
            "--json" => output_format = OutputFormat::Json,
            "--yes" => execute = true,
            "--plan" => plan_path = Some(next_plan_path(&mut args)?),
            value if value.starts_with("--plan=") => {
                plan_path = Some(validate_plan_path(PathBuf::from(
                    &value["--plan=".len()..],
                ))?);
            }
            "--last" | "last" => {
                return Err(CliError::Usage(
                    "implicit `last` plans are not supported; pass `--plan <path>`".to_string(),
                ));
            }
            value if value.starts_with('-') => {
                return Err(CliError::Usage(format!("unknown apply option `{value}`")));
            }
            value => {
                return Err(CliError::Usage(format!(
                    "unexpected apply argument `{value}`; pass `--plan <path>`"
                )));
            }
        }
    }

    let Some(plan_path) = plan_path else {
        return Err(CliError::Usage(
            "apply requires an explicit `--plan <path>`".to_string(),
        ));
    };

    Ok(ApplyCommand {
        plan_path,
        output_format,
        execute,
    })
}

pub(super) fn run_apply(
    command: &ApplyCommand,
    output: &mut impl Write,
) -> Result<ExitCode, CliError> {
    let document = load_plan_from_path(&command.plan_path)?;
    let report = if command.execute {
        execute_persisted_plan_apply(&document, SystemTime::now())?
    } else {
        validate_persisted_plan_for_apply(&document, SystemTime::now())?
    };
    let exit_code = if report.totals.failed_count == 0 {
        ExitCode::SUCCESS
    } else {
        ExitCode::FAILURE
    };
    write_apply_report(output, &report, command.output_format)?;
    Ok(exit_code)
}

fn next_plan_path(args: &mut impl Iterator<Item = OsString>) -> Result<PathBuf, CliError> {
    let path = next_path(args, "--plan")?;
    validate_plan_path(path)
}

fn validate_plan_path(path: PathBuf) -> Result<PathBuf, CliError> {
    if path.as_os_str() == "last" {
        return Err(CliError::Usage(
            "implicit `last` plans are not supported; pass an explicit plan path".to_string(),
        ));
    }
    Ok(path)
}

pub(super) fn write_apply_report(
    output: &mut impl Write,
    report: &ApplyReport,
    format: OutputFormat,
) -> Result<(), CliError> {
    write_apply_report_with_command(output, report, format, "apply")
}

pub(super) fn write_apply_report_with_command(
    output: &mut impl Write,
    report: &ApplyReport,
    format: OutputFormat,
    command_label: &str,
) -> Result<(), CliError> {
    if format == OutputFormat::Json {
        write_apply_json_report(output, report, command_label)?;
        return Ok(());
    }

    let report_path = save_terminal_apply_report(report, command_label).ok();

    if report.dry_run {
        writeln!(output, "cargo-reclaim {command_label} validation")?;
        writeln!(output, "validation only; no files were deleted or modified")?;
    } else {
        writeln!(output, "cargo-reclaim {command_label} execution")?;
        writeln!(
            output,
            "execution mode; only freshly revalidated delete entries were removed"
        )?;
    }
    writeln!(output, "plan id: {}", report.plan_id.as_str())?;
    writeln!(output, "entries: {}", report.totals.entry_count)?;
    writeln!(
        output,
        "delete candidates: {}",
        report.totals.delete_candidate_count
    )?;
    writeln!(output, "would delete: {}", report.totals.would_delete_count)?;
    writeln!(
        output,
        "would delete bytes: {}",
        report.totals.would_delete_bytes
    )?;
    writeln!(output, "deleted: {}", report.totals.applied_count)?;
    writeln!(output, "deleted bytes: {}", report.totals.applied_bytes)?;
    writeln!(output, "delete failures: {}", report.totals.failed_count)?;
    writeln!(output, "skipped: {}", report.totals.skipped_count)?;
    writeln!(output, "stale skips: {}", report.totals.stale_skip_count)?;

    if let Some(path) = report_path {
        writeln!(output, "full report: {}", path.display())?;
    } else {
        writeln!(
            output,
            "full report: unavailable; rerun with --json to capture every entry"
        )?;
    }
    write_terminal_notable_entries(output, report)?;

    Ok(())
}

fn save_terminal_apply_report(
    report: &ApplyReport,
    command_label: &str,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let report_dir = default_state_dir(current_scheduler_platform()).join("reports");
    fs::create_dir_all(&report_dir)?;
    let path = report_dir.join(report_file_name(report, command_label));
    let mut file = fs::File::create(&path)?;
    serde_json::to_writer_pretty(&mut file, &apply_json_document(report, command_label))?;
    writeln!(file)?;
    Ok(path)
}

fn report_file_name(report: &ApplyReport, command_label: &str) -> String {
    let timestamp_millis = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_millis())
        .unwrap_or(0);
    let mode = if report.dry_run {
        "validation"
    } else {
        "execution"
    };
    let plan_id = sanitize_file_component(report.plan_id.as_str());
    format!("{timestamp_millis}-{command_label}-{mode}-{plan_id}.json")
}

fn sanitize_file_component(value: &str) -> String {
    value
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
                ch
            } else {
                '-'
            }
        })
        .collect()
}

fn current_scheduler_platform() -> SchedulerPlatform {
    if cfg!(target_os = "macos") {
        SchedulerPlatform::Launchd
    } else if cfg!(target_os = "windows") {
        SchedulerPlatform::TaskScheduler
    } else {
        SchedulerPlatform::SystemdUser
    }
}

fn write_terminal_notable_entries(
    output: &mut impl Write,
    report: &ApplyReport,
) -> Result<(), CliError> {
    let notable_entries = report
        .entries
        .iter()
        .filter(|entry| is_terminal_notable(entry))
        .collect::<Vec<_>>();
    let hidden_count = report.entries.len().saturating_sub(notable_entries.len());
    let shown_count = notable_entries.len().min(TERMINAL_NOTABLE_ENTRY_LIMIT);
    let omitted_notable_count = notable_entries.len().saturating_sub(shown_count);

    if shown_count > 0 {
        writeln!(output, "notable entries:")?;
        for entry in notable_entries
            .into_iter()
            .take(TERMINAL_NOTABLE_ENTRY_LIMIT)
        {
            writeln!(
                output,
                "{}\t{}\t{}\t{}",
                apply_status_label(entry.status),
                entry.planned_action,
                entry.path,
                entry.reason
            )?;
        }
    }

    if hidden_count > 0 || omitted_notable_count > 0 {
        writeln!(
            output,
            "entries not shown: {} preserved/unchanged, {} additional notable; use --json or open the full report for every entry",
            hidden_count, omitted_notable_count
        )?;
    }

    Ok(())
}

fn is_terminal_notable(entry: &ApplyEntryResult) -> bool {
    matches!(
        entry.status,
        ApplyEntryStatus::WouldDelete
            | ApplyEntryStatus::Deleted
            | ApplyEntryStatus::SkipStalePlan
            | ApplyEntryStatus::DeleteFailed
    )
}

fn write_apply_json_report(
    output: &mut impl Write,
    report: &ApplyReport,
    command_label: &str,
) -> Result<(), CliError> {
    let document = apply_json_document(report, command_label);
    serde_json::to_writer(&mut *output, &document)?;
    writeln!(output)?;
    Ok(())
}

fn apply_json_document(report: &ApplyReport, command_label: &str) -> serde_json::Value {
    let entries = report
        .entries
        .iter()
        .map(|entry| {
            serde_json::json!({
                "path": entry.path,
                "planned_action": entry.planned_action,
                "status": apply_status_label(entry.status),
                "reason": entry.reason,
                "size_bytes": entry.size_bytes,
                "deleted_bytes": entry.deleted_bytes,
            })
        })
        .collect::<Vec<_>>();
    serde_json::json!({
        "command": command_label,
        "dry_run": report.dry_run,
        "plan_id": report.plan_id.as_str(),
        "totals": {
            "entry_count": report.totals.entry_count,
            "delete_candidate_count": report.totals.delete_candidate_count,
            "would_delete_count": report.totals.would_delete_count,
            "applied_count": report.totals.applied_count,
            "failed_count": report.totals.failed_count,
            "skipped_count": report.totals.skipped_count,
            "stale_skip_count": report.totals.stale_skip_count,
            "would_delete_bytes": report.totals.would_delete_bytes,
            "applied_bytes": report.totals.applied_bytes,
        },
        "entries": entries,
    })
}

pub(super) fn apply_status_label(status: ApplyEntryStatus) -> &'static str {
    match status {
        ApplyEntryStatus::WouldDelete => "would_delete",
        ApplyEntryStatus::Deleted => "deleted",
        ApplyEntryStatus::NotPlannedForDeletion => "not_planned_for_deletion",
        ApplyEntryStatus::SkipStalePlan => "skip_stale_plan",
        ApplyEntryStatus::DeleteFailed => "delete_failed",
    }
}