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::cache::{CachePurgeEntry, CachePurgeMode, CachePurgeReport, CachePurgeSummary};

#[derive(Debug, Clone)]
pub(crate) struct CachePurgeProjection<'a> {
    pub(crate) cache_dir: &'a Path,
    pub(crate) mode_label: &'static str,
    pub(crate) lifecycle_label: &'static str,
    pub(crate) retention_label: &'static str,
    pub(crate) cache_dir_exists_label: &'static str,
    pub(crate) preserves_cache_dir_label: &'static str,
    pub(crate) summary: CachePurgeHumanSummary,
    issue_matrix: Vec<CachePurgeIssueRow>,
    entries: Vec<CachePurgeEntryRow<'a>>,
    show_delete_hint: bool,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct CachePurgeHumanSummary {
    pub(crate) total_entries: usize,
    pub(crate) would_delete_entries: usize,
    pub(crate) deleted_entries: usize,
    pub(crate) skipped_entries: usize,
    pub(crate) failed_entries: usize,
    pub(crate) files: u64,
    pub(crate) directories: u64,
    pub(crate) estimated_bytes: u64,
    pub(crate) reclaimed_bytes: u64,
}

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

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CachePurgeEntryRow<'a> {
    pub(crate) status_label: &'static str,
    pub(crate) path: &'a Path,
    pub(crate) estimated_bytes: u64,
    pub(crate) files: u64,
    pub(crate) directories: u64,
    pub(crate) reason_suffix: String,
}

impl<'a> CachePurgeProjection<'a> {
    pub(crate) fn new(report: &'a CachePurgeReport) -> Self {
        let issue_matrix = report
            .summary
            .issue_matrix
            .iter()
            .map(|issue| CachePurgeIssueRow {
                status_label: issue.status.label(),
                reason_label: issue.reason_code.label(),
                entries_label: format_count(issue.entries, "entry", "entries"),
                estimated_bytes: issue.estimated_bytes,
            })
            .collect();

        let entries = report
            .entries
            .iter()
            .map(CachePurgeEntryRow::from)
            .collect();

        Self {
            cache_dir: report.cache_dir.as_path(),
            mode_label: mode_label(report.mode),
            lifecycle_label: report.cache_dir_lifecycle.label(),
            retention_label: report.cache_dir_retention.label(),
            cache_dir_exists_label: yes_no(report.cache_dir_exists),
            preserves_cache_dir_label: yes_no(report.preserves_cache_dir),
            summary: CachePurgeHumanSummary::from(&report.summary),
            issue_matrix,
            entries,
            show_delete_hint: report.mode == CachePurgeMode::DryRun && !report.entries.is_empty(),
        }
    }

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

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

    pub(crate) fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    pub(crate) fn show_delete_hint(&self) -> bool {
        self.show_delete_hint
    }
}

impl From<&CachePurgeSummary> for CachePurgeHumanSummary {
    fn from(summary: &CachePurgeSummary) -> Self {
        Self {
            total_entries: summary.total_entries,
            would_delete_entries: summary.would_delete_entries,
            deleted_entries: summary.deleted_entries,
            skipped_entries: summary.skipped_entries,
            failed_entries: summary.failed_entries,
            files: summary.files,
            directories: summary.directories,
            estimated_bytes: summary.estimated_bytes,
            reclaimed_bytes: summary.reclaimed_bytes,
        }
    }
}

impl<'a> From<&'a CachePurgeEntry> for CachePurgeEntryRow<'a> {
    fn from(entry: &'a CachePurgeEntry) -> Self {
        Self {
            status_label: entry.status.label(),
            path: entry.path.as_path(),
            estimated_bytes: entry.estimated_bytes,
            files: entry.files,
            directories: entry.directories,
            reason_suffix: entry
                .reason
                .as_deref()
                .map(|reason| format!(" - {reason}"))
                .unwrap_or_default(),
        }
    }
}

fn mode_label(mode: CachePurgeMode) -> &'static str {
    match mode {
        CachePurgeMode::DryRun => "dry-run",
        CachePurgeMode::Delete => "delete",
    }
}

