rebecca 0.3.0

Cross-platform cleanup CLI and Rust library surface for Rebecca cleanup planning, rules, and platform adapters.
Documentation
use std::collections::BTreeSet;

use anyhow::Result;
use rebecca::core::plan::CleanupPlan;
use rebecca::core::{
    CleanupWorkflow, DEFAULT_PROJECT_ARTIFACT_MAX_DEPTH, DEFAULT_PROJECT_ARTIFACT_MIN_AGE_DAYS,
    DeleteMode, PlanRequest,
};

use crate::clean_view::{CleanPlanProjection, CleanTargetRow, ScanCacheProgressSummary};
use crate::output::{format_bytes, format_shell_command, restore_hint_suffix};
use crate::render::estimate_provenance_suffix;

pub(crate) fn print_plan(
    plan: &CleanupPlan,
    scan_cache_summary: Option<ScanCacheProgressSummary>,
) -> Result<()> {
    let projection = CleanPlanProjection::new(plan, scan_cache_summary);

    print_plan_overview(&projection);

    if !projection.largest_targets().is_empty() {
        println!();
        println!("Largest estimated targets:");
        for target in projection.largest_targets() {
            print_target_line(target, "  -");
        }
    }

    if !projection.target_groups().is_empty() {
        println!();
        println!("Target details:");
        for group in projection.target_groups() {
            println!("{} ({})", group.status_label, group.targets.len());
            for target in &group.targets {
                print_target_line(target, "  -");
            }
        }
    }

    Ok(())
}

pub(super) fn print_plan_overview(projection: &CleanPlanProjection<'_>) {
    println!("Workflow: {}", projection.workflow_title);
    println!("Cleanup mode: {}", projection.mode_label);
    println!("Targets: {}", projection.summary.total_targets);
    println!("Allowed: {}", projection.summary.allowed_targets);
    println!("Skipped: {}", projection.summary.skipped_targets);
    println!("Blocked: {}", projection.summary.blocked_targets);
    println!("Failed: {}", projection.summary.failed_targets);
    println!("Completed: {}", projection.summary.completed_targets);
    println!(
        "Estimated bytes: {} ({})",
        projection.summary.estimated_bytes,
        format_bytes(projection.summary.estimated_bytes)
    );
    println!(
        "Freed bytes: {} ({})",
        projection.summary.freed_bytes,
        format_bytes(projection.summary.freed_bytes)
    );
    println!(
        "Pending reclaim bytes: {} ({})",
        projection.summary.pending_reclaim_bytes,
        format_bytes(projection.summary.pending_reclaim_bytes)
    );
    print_plan_decision(projection);
    if !projection.issue_matrix().is_empty() {
        println!();
        println!("Issue matrix:");
        for issue in projection.issue_matrix() {
            println!(
                "- {} {}: {}, {} ({})",
                issue.status_label,
                issue.reason_label,
                issue.targets_label,
                issue.estimated_bytes,
                format_bytes(issue.estimated_bytes)
            );
        }
    }
    if !projection.warning_matrix().is_empty() {
        println!();
        println!("Warning matrix:");
        for warning in projection.warning_matrix() {
            println!(
                "- {}: {}, {} ({})",
                warning.warning,
                warning.targets_label,
                warning.estimated_bytes,
                format_bytes(warning.estimated_bytes)
            );
        }
    }
    if let Some(summary) = projection.scan_cache_summary() {
        println!(
            "Scan cache summary: {}, {}, {}, {}",
            summary.hits_label,
            summary.misses_label,
            summary.write_skipped_label,
            summary.pruned_label
        );
    }
}

