use std::cell::Cell;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Barrier;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
use std::time::{Duration, Instant};
use rebecca_core::config::AppPaths;
use rebecca_core::executor::{
CleanupBackend, ExecutionOutcome, execute_cleanup_plan,
execute_cleanup_plan_parallel_with_policy, execute_cleanup_plan_with_policy,
};
use rebecca_core::plan::{CleanupPlan, CleanupTarget, CleanupTargetIssueReason};
use rebecca_core::protection::ProtectionPolicy;
use rebecca_core::{CleanupWorkflow, DeleteMode, PlanRequest, Platform, Result, TargetStatus};
#[test]
fn executor_marks_allowed_targets_completed_and_keeps_blocked_targets() {
let temp = tempfile::tempdir().unwrap();
let file = temp.path().join("file.tmp");
fs::write(&file, b"trash").unwrap();
let mut plan = CleanupPlan::empty(PlanRequest::for_platform(
Platform::Windows,
DeleteMode::RecycleBin,
));
plan.targets.push(CleanupTarget::allowed(
"windows.user-temp",
file,
10,
DeleteMode::RecycleBin,
));
plan.targets.push(CleanupTarget::blocked_with_reason_code(
"windows.user-temp",
PathBuf::from("C:/Windows"),
DeleteMode::RecycleBin,
CleanupTargetIssueReason::SafetyPolicyBlocked,
"protected",
));
plan.recompute_summary();
let backend = FakeBackend::success();
execute_cleanup_plan(&mut plan, &backend).unwrap();
assert_eq!(backend.calls.get(), 1);
assert_eq!(plan.targets[0].status, TargetStatus::Completed);
assert_eq!(plan.targets[0].pending_reclaim_bytes, 10);
assert_eq!(plan.targets[1].status, TargetStatus::Blocked);
}
#[test]
fn executor_records_failure_without_aborting_plan() {
let temp = tempfile::tempdir().unwrap();
let file = temp.path().join("file.tmp");
fs::write(&file, b"trash").unwrap();
let mut plan = CleanupPlan::empty(PlanRequest::for_platform(
Platform::Windows,
DeleteMode::RecycleBin,
));
plan.targets.push(CleanupTarget::allowed(
"windows.user-temp",
file,
10,
DeleteMode::RecycleBin,
));
plan.recompute_summary();
let backend = FakeBackend::failure();
execute_cleanup_plan(&mut plan, &backend).unwrap();
assert_eq!(plan.targets[0].status, TargetStatus::Failed);
assert_eq!(
plan.targets[0].reason_code,
Some(CleanupTargetIssueReason::ExecutionFailed)
);
assert_eq!(plan.summary.failed_targets, 1);
assert_eq!(
plan.summary.issue_matrix[0].reason_code,
CleanupTargetIssueReason::ExecutionFailed
);
}
#[test]
fn executor_revalidates_protected_category_targets_before_backend_calls() {
let mut plan = CleanupPlan::empty(PlanRequest::for_platform(
Platform::Windows,
DeleteMode::RecycleBin,
));
plan.targets.push(CleanupTarget::allowed(
"windows.custom-browser-history",
PathBuf::from("C:/Users/Alice/AppData/Local/Google/Chrome/User Data/Default/History"),
10,
DeleteMode::RecycleBin,
));
plan.recompute_summary();
let backend = FakeBackend::success();
execute_cleanup_plan_with_policy(&mut plan, &backend, ProtectionPolicy::new()).unwrap();
assert_eq!(backend.calls.get(), 0);
assert_eq!(plan.targets[0].status, TargetStatus::Blocked);
assert_eq!(
plan.targets[0].reason_code,
Some(CleanupTargetIssueReason::SafetyPolicyBlocked)
);
assert!(
plan.targets[0]
.reason
.as_deref()
.unwrap()
.contains("browser private data")
);
assert_eq!(plan.summary.blocked_targets, 1);
}
#[test]
fn executor_revalidates_rebecca_owned_storage_before_backend_calls() {
let app_paths = AppPaths {
config_dir: PathBuf::from("C:/Users/Alice/AppData/Roaming/Rebecca"),
config_file: PathBuf::from("C:/Users/Alice/AppData/Roaming/Rebecca/config.toml"),
state_dir: PathBuf::from("C:/Users/Alice/AppData/Local/Rebecca/state"),
cache_dir: PathBuf::from("C:/Users/Alice/AppData/Local/Rebecca/cache"),
history_file: PathBuf::from("C:/Users/Alice/AppData/Local/Rebecca/state/history.jsonl"),
};
let protected_storage = app_paths.storage_entries();
let mut plan = CleanupPlan::empty(PlanRequest::for_platform(
Platform::Windows,
DeleteMode::RecycleBin,
));
plan.targets.push(CleanupTarget::allowed(
"windows.custom-rebecca-cache",
app_paths.cache_dir.join("scan"),
10,
DeleteMode::RecycleBin,
));
plan.recompute_summary();
let backend = FakeBackend::success();
let policy = ProtectionPolicy::new().with_protected_storage(&protected_storage);
execute_cleanup_plan_with_policy(&mut plan, &backend, policy).unwrap();
assert_eq!(backend.calls.get(), 0);
assert_eq!(plan.targets[0].status, TargetStatus::Blocked);
assert_eq!(
plan.targets[0].reason_code,
Some(CleanupTargetIssueReason::SafetyPolicyBlocked)
);
assert!(
plan.targets[0]
.reason
.as_deref()
.unwrap()
.contains("Rebecca-owned Cache dir")
);
assert_eq!(plan.summary.blocked_targets, 1);
}
#[test]
fn executor_revalidates_user_protected_paths_before_backend_calls() {
let temp = tempfile::tempdir().unwrap();
let cache_dir = temp.path().join("Slack").join("Cache");
fs::create_dir_all(&cache_dir).unwrap();
fs::write(cache_dir.join("cache.bin"), b"trash").unwrap();
let protected_paths = vec![cache_dir.clone()];
let mut plan = CleanupPlan::empty(PlanRequest::for_platform(
Platform::Windows,
DeleteMode::RecycleBin,
));
plan.targets.push(CleanupTarget::allowed(
"windows.slack-cache",
cache_dir,
5,
DeleteMode::RecycleBin,
));
plan.recompute_summary();
let backend = FakeBackend::success();
let policy = ProtectionPolicy::new().with_protected_paths(&protected_paths);
execute_cleanup_plan_with_policy(&mut plan, &backend, policy).unwrap();
assert_eq!(backend.calls.get(), 0);
assert_eq!(plan.targets[0].status, TargetStatus::Blocked);
assert_eq!(
plan.targets[0].reason_code,
Some(CleanupTargetIssueReason::SafetyPolicyBlocked)
);
assert!(
plan.targets[0]
.reason
.as_deref()
.unwrap()
.contains("user-protected path")
);
assert_eq!(plan.summary.blocked_targets, 1);
}
#[test]
fn executor_revalidates_project_artifact_targets_before_backend_calls() {
let temp = tempfile::tempdir().unwrap();
let target_dir = temp.path().join("project").join("target");
fs::create_dir_all(&target_dir).unwrap();
fs::write(target_dir.join("cache.bin"), b"trash").unwrap();
let protected_paths = vec![target_dir.clone()];
let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::RecycleBin);
request.workflow = CleanupWorkflow::ProjectArtifacts;
let mut plan = CleanupPlan::empty(request);
plan.targets.push(CleanupTarget::allowed(
"windows.project-artifact-target",
target_dir,
5,
DeleteMode::RecycleBin,
));
plan.recompute_summary();
let backend = FakeBackend::success();
let policy = ProtectionPolicy::new().with_protected_paths(&protected_paths);
execute_cleanup_plan_with_policy(&mut plan, &backend, policy).unwrap();
assert_eq!(backend.calls.get(), 0);
assert_eq!(plan.targets[0].status, TargetStatus::Blocked);
assert_eq!(
plan.targets[0].reason_code,
Some(CleanupTargetIssueReason::SafetyPolicyBlocked)
);
}
#[test]
fn executor_skips_missing_targets_before_backend_calls() {
let temp = tempfile::tempdir().unwrap();
let missing_file = temp.path().join("definitely-missing.tmp");
let mut plan = CleanupPlan::empty(PlanRequest::for_platform(
Platform::Windows,
DeleteMode::RecycleBin,
));
plan.targets.push(CleanupTarget::allowed(
"windows.user-temp",
missing_file,
10,
DeleteMode::RecycleBin,
));
plan.recompute_summary();
let backend = FakeBackend::success();
execute_cleanup_plan_with_policy(&mut plan, &backend, ProtectionPolicy::new()).unwrap();
assert_eq!(backend.calls.get(), 0);
assert_eq!(plan.targets[0].status, TargetStatus::Skipped);
assert_eq!(
plan.targets[0].reason_code,
Some(CleanupTargetIssueReason::ExecutionTargetMissing)
);
assert!(
plan.targets[0]
.reason
.as_deref()
.unwrap()
.contains("path does not exist")
);
assert_eq!(plan.summary.skipped_targets, 1);
assert_eq!(
plan.summary.issue_matrix[0].reason_code,
CleanupTargetIssueReason::ExecutionTargetMissing
);
}
#[test]
fn executor_allows_app_leftover_cache_targets_after_revalidation() {
let temp = tempfile::tempdir().unwrap();
let cache_dir = temp
.path()
.join("AppData")
.join("Local")
.join("Example App")
.join("Cache");
fs::create_dir_all(&cache_dir).unwrap();
fs::write(cache_dir.join("cache.bin"), b"trash").unwrap();
let mut plan = CleanupPlan::empty(
PlanRequest::for_platform(Platform::Windows, DeleteMode::RecycleBin)
.with_workflow(CleanupWorkflow::AppLeftovers),
);
plan.targets.push(CleanupTarget::allowed(
"windows.app-leftover-local-cache",
cache_dir,
5,
DeleteMode::RecycleBin,
));
plan.recompute_summary();
let backend = FakeBackend::success();
execute_cleanup_plan_with_policy(&mut plan, &backend, ProtectionPolicy::new()).unwrap();
assert_eq!(backend.calls.get(), 1);
assert_eq!(plan.targets[0].status, TargetStatus::Completed);
assert_eq!(plan.targets[0].pending_reclaim_bytes, 5);
}
#[test]
fn executor_skips_missing_app_leftover_targets_before_backend_calls() {
let temp = tempfile::tempdir().unwrap();
let missing_cache_dir = temp
.path()
.join("AppData")
.join("Local")
.join("Example App")
.join("Cache");
let mut plan = CleanupPlan::empty(
PlanRequest::for_platform(Platform::Windows, DeleteMode::RecycleBin)
.with_workflow(CleanupWorkflow::AppLeftovers),
);
plan.targets.push(CleanupTarget::allowed(
"windows.app-leftover-local-cache",
missing_cache_dir,
5,
DeleteMode::RecycleBin,
));
plan.recompute_summary();
let backend = FakeBackend::success();
execute_cleanup_plan_with_policy(&mut plan, &backend, ProtectionPolicy::new()).unwrap();
assert_eq!(backend.calls.get(), 0);
assert_eq!(plan.targets[0].status, TargetStatus::Skipped);
assert_eq!(
plan.targets[0].reason_code,
Some(CleanupTargetIssueReason::ExecutionTargetMissing)
);
assert_eq!(
plan.targets[0].reason.as_deref(),
Some("path does not exist")
);
assert_eq!(plan.summary.skipped_targets, 1);
}
#[test]
fn executor_parallel_batches_independent_targets() {
let temp = tempfile::tempdir().unwrap();
let first = temp.path().join("first.tmp");
let second = temp.path().join("second.tmp");
fs::write(&first, b"trash").unwrap();
fs::write(&second, b"trash").unwrap();
let mut plan = CleanupPlan::empty(PlanRequest::for_platform(
Platform::Windows,
DeleteMode::RecycleBin,
));
plan.targets.push(CleanupTarget::allowed(
"windows.first",
first,
10,
DeleteMode::RecycleBin,
));
plan.targets.push(CleanupTarget::allowed(
"windows.second",
second,
20,
DeleteMode::RecycleBin,
));
plan.recompute_summary();
let backend = Arc::new(BlockingBackend::new());
let backend_for_thread = Arc::clone(&backend);
let delete_thread = thread::spawn(move || {
execute_cleanup_plan_parallel_with_policy(
&mut plan,
backend_for_thread.as_ref(),
ProtectionPolicy::new(),
)
.unwrap();
plan
});
let deadline = Instant::now() + Duration::from_secs(1);
while backend.started.load(Ordering::SeqCst) < 2 && Instant::now() < deadline {
thread::sleep(Duration::from_millis(10));
}
assert_eq!(
backend.started.load(Ordering::SeqCst),
2,
"expected both deletes to be in flight at once"
);
backend.gate.wait();
let plan = delete_thread.join().unwrap();
assert_eq!(backend.max_active.load(Ordering::SeqCst), 2);
assert_eq!(plan.summary.completed_targets, 2);
assert_eq!(plan.targets[0].status, TargetStatus::Completed);
assert_eq!(plan.targets[1].status, TargetStatus::Completed);
}
#[test]
fn executor_blocks_app_leftover_durable_paths_before_backend_calls() {
let mut plan = CleanupPlan::empty(
PlanRequest::for_platform(Platform::Windows, DeleteMode::RecycleBin)
.with_workflow(CleanupWorkflow::AppLeftovers),
);
plan.targets.push(CleanupTarget::allowed(
"windows.app-leftover-local-cache",
PathBuf::from("C:/Users/Alice/AppData/Local/Example App/Local Storage"),
10,
DeleteMode::RecycleBin,
));
plan.recompute_summary();
let backend = FakeBackend::success();
execute_cleanup_plan_with_policy(&mut plan, &backend, ProtectionPolicy::new()).unwrap();
assert_eq!(backend.calls.get(), 0);
assert_eq!(plan.targets[0].status, TargetStatus::Blocked);
assert_eq!(
plan.targets[0].reason_code,
Some(CleanupTargetIssueReason::SafetyPolicyBlocked)
);
assert!(
plan.targets[0]
.reason
.as_deref()
.unwrap()
.contains("application durable data")
);
assert_eq!(plan.summary.blocked_targets, 1);
}
struct FakeBackend {
calls: Cell<usize>,
fail: bool,
}
impl FakeBackend {
fn success() -> Self {
Self {
calls: Cell::new(0),
fail: false,
}
}
fn failure() -> Self {
Self {
calls: Cell::new(0),
fail: true,
}
}
}
impl CleanupBackend for FakeBackend {
fn delete(&self, target: &CleanupTarget) -> Result<ExecutionOutcome> {
self.calls.set(self.calls.get() + 1);
if self.fail {
return Err(rebecca_core::RebeccaError::ExecutionFailed(
"permission denied".to_string(),
));
}
Ok(ExecutionOutcome {
freed_bytes: 0,
pending_reclaim_bytes: target.estimated_bytes,
note: Some("fake delete".to_string()),
})
}
}
struct BlockingBackend {
started: AtomicUsize,
max_active: AtomicUsize,
gate: Arc<Barrier>,
}
impl BlockingBackend {
fn new() -> Self {
Self {
started: AtomicUsize::new(0),
max_active: AtomicUsize::new(0),
gate: Arc::new(Barrier::new(3)),
}
}
}
impl CleanupBackend for BlockingBackend {
fn delete(&self, target: &CleanupTarget) -> Result<ExecutionOutcome> {
let active = self.started.fetch_add(1, Ordering::SeqCst) + 1;
loop {
let current = self.max_active.load(Ordering::SeqCst);
if active <= current {
break;
}
if self
.max_active
.compare_exchange(current, active, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
{
break;
}
}
self.gate.wait();
self.started.fetch_sub(1, Ordering::SeqCst);
Ok(ExecutionOutcome {
freed_bytes: 0,
pending_reclaim_bytes: target.estimated_bytes,
note: Some("blocking delete".to_string()),
})
}
}