rebecca-core 0.2.0

Core planning, safety, scanning, and history models for Rebecca.
Documentation
use std::sync::OnceLock;

use rayon::ThreadPool;
use rayon::prelude::*;

use crate::error::Result;
use crate::model::CleanupWorkflow;
use crate::parallelism::{bounded_parallelism_budget, run_scoped_parallel_work};
use crate::path_overlap::paths_overlap;
use crate::plan::{CleanupPlan, CleanupTarget, CleanupTargetIssueReason};
use crate::protection::{AppLeftoverPathDisposition, ProtectionPolicy};
use crate::safety::{
    PATH_DOES_NOT_EXIST_REASON, PathDisposition, assess_existing_path_with_policy,
};

static CLEANUP_THREAD_POOL: OnceLock<ThreadPool> = OnceLock::new();

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecutionOutcome {
    pub freed_bytes: u64,
    pub pending_reclaim_bytes: u64,
    pub note: Option<String>,
}

pub trait CleanupBackend {
    fn delete(&self, target: &CleanupTarget) -> Result<ExecutionOutcome>;
}

pub fn execute_cleanup_plan<B: CleanupBackend>(plan: &mut CleanupPlan, backend: &B) -> Result<()> {
    execute_cleanup_plan_with_policy(plan, backend, ProtectionPolicy::new())
}

pub fn execute_cleanup_plan_with_policy<B: CleanupBackend>(
    plan: &mut CleanupPlan,
    backend: &B,
    policy: ProtectionPolicy<'_>,
) -> Result<()> {
    execute_cleanup_plan_serially_with_policy(plan, backend, policy)
}

pub fn execute_cleanup_plan_parallel<B: CleanupBackend + Sync>(
    plan: &mut CleanupPlan,
    backend: &B,
) -> Result<()> {
    execute_cleanup_plan_parallel_with_policy(plan, backend, ProtectionPolicy::new())
}

pub fn execute_cleanup_plan_parallel_with_policy<B: CleanupBackend + Sync>(
    plan: &mut CleanupPlan,
    backend: &B,
    policy: ProtectionPolicy<'_>,
) -> Result<()> {
    if plan.request.mode.is_dry_run() {
        plan.recompute_summary();
        return Ok(());
    }

    if !revalidate_executable_targets(plan, policy) {
        plan.recompute_summary();
        return Ok(());
    }

    let batches = batch_executable_targets(&plan.targets);
    if batches.is_empty() {
        plan.recompute_summary();
        return Ok(());
    }

    for batch in batches {
        let outcomes = run_scoped_cleanup(|| {
            batch
                .into_par_iter()
                .map(|index| (index, backend.delete(&plan.targets[index])))
                .collect::<Vec<_>>()
        });

        for (index, outcome) in outcomes {
            apply_delete_result(&mut plan.targets[index], outcome);
        }
    }

    plan.recompute_summary();
    Ok(())
}

fn revalidate_executable_targets(plan: &mut CleanupPlan, policy: ProtectionPolicy<'_>) -> bool {
    let mut any_executable = false;

    for target in &mut plan.targets {
        if !target.status.is_executable() {
            continue;
        }

        if execution_target_is_still_allowed(plan.request.workflow, target, policy) {
            any_executable = true;
        }
    }

    any_executable
}

fn execute_cleanup_plan_serially_with_policy<B: CleanupBackend>(
    plan: &mut CleanupPlan,
    backend: &B,
    policy: ProtectionPolicy<'_>,
) -> Result<()> {
    if plan.request.mode.is_dry_run() {
        plan.recompute_summary();
        return Ok(());
    }

    for target in &mut plan.targets {
        if !target.status.is_executable() {
            continue;
        }

        if !execution_target_is_still_allowed(plan.request.workflow, target, policy) {
            continue;
        }

        let outcome = backend.delete(target);
        apply_delete_result(target, outcome);
    }

    plan.recompute_summary();
    Ok(())
}