fn print_plan_decision(projection: &CleanPlanProjection<'_>) {
    println!();
    println!("Decision: {}", decision_label(projection));
    println!(
        "Reclaimable now: {} ({})",
        reclaimable_now_bytes(projection),
        format_bytes(reclaimable_now_bytes(projection))
    );
    println!("Execution: {}", execution_label(projection));

    if projection.mode.is_dry_run() && projection.summary.allowed_targets > 0 {
        println!(
            "Next command: {}",
            suggested_execution_command(projection.request)
        );
        let opt_ins = required_opt_in_flags(projection.request);
        if !opt_ins.is_empty() {
            println!("Required opt-ins in next command: {}.", opt_ins.join(", "));
        }
    }

    if !projection.warning_matrix().is_empty() {
        let warnings = projection
            .warning_matrix()
            .iter()
            .map(|warning| warning.warning.as_str())
            .collect::<Vec<_>>()
            .join(", ");
        println!("Warning gates in plan: {warnings}.");
    }

    if projection.mode.is_dry_run() {
        let guidance = pre_execution_guidance(projection);
        if !guidance.is_empty() {
            println!("Resolve before execution:");
            for line in guidance {
                println!("- {line}");
            }
        }

        for hint in doctor_hints(projection) {
            println!("Doctor hint: {hint}");
        }
    }
}

