rebecca 0.2.0

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

use rebecca::core::plan::{CleanupPlan, CleanupSummary, CleanupTarget};
use rebecca::core::{DeleteMode, TargetStatus};

const LARGEST_TARGET_LIMIT: usize = 5;

#[derive(Debug, Clone)]
pub(crate) struct CleanPlanProjection<'a> {
    pub(crate) workflow_title: &'static str,
    pub(crate) mode_label: &'static str,
    pub(crate) summary: CleanPlanSummary,
    issue_matrix: Vec<CleanIssueRow>,
    scan_cache_summary: Option<ScanCacheSummaryRow>,
    largest_targets: Vec<CleanTargetRow<'a>>,
    target_groups: Vec<CleanTargetGroup<'a>>,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct CleanPlanSummary {
    pub(crate) total_targets: usize,
    pub(crate) allowed_targets: usize,
    pub(crate) skipped_targets: usize,
    pub(crate) blocked_targets: usize,
    pub(crate) failed_targets: usize,
    pub(crate) completed_targets: usize,
    pub(crate) estimated_bytes: u64,
    pub(crate) freed_bytes: u64,
    pub(crate) pending_reclaim_bytes: u64,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CleanIssueRow {
    pub(crate) status_label: &'static str,
    pub(crate) reason_label: &'static str,
    pub(crate) targets_label: String,
    pub(crate) estimated_bytes: u64,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ScanCacheSummaryRow {
    pub(crate) hits_label: String,
    pub(crate) misses_label: String,
    pub(crate) write_skipped_label: String,
    pub(crate) pruned_label: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CleanTargetGroup<'a> {
    pub(crate) status_label: &'static str,
    pub(crate) targets: Vec<CleanTargetRow<'a>>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CleanTargetRow<'a> {
    pub(crate) rule_id: &'a str,
    pub(crate) path: &'a Path,
    pub(crate) estimated_bytes: u64,
    pub(crate) reason: Option<&'a str>,
    pub(crate) restore_hint: Option<&'a str>,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct ScanCacheProgressSummary {
    pub(crate) hits: u64,
    pub(crate) misses: u64,
    pub(crate) write_skipped: u64,
    pub(crate) pruned: u64,
}

impl<'a> CleanPlanProjection<'a> {
    pub(crate) fn new(
        plan: &'a CleanupPlan,
        scan_cache_summary: Option<ScanCacheProgressSummary>,
    ) -> Self {
        Self {
            workflow_title: plan.request.workflow.title(),
            mode_label: cleanup_mode_label(plan.request.mode),
            summary: CleanPlanSummary::from(&plan.summary),
            issue_matrix: issue_matrix_rows(plan),
            scan_cache_summary: scan_cache_summary
                .filter(|summary| summary.has_activity())
                .map(ScanCacheSummaryRow::from),
            largest_targets: largest_target_rows(plan),
            target_groups: target_groups(plan),
        }
    }

    pub(crate) fn issue_matrix(&self) -> &[CleanIssueRow] {
        &self.issue_matrix
    }

    pub(crate) fn scan_cache_summary(&self) -> Option<&ScanCacheSummaryRow> {
        self.scan_cache_summary.as_ref()
    }

    pub(crate) fn largest_targets(&self) -> &[CleanTargetRow<'a>] {
        &self.largest_targets
    }

    pub(crate) fn target_groups(&self) -> &[CleanTargetGroup<'a>] {
        &self.target_groups
    }
}

impl From<&CleanupSummary> for CleanPlanSummary {
    fn from(summary: &CleanupSummary) -> Self {
        Self {
            total_targets: summary.total_targets,
            allowed_targets: summary.allowed_targets,
            skipped_targets: summary.skipped_targets,
            blocked_targets: summary.blocked_targets,
            failed_targets: summary.failed_targets,
            completed_targets: summary.completed_targets,
            estimated_bytes: summary.estimated_bytes,
            freed_bytes: summary.freed_bytes,
            pending_reclaim_bytes: summary.pending_reclaim_bytes,
        }
    }
}

impl ScanCacheProgressSummary {
    fn has_activity(self) -> bool {
        self.hits > 0 || self.misses > 0 || self.write_skipped > 0 || self.pruned > 0
    }
}

impl From<ScanCacheProgressSummary> for ScanCacheSummaryRow {
    fn from(summary: ScanCacheProgressSummary) -> Self {
        Self {
            hits_label: format_count(summary.hits, "hit", "hits"),
            misses_label: format_count(summary.misses, "miss", "misses"),
            write_skipped_label: format_count(
                summary.write_skipped,
                "skipped write",
                "skipped writes",
            ),
            pruned_label: format_count(summary.pruned, "pruned record", "pruned records"),
        }
    }
}

impl<'a> From<&'a CleanupTarget> for CleanTargetRow<'a> {
    fn from(target: &'a CleanupTarget) -> Self {
        Self {
            rule_id: &target.rule_id,
            path: target.path.as_path(),
            estimated_bytes: target.estimated_bytes,
            reason: target.reason.as_deref(),
            restore_hint: target.restore_hint.as_deref(),
        }
    }
}

fn issue_matrix_rows(plan: &CleanupPlan) -> Vec<CleanIssueRow> {
    plan.summary
        .issue_matrix
        .iter()
        .map(|issue| CleanIssueRow {
            status_label: issue.status.label(),
            reason_label: issue.reason_code.label(),
            targets_label: format_count(issue.targets as u64, "target", "targets"),
            estimated_bytes: issue.estimated_bytes,
        })
        .collect()
}

fn largest_target_rows(plan: &CleanupPlan) -> Vec<CleanTargetRow<'_>> {
    let mut targets = plan
        .targets
        .iter()
        .filter(|target| target.estimated_bytes > 0)
        .collect::<Vec<_>>();

    targets.sort_by(|left, right| {
        right
            .estimated_bytes
            .cmp(&left.estimated_bytes)
            .then_with(|| left.rule_id.cmp(&right.rule_id))
            .then_with(|| left.path.cmp(&right.path))
    });

    targets
        .into_iter()
        .take(LARGEST_TARGET_LIMIT)
        .map(CleanTargetRow::from)
        .collect()
}