fn execution_target_is_still_allowed(
    workflow: CleanupWorkflow,
    target: &mut CleanupTarget,
    policy: ProtectionPolicy<'_>,
) -> bool {
    match workflow {
        CleanupWorkflow::Rules | CleanupWorkflow::ProjectArtifacts => {
            match assess_existing_path_with_policy(&target.path, policy) {
                PathDisposition::Allowed => true,
                PathDisposition::Missing => {
                    mark_target_missing_before_execution(target);
                    false
                }
                PathDisposition::Skipped(reason) => {
                    mark_target_skipped_by_policy(target, reason);
                    false
                }
                PathDisposition::Blocked(reason) => {
                    mark_target_blocked_by_policy(target, reason);
                    false
                }
            }
        }
        CleanupWorkflow::AppLeftovers => {
            match policy.assess_existing_app_leftover_path(&target.path) {
                AppLeftoverPathDisposition::Allowed => true,
                AppLeftoverPathDisposition::Missing => {
                    mark_target_missing_before_execution(target);
                    false
                }
                AppLeftoverPathDisposition::Blocked(reason) => {
                    mark_target_blocked_by_policy(target, reason);
                    false
                }
            }
        }
    }
}

fn mark_target_missing_before_execution(target: &mut CleanupTarget) {
    target.status = crate::TargetStatus::Skipped;
    target.reason = Some(PATH_DOES_NOT_EXIST_REASON.to_string());
    target.reason_code = Some(CleanupTargetIssueReason::ExecutionTargetMissing);
    target.freed_bytes = 0;
    target.pending_reclaim_bytes = 0;
}

fn mark_target_skipped_by_policy(target: &mut CleanupTarget, reason: String) {
    target.status = crate::TargetStatus::Skipped;
    target.reason = Some(reason);
    target.reason_code = Some(CleanupTargetIssueReason::SafetyPolicySkipped);
    target.freed_bytes = 0;
    target.pending_reclaim_bytes = 0;
}

fn mark_target_blocked_by_policy(target: &mut CleanupTarget, reason: String) {
    target.status = crate::TargetStatus::Blocked;
    target.reason = Some(reason);
    target.reason_code = Some(CleanupTargetIssueReason::SafetyPolicyBlocked);
    target.freed_bytes = 0;
    target.pending_reclaim_bytes = 0;
}

fn apply_delete_result(target: &mut CleanupTarget, outcome: Result<ExecutionOutcome>) {
    match outcome {
        Ok(outcome) => {
            target.status = crate::TargetStatus::Completed;
            target.freed_bytes = outcome.freed_bytes;
            target.pending_reclaim_bytes = outcome.pending_reclaim_bytes;
            target.reason = outcome.note;
            target.reason_code = None;
        }
        Err(err) => {
            target.status = crate::TargetStatus::Failed;
            target.reason = Some(err.to_string());
            target.reason_code = Some(CleanupTargetIssueReason::ExecutionFailed);
            target.freed_bytes = 0;
            target.pending_reclaim_bytes = 0;
        }
    }
}

fn batch_executable_targets(targets: &[CleanupTarget]) -> Vec<Vec<usize>> {
    let mut executable_indices: Vec<_> = targets
        .iter()
        .enumerate()
        .filter_map(|(index, target)| target.status.is_executable().then_some(index))
        .collect();

    executable_indices.sort_by(|left, right| {
        path_depth(&targets[*right].path)
            .cmp(&path_depth(&targets[*left].path))
            .then_with(|| targets[*left].path.cmp(&targets[*right].path))
            .then_with(|| left.cmp(right))
    });

    let mut batches: Vec<Vec<usize>> = Vec::new();
    'candidate: for index in executable_indices {
        let path = targets[index].path.as_path();
        for batch in &mut batches {
            if batch
                .iter()
                .all(|&existing| !paths_overlap(path, targets[existing].path.as_path()))
            {
                batch.push(index);
                continue 'candidate;
            }
        }

        batches.push(vec![index]);
    }

    batches
}

fn path_depth(path: &std::path::Path) -> usize {
    path.components().count()
}

