rebecca 0.1.1

Windows-first cleanup CLI and Rust library surface for Rebecca cleanup planning, rules, and platform adapters.
Documentation
use std::num::NonZeroUsize;

use anyhow::Result;
use rebecca::core::applications::ApplicationDiscovery;
#[cfg(debug_assertions)]
use rebecca::core::applications::{
    InstalledApplication, NoopApplicationDiscovery, StaticApplicationDiscovery, SteamInstallation,
};
use rebecca::core::config::{AppPaths, load_app_paths};
use rebecca::core::history::HistoryStore;
use rebecca::core::plan::{CleanupIssueSummary, CleanupTarget, CleanupTargetIssueReason};
use serde::Serialize;

use crate::cli::OutputMode;
use crate::history_view::{HistoryAggregateSummary, HistoryProjection, HistoryRunHighlight};
use crate::output::{format_bytes, format_issue_matrix_entry, restore_hint_suffix};

#[derive(Debug, Serialize)]
struct PermissionDiagnostic<'a> {
    platform: &'a str,
    platform_supported: bool,
    cleanup_execution_supported: bool,
    privilege_level: &'a str,
    suggested_action: &'a str,
}

fn config_paths_json(paths: &AppPaths) -> serde_json::Value {
    serde_json::json!({
        "config_dir": &paths.config_dir,
        "config_file": &paths.config_file,
        "state_dir": &paths.state_dir,
        "cache_dir": &paths.cache_dir,
        "history_file": &paths.history_file,
        "storage": paths.storage_entries(),
    })
}

pub fn print_history(output_mode: OutputMode, limit: Option<NonZeroUsize>) -> Result<()> {
    let paths = load_app_paths()?;
    let store = HistoryStore::new(paths.history_file);
    let history = HistoryProjection::new(store.load()?, limit);

    if output_mode.is_json() {
        return crate::output::print_success("history", "history-list", history.entries());
    }

    if output_mode.is_ndjson() {
        return crate::output::print_success_event("history", "history-list", history.entries());
    }

    if history.is_empty() {
        println!("No cleanup history found.");
        return Ok(());
    }

    println!("Cleanup history: {} run(s)", history.entries().len());
    print_history_summary(history.summary());
    print_largest_history_runs(history.largest_runs());

    for entry in history.entries() {
        println!(
            "- {}: {} completed, {} failed, {} pending bytes{}",
            entry.recorded_at_unix_seconds,
            entry.summary.completed_targets,
            entry.summary.failed_targets,
            entry.summary.pending_reclaim_bytes,
            restore_hint_suffix(
                entry
                    .targets
                    .iter()
                    .filter_map(|target| target.restore_hint.as_deref())
            )
        );
        print_history_issue_matrix(&entry.summary.issue_matrix);
        print_history_issue_targets(&entry.targets);
    }

    Ok(())
}

fn print_history_summary(summary: &HistoryAggregateSummary) {
    println!();
    println!("History summary:");
    println!("  Runs: {}", summary.runs);
    println!("  Completed targets: {}", summary.completed_targets);
    println!("  Skipped targets: {}", summary.skipped_targets);
    println!("  Blocked targets: {}", summary.blocked_targets);
    println!("  Failed targets: {}", summary.failed_targets);
    println!(
        "  Freed bytes: {} ({})",
        summary.freed_bytes,
        format_bytes(summary.freed_bytes)
    );
    println!(
        "  Pending reclaim bytes: {} ({})",
        summary.pending_reclaim_bytes,
        format_bytes(summary.pending_reclaim_bytes)
    );
    println!();
}

fn print_largest_history_runs(runs: &[HistoryRunHighlight]) {
    if runs.is_empty() {
        return;
    }

    println!("Largest cleanup runs:");
    for run in runs {
        println!(
            "  - {}: {} ({}) total, {} ({}) freed, {} ({}) pending reclaim",
            run.recorded_at_unix_seconds,
            run.total_bytes,
            format_bytes(run.total_bytes),
            run.freed_bytes,
            format_bytes(run.freed_bytes),
            run.pending_reclaim_bytes,
            format_bytes(run.pending_reclaim_bytes)
        );
    }
    println!();
}

fn print_history_issue_matrix(issue_matrix: &[CleanupIssueSummary]) {
    if issue_matrix.is_empty() {
        return;
    }

    println!("  Issue matrix:");
    for issue in issue_matrix {
        println!("  - {}", format_issue_matrix_entry(issue));
    }
}

fn print_history_issue_targets(targets: &[CleanupTarget]) {
    let issue_targets = targets
        .iter()
        .filter(|target| target.status.is_issue())
        .collect::<Vec<_>>();

    if issue_targets.is_empty() {
        return;
    }

    println!("  Issue targets:");
    for target in issue_targets {
        println!(
            "  - {} {}: {} [{}]{}{}",
            target.status.label(),
            target
                .reason_code
                .unwrap_or(CleanupTargetIssueReason::Unclassified)
                .label(),
            target.rule_id,
            target.path.display(),
            target
                .reason
                .as_deref()
                .map(|reason| format!(" ({reason})"))
                .unwrap_or_default(),
            restore_hint_suffix(target.restore_hint.as_deref())
        );
    }
}