fn yes_no(value: bool) -> &'static str {
    if value { "yes" } else { "no" }
}

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

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

    use rebecca::core::cache::{
        CachePurgeEntryKind, CachePurgeEntryReason, CachePurgeEntryStatus, CachePurgeIssueSummary,
    };
    use rebecca::core::config::{AppStorageLifecycle, AppStorageRetention};

    use super::*;

    fn cache_report(mode: CachePurgeMode, entries: Vec<CachePurgeEntry>) -> CachePurgeReport {
        let issue_matrix = if entries.is_empty() {
            Vec::new()
        } else {
            vec![CachePurgeIssueSummary {
                status: CachePurgeEntryStatus::Skipped,
                reason_code: CachePurgeEntryReason::SymlinkSkipped,
                entries: 1,
                estimated_bytes: 0,
            }]
        };

        let summary = CachePurgeSummary {
            total_entries: entries.len(),
            would_delete_entries: entries
                .iter()
                .filter(|entry| entry.status == CachePurgeEntryStatus::WouldDelete)
                .count(),
            deleted_entries: entries
                .iter()
                .filter(|entry| entry.status == CachePurgeEntryStatus::Deleted)
                .count(),
            skipped_entries: entries
                .iter()
                .filter(|entry| entry.status == CachePurgeEntryStatus::Skipped)
                .count(),
            failed_entries: entries
                .iter()
                .filter(|entry| entry.status == CachePurgeEntryStatus::Failed)
                .count(),
            files: entries.iter().map(|entry| entry.files).sum(),
            directories: entries.iter().map(|entry| entry.directories).sum(),
            estimated_bytes: entries.iter().map(|entry| entry.estimated_bytes).sum(),
            reclaimed_bytes: entries
                .iter()
                .filter(|entry| entry.status == CachePurgeEntryStatus::Deleted)
                .map(|entry| entry.estimated_bytes)
                .sum(),
            issue_matrix,
        };

        CachePurgeReport {
            cache_dir: PathBuf::from("cache"),
            cache_dir_lifecycle: AppStorageLifecycle::RebuildableCache,
            cache_dir_retention: AppStorageRetention::Rebuildable,
            cache_dir_exists: true,
            preserves_cache_dir: true,
            mode,
            deleted: mode == CachePurgeMode::Delete,
            summary,
            entries,
        }
    }

    fn skipped_symlink_entry() -> CachePurgeEntry {
        CachePurgeEntry {
            path: PathBuf::from("cache/link"),
            kind: CachePurgeEntryKind::Symlink,
            status: CachePurgeEntryStatus::Skipped,
            estimated_bytes: 0,
            files: 0,
            directories: 0,
            reason: Some("symlink entries are skipped".to_string()),
            reason_code: Some(CachePurgeEntryReason::SymlinkSkipped),
        }
    }

    #[test]
    fn projection_prepares_issue_rows_and_entry_reason_suffixes() {
        let report = cache_report(CachePurgeMode::DryRun, vec![skipped_symlink_entry()]);

        let projection = CachePurgeProjection::new(&report);

        assert_eq!(projection.mode_label, "dry-run");
        assert_eq!(projection.cache_dir_exists_label, "yes");
        assert_eq!(projection.summary.total_entries, 1);
        assert_eq!(projection.issue_matrix().len(), 1);
        assert_eq!(projection.issue_matrix()[0].status_label, "skipped");
        assert_eq!(projection.issue_matrix()[0].reason_label, "symlink-skipped");
        assert_eq!(projection.issue_matrix()[0].entries_label, "1 entry");
        assert_eq!(projection.entries().len(), 1);
        assert_eq!(
            projection.entries()[0].reason_suffix,
            " - symlink entries are skipped"
        );
    }

    #[test]
    fn projection_only_prompts_for_confirmation_on_non_empty_dry_runs() {
        let dry_run = cache_report(CachePurgeMode::DryRun, vec![skipped_symlink_entry()]);
        let delete = cache_report(CachePurgeMode::Delete, vec![skipped_symlink_entry()]);
        let empty = cache_report(CachePurgeMode::DryRun, Vec::new());

        assert!(CachePurgeProjection::new(&dry_run).show_delete_hint());
        assert!(!CachePurgeProjection::new(&delete).show_delete_hint());
        assert!(!CachePurgeProjection::new(&empty).show_delete_hint());
    }
}