pub fn cleanup_parallelism_budget() -> usize {
    bounded_parallelism_budget()
}

pub(crate) fn run_scoped_cleanup<R, F>(work: F) -> R
where
    F: FnOnce() -> R + Send,
    R: Send,
{
    run_scoped_parallel_work(&CLEANUP_THREAD_POOL, "cleanup", work)
}

#[cfg(test)]
mod tests {
    use super::{batch_executable_targets, cleanup_parallelism_budget, run_scoped_cleanup};
    use std::sync::Arc;
    use std::sync::atomic::{AtomicUsize, Ordering};

    use crate::plan::{CleanupTarget, CleanupTargetDeletionStyle};
    use crate::scan::scan_parallelism_budget;
    use crate::{DeleteMode, TargetStatus};

    #[test]
    fn cleanup_parallelism_budget_stays_bounded() {
        let budget = cleanup_parallelism_budget();

        assert!((2..=8).contains(&budget));
    }

    #[test]
    fn run_scoped_cleanup_executes_work() {
        let counter = Arc::new(AtomicUsize::new(0));
        let counter_ref = Arc::clone(&counter);

        run_scoped_cleanup(move || {
            counter_ref.fetch_add(1, Ordering::SeqCst);
        });

        assert_eq!(counter.load(Ordering::SeqCst), 1);
    }

    #[test]
    fn cleanup_and_scan_parallelism_budgets_match() {
        assert_eq!(cleanup_parallelism_budget(), scan_parallelism_budget());
    }

    #[test]
    fn batch_executable_targets_keeps_overlapping_paths_separate() {
        let targets = vec![
            CleanupTarget::allowed(
                "windows.a",
                std::path::PathBuf::from(r"C:\Temp\Rebecca\a"),
                1,
                DeleteMode::RecycleBin,
            )
            .with_deletion_style(CleanupTargetDeletionStyle::DeleteWholePath),
            CleanupTarget::allowed(
                "windows.b",
                std::path::PathBuf::from(r"C:\Temp\Rebecca\a\child"),
                1,
                DeleteMode::RecycleBin,
            )
            .with_deletion_style(CleanupTargetDeletionStyle::DeleteWholePath),
            CleanupTarget::allowed(
                "windows.c",
                std::path::PathBuf::from(r"C:\Temp\Rebecca\other"),
                1,
                DeleteMode::RecycleBin,
            )
            .with_deletion_style(CleanupTargetDeletionStyle::DeleteWholePath),
        ];

        let batches = batch_executable_targets(&targets);
        let mut seen = batches.iter().flatten().copied().collect::<Vec<_>>();
        seen.sort_unstable();

        assert_eq!(seen, vec![0, 1, 2]);
        assert!(batches.iter().all(|batch| {
            batch.iter().enumerate().all(|(index, left)| {
                batch[index + 1..].iter().all(|right| {
                    !crate::path_overlap::paths_overlap(
                        targets[*left].path.as_path(),
                        targets[*right].path.as_path(),
                    )
                })
            })
        }));
        assert!(
            targets
                .iter()
                .all(|target| target.status == TargetStatus::Allowed)
        );
    }

    #[test]
    fn batch_executable_targets_prefers_descendants_before_ancestors() {
        let targets = vec![
            CleanupTarget::allowed(
                "windows.parent",
                std::path::PathBuf::from(r"C:\Temp\Rebecca"),
                1,
                DeleteMode::RecycleBin,
            )
            .with_deletion_style(CleanupTargetDeletionStyle::DeleteWholePath),
            CleanupTarget::allowed(
                "windows.child",
                std::path::PathBuf::from(r"C:\Temp\Rebecca\child"),
                1,
                DeleteMode::RecycleBin,
            )
            .with_deletion_style(CleanupTargetDeletionStyle::DeleteWholePath),
        ];

        let batches = batch_executable_targets(&targets);

        assert_eq!(batches.len(), 2);
        assert_eq!(batches[0], vec![1]);
        assert_eq!(batches[1], vec![0]);
    }
}