pub fn print_config_paths(output_mode: OutputMode) -> Result<()> {
    let paths = load_app_paths()?;

    if output_mode.is_json() {
        let value = config_paths_json(&paths);
        return crate::output::print_success("config paths", "config-paths", &value);
    }

    if output_mode.is_ndjson() {
        let value = config_paths_json(&paths);
        return crate::output::print_success_event("config paths", "config-paths", &value);
    }

    for entry in paths.storage_entries() {
        println!(
            "{}: {} [{}; {}]",
            entry.id.label(),
            entry.path.display(),
            entry.lifecycle.label(),
            entry.retention.label()
        );
    }

    Ok(())
}

pub fn print_privilege_level(output_mode: OutputMode) -> Result<()> {
    if output_mode.is_json() {
        let diagnostic = permission_diagnostic();
        return crate::output::print_success(
            "doctor permissions",
            "permissions-diagnostic",
            &diagnostic,
        );
    }

    if output_mode.is_ndjson() {
        let diagnostic = permission_diagnostic();
        return crate::output::print_success_event(
            "doctor permissions",
            "permissions-diagnostic",
            &diagnostic,
        );
    }

    println!("Privilege level: {}", current_privilege_label());
    Ok(())
}

fn permission_diagnostic() -> PermissionDiagnostic<'static> {
    let privilege_level = current_privilege_label();
    PermissionDiagnostic {
        platform: current_platform_label(),
        platform_supported: cfg!(windows),
        cleanup_execution_supported: cfg!(windows),
        privilege_level,
        suggested_action: permission_suggested_action(privilege_level),
    }
}

fn permission_suggested_action(privilege_level: &str) -> &'static str {
    match privilege_level {
        "elevated" => "cleanup execution can use elevated Windows permissions",
        "standard-user" => "run from an elevated shell if protected cleanup targets are blocked",
        "unsupported-platform" => {
            "cleanup execution is currently Windows-only; use preview commands on this platform"
        }
        _ => {
            "permission level could not be determined; use dry-run preview before executing cleanup"
        }
    }
}

fn current_platform_label() -> &'static str {
    if cfg!(windows) {
        "windows"
    } else if cfg!(target_os = "macos") {
        "macos"
    } else if cfg!(target_os = "linux") {
        "linux"
    } else {
        "unsupported"
    }
}

pub fn application_discovery() -> Box<dyn ApplicationDiscovery> {
    if let Some(applications) = application_discovery_override() {
        return applications;
    }

    #[cfg(windows)]
    {
        Box::new(rebecca::windows::steam::WindowsApplicationDiscovery::new())
    }

    #[cfg(not(windows))]
    {
        Box::new(rebecca::core::applications::NoopApplicationDiscovery::new())
    }
}

#[cfg(debug_assertions)]
fn application_discovery_override() -> Option<Box<dyn ApplicationDiscovery>> {
    if app_discovery_is_disabled() {
        return Some(Box::new(NoopApplicationDiscovery::new()));
    }

    let mut static_discovery = StaticApplicationDiscovery::new();
    let mut has_override = false;

    let steam_discovery = std::env::var("REBECCA_STEAM_DISCOVERY").ok();
    if steam_discovery.as_deref().is_some_and(|value| {
        value.eq_ignore_ascii_case("none") || value.eq_ignore_ascii_case("disabled")
    }) {
        has_override = true;
    } else if let Ok(path) = std::env::var("REBECCA_STEAM_DISCOVERY_PATH") {
        let path = path.trim();
        has_override = true;
        if !path.is_empty() {
            static_discovery = static_discovery
                .with_steam_installation(SteamInstallation::from_install_path_best_effort(path));
        }
    }

    let installed_applications = installed_applications_override();
    if !installed_applications.is_empty() {
        has_override = true;
        static_discovery = static_discovery.with_installed_applications(installed_applications);
    }

    has_override.then(|| Box::new(static_discovery) as Box<dyn ApplicationDiscovery>)
}

#[cfg(not(debug_assertions))]
fn application_discovery_override() -> Option<Box<dyn ApplicationDiscovery>> {
    None
}

#[cfg(debug_assertions)]
fn app_discovery_is_disabled() -> bool {
    std::env::var("REBECCA_APP_DISCOVERY")
        .ok()
        .is_some_and(|value| {
            value.eq_ignore_ascii_case("none") || value.eq_ignore_ascii_case("disabled")
        })
}

#[cfg(debug_assertions)]
fn installed_applications_override() -> Vec<InstalledApplication> {
    let Ok(raw) = std::env::var("REBECCA_INSTALLED_APPLICATIONS") else {
        return Vec::new();
    };

    raw.split(';')
        .enumerate()
        .filter_map(|(index, name)| {
            let name = name.trim();
            (!name.is_empty()).then(|| {
                InstalledApplication::new(
                    format!("debug/installed-application/{index}"),
                    name,
                    Vec::new(),
                )
            })
        })
        .collect()
}

#[cfg(windows)]
fn current_privilege_label() -> &'static str {
    match rebecca::windows::current_privilege_level() {
        rebecca::windows::PrivilegeLevel::StandardUser => "standard-user",
        rebecca::windows::PrivilegeLevel::Elevated => "elevated",
        rebecca::windows::PrivilegeLevel::Unknown => "unknown",
    }
}

#[cfg(not(windows))]
fn current_privilege_label() -> &'static str {
    "unsupported-platform"
}