fn target_groups(plan: &CleanupPlan) -> Vec<CleanTargetGroup<'_>> {
    [
        TargetStatus::Allowed,
        TargetStatus::Completed,
        TargetStatus::Failed,
        TargetStatus::Blocked,
        TargetStatus::Skipped,
    ]
    .into_iter()
    .filter_map(|status| {
        let targets = plan
            .targets
            .iter()
            .filter(|target| target.status == status)
            .map(CleanTargetRow::from)
            .collect::<Vec<_>>();

        (!targets.is_empty()).then_some(CleanTargetGroup {
            status_label: status.label(),
            targets,
        })
    })
    .collect()
}

fn cleanup_mode_label(mode: DeleteMode) -> &'static str {
    match mode {
        DeleteMode::DryRun => "dry-run",
        DeleteMode::RecycleBin => "recycle-bin",
    }
}

fn format_count(count: u64, singular: &str, plural: &str) -> String {
    if count == 1 {
        format!("{count} {singular}")
    } else {
        format!("{count} {plural}")
    }
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use rebecca::core::plan::{CleanupTarget, CleanupTargetIssueReason};
    use rebecca::core::{PlanRequest, Platform};

    use super::*;

    fn plan_with_targets(targets: Vec<CleanupTarget>) -> CleanupPlan {
        let mut plan = CleanupPlan {
            request: PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun),
            summary: CleanupSummary::default(),
            targets,
        };
        plan.recompute_summary();
        plan
    }

    fn target(rule_id: &str, estimated_bytes: u64, status: TargetStatus) -> CleanupTarget {
        let path = PathBuf::from(format!("cache/{rule_id}-{estimated_bytes}"));
        let mut target = match status {
            TargetStatus::Allowed => {
                CleanupTarget::allowed(rule_id, path, estimated_bytes, DeleteMode::DryRun)
            }
            TargetStatus::Skipped => CleanupTarget::skipped_with_reason_code(
                rule_id,
                path,
                DeleteMode::DryRun,
                CleanupTargetIssueReason::DuplicateTargetPath,
                "duplicate target path already covered",
            ),
            TargetStatus::Blocked => CleanupTarget::blocked_with_reason_code(
                rule_id,
                path,
                DeleteMode::DryRun,
                CleanupTargetIssueReason::SafetyPolicyBlocked,
                "blocked by safety policy",
            ),
            TargetStatus::Failed => CleanupTarget::failed_with_reason_code(
                rule_id,
                path,
                DeleteMode::DryRun,
                estimated_bytes,
                CleanupTargetIssueReason::ScanFailed,
                "scan failed",
            ),
            TargetStatus::Completed => {
                CleanupTarget::allowed(rule_id, path, estimated_bytes, DeleteMode::DryRun)
            }
        };

        if status == TargetStatus::Completed {
            target.status = TargetStatus::Completed;
            target.freed_bytes = estimated_bytes;
        }

        target
    }

    #[test]
    fn projection_orders_and_limits_largest_targets() {
        let plan = plan_with_targets(vec![
            target("rule-f", 1, TargetStatus::Allowed),
            target("rule-a", 100, TargetStatus::Allowed),
            target("rule-c", 30, TargetStatus::Allowed),
            target("rule-b", 100, TargetStatus::Allowed),
            target("rule-e", 10, TargetStatus::Allowed),
            target("rule-d", 20, TargetStatus::Allowed),
        ]);

        let projection = CleanPlanProjection::new(&plan, None);
        let rule_ids = projection
            .largest_targets()
            .iter()
            .map(|target| target.rule_id)
            .collect::<Vec<_>>();

        assert_eq!(rule_ids, ["rule-a", "rule-b", "rule-c", "rule-d", "rule-e"]);
    }

    #[test]
    fn projection_groups_targets_in_display_order() {
        let plan = plan_with_targets(vec![
            target("skipped", 0, TargetStatus::Skipped),
            target("allowed", 1, TargetStatus::Allowed),
            target("blocked", 0, TargetStatus::Blocked),
            target("failed", 2, TargetStatus::Failed),
            target("completed", 3, TargetStatus::Completed),
        ]);

        let projection = CleanPlanProjection::new(&plan, None);
        let groups = projection
            .target_groups()
            .iter()
            .map(|group| (group.status_label, group.targets[0].rule_id))
            .collect::<Vec<_>>();

        assert_eq!(
            groups,
            [
                ("allowed", "allowed"),
                ("completed", "completed"),
                ("failed", "failed"),
                ("blocked", "blocked"),
                ("skipped", "skipped"),
            ]
        );
    }

    #[test]
    fn projection_prepares_issue_matrix_rows() {
        let plan = plan_with_targets(vec![target("skipped", 0, TargetStatus::Skipped)]);

        let projection = CleanPlanProjection::new(&plan, None);
        let issue = projection
            .issue_matrix()
            .first()
            .expect("skipped target should produce an issue row");

        assert_eq!(issue.status_label, "skipped");
        assert_eq!(issue.reason_label, "duplicate-target-path");
        assert_eq!(issue.targets_label, "1 target");
        assert_eq!(issue.estimated_bytes, 0);
    }

    #[test]
    fn projection_formats_active_scan_cache_summary() {
        let plan = plan_with_targets(Vec::new());
        let projection = CleanPlanProjection::new(
            &plan,
            Some(ScanCacheProgressSummary {
                hits: 1,
                misses: 2,
                write_skipped: 1,
                pruned: 3,
            }),
        );

        let summary = projection
            .scan_cache_summary()
            .expect("active summary should be visible");
        assert_eq!(summary.hits_label, "1 hit");
        assert_eq!(summary.misses_label, "2 misses");
        assert_eq!(summary.write_skipped_label, "1 skipped write");
        assert_eq!(summary.pruned_label, "3 pruned records");
    }

    #[test]
    fn projection_omits_inactive_scan_cache_summary() {
        let plan = plan_with_targets(Vec::new());
        let projection = CleanPlanProjection::new(&plan, Some(ScanCacheProgressSummary::default()));

        assert!(projection.scan_cache_summary().is_none());
    }
}