rebecca 0.1.1

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

use rebecca::core::plan::{CleanupPlan, CleanupTarget, CleanupTargetIssueReason};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ProjectArtifactPlanProjection<'a> {
    project_groups: Vec<ProjectArtifactGroup<'a>>,
    recently_modified: Vec<ProjectArtifactRow<'a>>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ProjectArtifactGroup<'a> {
    pub(crate) project_path: PathBuf,
    pub(crate) targets_label: String,
    pub(crate) estimated_bytes: u64,
    pub(crate) targets: Vec<ProjectArtifactRow<'a>>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ProjectArtifactRow<'a> {
    pub(crate) artifact_type: String,
    pub(crate) status_label: &'static str,
    pub(crate) path: &'a Path,
    pub(crate) estimated_bytes: u64,
    pub(crate) modified_at_unix_seconds: Option<u64>,
    pub(crate) reason: Option<&'a str>,
    pub(crate) restore_hint: Option<&'a str>,
}

impl<'a> ProjectArtifactPlanProjection<'a> {
    pub(crate) fn new(plan: &'a CleanupPlan) -> Self {
        let mut grouped = BTreeMap::<PathBuf, Vec<ProjectArtifactRow<'a>>>::new();
        let mut recently_modified = Vec::new();

        for target in &plan.targets {
            let row = ProjectArtifactRow::from(target);
            if target.reason_code == Some(CleanupTargetIssueReason::ProjectArtifactRecentlyModified)
            {
                recently_modified.push(ProjectArtifactRow::from(target));
            }

            grouped
                .entry(project_path_for(target.path.as_path()))
                .or_default()
                .push(row);
        }

        recently_modified.sort_by(|left, right| {
            left.path
                .cmp(right.path)
                .then_with(|| left.artifact_type.cmp(&right.artifact_type))
        });

        let project_groups = grouped
            .into_iter()
            .map(|(project_path, mut targets)| {
                targets.sort_by(|left, right| {
                    status_order(left.status_label)
                        .cmp(&status_order(right.status_label))
                        .then_with(|| left.artifact_type.cmp(&right.artifact_type))
                        .then_with(|| left.path.cmp(right.path))
                });

                let estimated_bytes = targets
                    .iter()
                    .map(|target| target.estimated_bytes)
                    .sum::<u64>();
                let targets_label = format_count(targets.len() as u64, "target", "targets");

                ProjectArtifactGroup {
                    project_path,
                    targets_label,
                    estimated_bytes,
                    targets,
                }
            })
            .collect();

        Self {
            project_groups,
            recently_modified,
        }
    }

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

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

impl<'a> From<&'a CleanupTarget> for ProjectArtifactRow<'a> {
    fn from(target: &'a CleanupTarget) -> Self {
        Self {
            artifact_type: artifact_type_label(&target.rule_id, target.path.as_path()),
            status_label: target.status.label(),
            path: target.path.as_path(),
            estimated_bytes: target.estimated_bytes,
            modified_at_unix_seconds: target.modified_at_unix_seconds,
            reason: target.reason.as_deref(),
            restore_hint: target.restore_hint.as_deref(),
        }
    }
}

fn artifact_type_label(rule_id: &str, path: &Path) -> String {
    match rule_id {
        "windows.project-artifact-cachedir-tag" => "CACHEDIR.TAG".to_string(),
        "windows.project-artifact-composer-vendor" => "vendor (Composer)".to_string(),
        "windows.project-artifact-dotnet-bin" => "bin (.NET)".to_string(),
        "windows.project-artifact-dotnet-obj" => "obj (.NET)".to_string(),
        _ => path
            .file_name()
            .and_then(|name| name.to_str())
            .map(str::to_string)
            .unwrap_or_else(|| {
                rule_id
                    .strip_prefix("windows.project-artifact-")
                    .unwrap_or(rule_id)
                    .replace('-', "_")
            }),
    }
}

fn project_path_for(path: &Path) -> PathBuf {
    path.parent()
        .map(Path::to_path_buf)
        .unwrap_or_else(|| path.to_path_buf())
}

fn status_order(status_label: &str) -> usize {
    match status_label {
        "allowed" => 0,
        "completed" => 1,
        "failed" => 2,
        "blocked" => 3,
        "skipped" => 4,
        _ => 5,
    }
}

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::{CleanupPlan, CleanupSummary, CleanupTargetIssueReason};
    use rebecca::core::{CleanupWorkflow, DeleteMode, PlanRequest, Platform};

    use super::*;

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

    #[test]
    fn projection_groups_artifacts_by_parent_project_path() {
        let plan = plan_with_targets(vec![
            CleanupTarget::allowed(
                "windows.project-artifact-target",
                PathBuf::from("workspace/app/target"),
                4,
                DeleteMode::DryRun,
            ),
            CleanupTarget::allowed(
                "windows.project-artifact-node-modules",
                PathBuf::from("workspace/app/node_modules"),
                3,
                DeleteMode::DryRun,
            ),
        ]);

        let projection = ProjectArtifactPlanProjection::new(&plan);

        assert_eq!(projection.project_groups().len(), 1);
        let group = &projection.project_groups()[0];
        assert_eq!(group.project_path, PathBuf::from("workspace/app"));
        assert_eq!(group.targets_label, "2 targets");
        assert_eq!(group.estimated_bytes, 7);
        assert_eq!(group.targets[0].artifact_type, "node_modules");
        assert_eq!(group.targets[1].artifact_type, "target");
    }

    #[test]
    fn projection_extracts_recently_modified_artifacts() {
        let target = CleanupTarget::skipped_with_reason_code(
            "windows.project-artifact-node-modules",
            PathBuf::from("workspace/app/node_modules"),
            DeleteMode::DryRun,
            CleanupTargetIssueReason::ProjectArtifactRecentlyModified,
            "project artifact was modified within the last 7 days",
        );
        let plan = plan_with_targets(vec![target]);

        let projection = ProjectArtifactPlanProjection::new(&plan);

        assert_eq!(projection.recently_modified().len(), 1);
        assert_eq!(
            projection.recently_modified()[0].artifact_type,
            "node_modules"
        );
        assert_eq!(
            projection.recently_modified()[0].reason,
            Some("project artifact was modified within the last 7 days")
        );
    }

    #[test]
    fn projection_labels_context_sensitive_artifact_types() {
        let plan = plan_with_targets(vec![
            CleanupTarget::allowed(
                "windows.project-artifact-composer-vendor",
                PathBuf::from("workspace/php-app/vendor"),
                1,
                DeleteMode::DryRun,
            ),
            CleanupTarget::allowed(
                "windows.project-artifact-dotnet-bin",
                PathBuf::from("workspace/dotnet-app/bin"),
                1,
                DeleteMode::DryRun,
            ),
            CleanupTarget::allowed(
                "windows.project-artifact-cachedir-tag",
                PathBuf::from("workspace/app/tool-cache"),
                1,
                DeleteMode::DryRun,
            ),
        ]);

        let projection = ProjectArtifactPlanProjection::new(&plan);
        let artifact_types = projection
            .project_groups()
            .iter()
            .flat_map(|group| {
                group
                    .targets
                    .iter()
                    .map(|target| target.artifact_type.as_str())
            })
            .collect::<Vec<_>>();

        assert!(artifact_types.contains(&"vendor (Composer)"));
        assert!(artifact_types.contains(&"bin (.NET)"));
        assert!(artifact_types.contains(&"CACHEDIR.TAG"));
    }
}