fn decision_label(projection: &CleanPlanProjection<'_>) -> &'static str {
    match projection.mode {
        DeleteMode::DryRun => "preview only; no files were deleted.",
        DeleteMode::RecoverableDelete if projection.summary.failed_targets > 0 => {
            "cleanup finished with failures."
        }
        DeleteMode::RecoverableDelete if projection.summary.completed_targets > 0 => {
            "cleanup executed."
        }
        DeleteMode::RecoverableDelete => "no cleanup target was executed.",
    }
}

fn execution_label(projection: &CleanPlanProjection<'_>) -> &'static str {
    match projection.mode {
        DeleteMode::DryRun if projection.summary.allowed_targets > 0 => {
            "would move allowed targets to recoverable trash."
        }
        DeleteMode::DryRun => "no eligible target would be deleted.",
        DeleteMode::RecoverableDelete if projection.summary.pending_reclaim_bytes > 0 => {
            "moved allowed targets to recoverable trash; empty it to reclaim pending bytes."
        }
        DeleteMode::RecoverableDelete if projection.summary.completed_targets > 0 => {
            "moved allowed targets to recoverable trash."
        }
        DeleteMode::RecoverableDelete => "nothing was moved to recoverable trash.",
    }
}

fn reclaimable_now_bytes(projection: &CleanPlanProjection<'_>) -> u64 {
    match projection.mode {
        DeleteMode::DryRun => projection
            .target_groups()
            .iter()
            .find(|group| group.status_label == "allowed")
            .map(|group| {
                group
                    .targets
                    .iter()
                    .map(|target| target.estimated_bytes)
                    .sum()
            })
            .unwrap_or(0),
        DeleteMode::RecoverableDelete => projection
            .summary
            .freed_bytes
            .saturating_add(projection.summary.pending_reclaim_bytes),
    }
}

fn suggested_execution_command(request: &PlanRequest) -> String {
    let mut args = match request.workflow {
        CleanupWorkflow::Rules => vec!["clean".to_string(), "--yes".to_string()],
        CleanupWorkflow::AppLeftovers => {
            vec!["apps".to_string(), "clean".to_string(), "--yes".to_string()]
        }
        CleanupWorkflow::ProjectArtifacts => vec!["purge".to_string(), "--yes".to_string()],
    };

    match request.workflow {
        CleanupWorkflow::Rules => {
            push_repeated_option(&mut args, "--category", &request.selected_categories);
            push_repeated_option(&mut args, "--rule", &request.selected_rule_ids);
            push_risk_options(&mut args, request);
        }
        CleanupWorkflow::AppLeftovers => {}
        CleanupWorkflow::ProjectArtifacts => {
            for root in &request.project_artifact_roots {
                args.push("--root".to_string());
                args.push(root.display().to_string());
            }
            if request.project_artifact_max_depth != DEFAULT_PROJECT_ARTIFACT_MAX_DEPTH {
                args.push("--max-depth".to_string());
                args.push(request.project_artifact_max_depth.to_string());
            }
            if request.project_artifact_min_age_days != DEFAULT_PROJECT_ARTIFACT_MIN_AGE_DAYS {
                args.push("--min-age-days".to_string());
                args.push(request.project_artifact_min_age_days.to_string());
            }
            if let Some(limit) = request.project_artifact_reclaim_limit_bytes {
                args.push("--reclaim-limit-bytes".to_string());
                args.push(limit.to_string());
            }
            push_repeated_option(&mut args, "--artifact", &request.project_artifact_selectors);
        }
    }

    format_shell_command("rebecca", &args)
}

fn required_opt_in_flags(request: &PlanRequest) -> Vec<String> {
    let mut flags = Vec::new();
    if request.allow_risky {
        flags.push("--allow-risky".to_string());
    } else if request.allow_moderate {
        flags.push("--allow-moderate".to_string());
    }

    flags.extend(
        request
            .allowed_warnings
            .iter()
            .map(|warning| format!("--allow-warning {warning}")),
    );
    flags
}

fn pre_execution_guidance(projection: &CleanPlanProjection<'_>) -> Vec<String> {
    let mut guidance = BTreeSet::new();
    for issue in projection.issue_matrix() {
        if let Some(message) = issue_resolution_hint(issue.reason_label) {
            guidance.insert(format!(
                "{} {}: {message}",
                issue.status_label, issue.reason_label
            ));
        }
    }
    guidance.into_iter().collect()
}

fn issue_resolution_hint(reason_label: &str) -> Option<&'static str> {
    match reason_label {
        "safety-opt-in-required" => {
            Some("add --allow-moderate or --allow-risky after reviewing the rule.")
        }
        "warning-gate-required" => Some(
            "add the named --allow-warning flag after checking active processes and app state.",
        ),
        "safety-policy-blocked" => {
            Some("blocked by protection policy; adjust --exclude or config only if intentional.")
        }
        "safety-policy-skipped" => Some(
            "excluded by protection policy; remove the matching exclude or protection only if intentional.",
        ),
        "duplicate-target-path" => {
            Some("already covered by another target; execute the allowed parent target instead.")
        }
        "target-discovery-skipped" => {
            Some("narrow the selection or fix discovery prerequisites before executing.")
        }
        "target-discovery-failed" | "scan-failed" => {
            Some("fix the scan error or narrow the selected rules before executing.")
        }
        "scan-permission-denied" => Some(
            "grant the required OS privacy access or run rebecca doctor permissions before retrying.",
        ),
        "execution-target-missing" => {
            Some("the path disappeared; rerun the preview before executing.")
        }
        "execution-target-shadowed" => {
            Some("the path changed during validation; rerun the preview before executing.")
        }
        "project-artifact-recently-modified" => {
            Some("lower --min-age-days only if the artifact is inactive.")
        }
        "reclaim-limit-satisfied" => {
            Some("raise --reclaim-limit-bytes if more artifacts should be considered.")
        }
        "execution-failed" => Some("review the failure, fix the path or permissions, and rerun."),
        "execution-permission-denied" => {
            Some("run rebecca doctor permissions and grant OS privacy access before retrying.")
        }
        "unclassified" => Some("review target details before executing."),
        _ => None,
    }
}

fn doctor_hints(projection: &CleanPlanProjection<'_>) -> Vec<&'static str> {
    if projection
        .warning_matrix()
        .iter()
        .any(|warning| warning.warning == "active-process")
    {
        vec!["rebecca doctor active-processes"]
    } else {
        Vec::new()
    }
}

fn push_repeated_option(args: &mut Vec<String>, flag: &str, values: &[String]) {
    for value in values {
        args.push(flag.to_string());
        args.push(value.clone());
    }
}

fn push_risk_options(args: &mut Vec<String>, request: &PlanRequest) {
    if request.allow_risky {
        args.push("--allow-risky".to_string());
    } else if request.allow_moderate {
        args.push("--allow-moderate".to_string());
    }

    push_repeated_option(args, "--allow-warning", &request.allowed_warnings);
}

fn print_target_line(target: &CleanTargetRow<'_>, prefix: &str) {
    println!(
        "{prefix} {} [{}] {} bytes ({}){}{}{}{}",
        target.rule_id,
        target.path.display(),
        target.estimated_bytes,
        format_bytes(target.estimated_bytes),
        estimate_provenance_suffix(target.estimate_source, target.estimate_provenance),
        target
            .reason
            .map(|reason| format!(" ({reason})"))
            .unwrap_or_default(),
        warning_suffix(target.warnings),
        restore_hint_suffix(target.restore_hint)
    );
}

fn warning_suffix(warnings: &[String]) -> String {
    if warnings.is_empty() {
        String::new()
    } else {
        format!(" [warnings: {}]", warnings.join(", "))
    }
}