use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::config::{AppPaths, AppStorageLifecycle, AppStorageRetention};
use crate::error::{RebeccaError, Result};
use crate::path_overlap::paths_overlap;
use crate::scan::ScanEngine;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CachePurgeMode {
DryRun,
Delete,
}
impl CachePurgeMode {
fn deletes(self) -> bool {
matches!(self, Self::Delete)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CachePurgeReport {
pub cache_dir: PathBuf,
pub cache_dir_lifecycle: AppStorageLifecycle,
pub cache_dir_retention: AppStorageRetention,
pub cache_dir_exists: bool,
pub preserves_cache_dir: bool,
pub mode: CachePurgeMode,
pub deleted: bool,
pub summary: CachePurgeSummary,
pub entries: Vec<CachePurgeEntry>,
}
impl CachePurgeReport {
fn empty(cache_dir: PathBuf, mode: CachePurgeMode) -> Self {
Self {
cache_dir,
cache_dir_lifecycle: AppStorageLifecycle::RebuildableCache,
cache_dir_retention: AppStorageRetention::Rebuildable,
cache_dir_exists: false,
preserves_cache_dir: true,
mode,
deleted: mode.deletes(),
summary: CachePurgeSummary::default(),
entries: Vec::new(),
}
}
fn recompute_summary(&mut self) {
self.summary = CachePurgeSummary::from_entries(&self.entries);
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CachePurgeSummary {
pub total_entries: usize,
pub would_delete_entries: usize,
pub deleted_entries: usize,
pub skipped_entries: usize,
pub failed_entries: usize,
pub files: u64,
pub directories: u64,
pub estimated_bytes: u64,
pub reclaimed_bytes: u64,
pub issue_matrix: Vec<CachePurgeIssueSummary>,
}
impl CachePurgeSummary {
fn from_entries(entries: &[CachePurgeEntry]) -> Self {
let mut summary = Self::default();
let mut issue_matrix = BTreeMap::new();
for entry in entries {
summary.total_entries += 1;
summary.files = summary.files.saturating_add(entry.files);
summary.directories = summary.directories.saturating_add(entry.directories);
summary.estimated_bytes = summary
.estimated_bytes
.saturating_add(entry.estimated_bytes);
match entry.status {
CachePurgeEntryStatus::WouldDelete => summary.would_delete_entries += 1,
CachePurgeEntryStatus::Deleted => {
summary.deleted_entries += 1;
summary.reclaimed_bytes = summary
.reclaimed_bytes
.saturating_add(entry.estimated_bytes);
}
CachePurgeEntryStatus::Skipped => summary.skipped_entries += 1,
CachePurgeEntryStatus::Failed => summary.failed_entries += 1,
}
if let Some(reason_code) = entry.reason_code {
let bucket = issue_matrix
.entry((entry.status, reason_code))
.or_insert_with(|| CachePurgeIssueSummary {
status: entry.status,
reason_code,
entries: 0,
estimated_bytes: 0,
});
bucket.entries = bucket.entries.saturating_add(1);
bucket.estimated_bytes =
bucket.estimated_bytes.saturating_add(entry.estimated_bytes);
}
}
summary.issue_matrix = issue_matrix.into_values().collect();
summary
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CachePurgeIssueSummary {
pub status: CachePurgeEntryStatus,
pub reason_code: CachePurgeEntryReason,
pub entries: usize,
pub estimated_bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CachePurgeEntry {
pub path: PathBuf,
pub kind: CachePurgeEntryKind,
pub status: CachePurgeEntryStatus,
pub estimated_bytes: u64,
pub files: u64,
pub directories: u64,
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason_code: Option<CachePurgeEntryReason>,
}
impl CachePurgeEntry {
fn failed(
path: PathBuf,
kind: CachePurgeEntryKind,
reason_code: CachePurgeEntryReason,
reason: String,
) -> Self {
Self {
path,
kind,
status: CachePurgeEntryStatus::Failed,
estimated_bytes: 0,
files: 0,
directories: 0,
reason: Some(reason),
reason_code: Some(reason_code),
}
}
fn skipped(
path: PathBuf,
kind: CachePurgeEntryKind,
reason_code: CachePurgeEntryReason,
reason: impl Into<String>,
) -> Self {
Self {
path,
kind,
status: CachePurgeEntryStatus::Skipped,
estimated_bytes: 0,
files: 0,
directories: 0,
reason: Some(reason.into()),
reason_code: Some(reason_code),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CachePurgeEntryKind {
File,
Directory,
Symlink,
Other,
}
impl CachePurgeEntryKind {
pub fn label(self) -> &'static str {
match self {
Self::File => "file",
Self::Directory => "directory",
Self::Symlink => "symlink",
Self::Other => "other",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CachePurgeEntryStatus {
WouldDelete,
Deleted,
Skipped,
Failed,
}
impl CachePurgeEntryStatus {
pub fn label(self) -> &'static str {
match self {
Self::WouldDelete => "would-delete",
Self::Deleted => "deleted",
Self::Skipped => "skipped",
Self::Failed => "failed",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CachePurgeEntryReason {
SymlinkSkipped,
UnsupportedEntryType,
MetadataReadFailed,
MeasurementFailed,
DeleteFailed,
}
impl CachePurgeEntryReason {
pub fn label(self) -> &'static str {
match self {
Self::SymlinkSkipped => "symlink-skipped",
Self::UnsupportedEntryType => "unsupported-entry-type",
Self::MetadataReadFailed => "metadata-read-failed",
Self::MeasurementFailed => "measurement-failed",
Self::DeleteFailed => "delete-failed",
}
}
}
pub fn purge_app_cache(paths: &AppPaths, mode: CachePurgeMode) -> Result<CachePurgeReport> {
validate_cache_purge_boundary(paths)?;
let mut report = CachePurgeReport::empty(paths.cache_dir.clone(), mode);
let cache_metadata = match std::fs::symlink_metadata(&paths.cache_dir) {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(report),
Err(err) => {
return Err(RebeccaError::ExecutionFailed(format!(
"cache purge failed at {}: {}",
paths.cache_dir.display(),
err
)));
}
};
if cache_metadata.file_type().is_symlink() {
return Err(RebeccaError::SafetyBlocked(format!(
"cache purge refused to operate on symlinked cache directory {}",
paths.cache_dir.display()
)));
}
if !cache_metadata.is_dir() {
return Err(RebeccaError::SafetyBlocked(format!(
"cache purge requires a directory at {}",
paths.cache_dir.display()
)));
}
report.cache_dir_exists = true;
let read_dir = std::fs::read_dir(&paths.cache_dir).map_err(|err| {
RebeccaError::ExecutionFailed(format!(
"cache purge failed to read {}: {}",
paths.cache_dir.display(),
err
))
})?;
let mut entries = Vec::new();
for entry in read_dir {
let entry = entry.map_err(|err| {
RebeccaError::ExecutionFailed(format!(
"cache purge failed while reading {}: {}",
paths.cache_dir.display(),
err
))
})?;
entries.push(purge_cache_entry(entry.path(), mode));
}
entries.sort_by(|left, right| left.path.cmp(&right.path));
report.entries = entries;
report.recompute_summary();
Ok(report)
}
fn validate_cache_purge_boundary(paths: &AppPaths) -> Result<()> {
if !paths.cache_dir.is_absolute() {
return Err(RebeccaError::SafetyBlocked(format!(
"cache purge requires an absolute cache directory, got {}",
paths.cache_dir.display()
)));
}
for entry in paths
.storage_entries()
.into_iter()
.filter(|entry| entry.retention == AppStorageRetention::Preserve)
{
if paths_overlap(&paths.cache_dir, &entry.path) {
return Err(RebeccaError::SafetyBlocked(format!(
"cache directory {} overlaps preserved {} at {}",
paths.cache_dir.display(),
entry.id.label(),
entry.path.display()
)));
}
}
Ok(())
}
fn purge_cache_entry(path: PathBuf, mode: CachePurgeMode) -> CachePurgeEntry {
let metadata = match std::fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(err) => {
return CachePurgeEntry::failed(
path,
CachePurgeEntryKind::Other,
CachePurgeEntryReason::MetadataReadFailed,
err.to_string(),
);
}
};
let file_type = metadata.file_type();
if file_type.is_symlink() {
return CachePurgeEntry::skipped(
path,
CachePurgeEntryKind::Symlink,
CachePurgeEntryReason::SymlinkSkipped,
"symlink entries are skipped",
);
}
let kind = if metadata.is_file() {
CachePurgeEntryKind::File
} else if metadata.is_dir() {
CachePurgeEntryKind::Directory
} else {
return CachePurgeEntry::skipped(
path,
CachePurgeEntryKind::Other,
CachePurgeEntryReason::UnsupportedEntryType,
"unsupported cache entry type",
);
};
let report = match ScanEngine::new().measure_path(&path) {
Ok(report) => report,
Err(err) => {
return CachePurgeEntry::failed(
path,
kind,
CachePurgeEntryReason::MeasurementFailed,
err.to_string(),
);
}
};
let status = if mode.deletes() {
match delete_cache_entry(&path, kind) {
Ok(()) => CachePurgeEntryStatus::Deleted,
Err(err) => {
return CachePurgeEntry {
path,
kind,
status: CachePurgeEntryStatus::Failed,
estimated_bytes: report.bytes_scanned,
files: report.files_scanned,
directories: report.directories_scanned,
reason: Some(err.to_string()),
reason_code: Some(CachePurgeEntryReason::DeleteFailed),
};
}
}
} else {
CachePurgeEntryStatus::WouldDelete
};
CachePurgeEntry {
path,
kind,
status,
estimated_bytes: report.bytes_scanned,
files: report.files_scanned,
directories: report.directories_scanned,
reason: None,
reason_code: None,
}
}
fn delete_cache_entry(path: &Path, kind: CachePurgeEntryKind) -> std::io::Result<()> {
match kind {
CachePurgeEntryKind::File => std::fs::remove_file(path),
CachePurgeEntryKind::Directory => std::fs::remove_dir_all(path),
CachePurgeEntryKind::Symlink | CachePurgeEntryKind::Other => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::{
CachePurgeEntry, CachePurgeEntryKind, CachePurgeEntryReason, CachePurgeEntryStatus,
CachePurgeIssueSummary, CachePurgeMode, CachePurgeSummary, purge_app_cache,
};
use crate::config::{AppPaths, AppStorageLifecycle, AppStorageRetention};
fn app_paths(temp: &tempfile::TempDir) -> AppPaths {
AppPaths {
config_dir: temp.path().join("config"),
config_file: temp.path().join("config").join("config.toml"),
state_dir: temp.path().join("state"),
cache_dir: temp.path().join("cache"),
history_file: temp.path().join("state").join("history.jsonl"),
}
}
#[test]
fn cache_purge_dry_run_reports_direct_contents_without_deleting() {
let temp = tempfile::tempdir().unwrap();
let paths = app_paths(&temp);
std::fs::create_dir_all(paths.cache_dir.join("nested")).unwrap();
std::fs::write(paths.cache_dir.join("file.bin"), b"abc").unwrap();
std::fs::write(paths.cache_dir.join("nested").join("nested.bin"), b"de").unwrap();
let report = purge_app_cache(&paths, CachePurgeMode::DryRun).unwrap();
assert!(!report.deleted);
assert_eq!(report.mode, CachePurgeMode::DryRun);
assert_eq!(
report.cache_dir_lifecycle,
AppStorageLifecycle::RebuildableCache
);
assert_eq!(report.cache_dir_retention, AppStorageRetention::Rebuildable);
assert!(report.cache_dir_exists);
assert!(report.preserves_cache_dir);
assert_eq!(report.summary.total_entries, 2);
assert_eq!(report.summary.would_delete_entries, 2);
assert_eq!(report.summary.deleted_entries, 0);
assert_eq!(report.summary.files, 2);
assert_eq!(report.summary.directories, 1);
assert_eq!(report.summary.estimated_bytes, 5);
assert!(report.summary.issue_matrix.is_empty());
assert!(paths.cache_dir.join("file.bin").exists());
assert!(paths.cache_dir.join("nested").join("nested.bin").exists());
assert!(
report
.entries
.iter()
.all(|entry| entry.status == CachePurgeEntryStatus::WouldDelete)
);
}
#[test]
fn cache_purge_delete_removes_direct_contents_but_keeps_cache_dir() {
let temp = tempfile::tempdir().unwrap();
let paths = app_paths(&temp);
std::fs::create_dir_all(paths.cache_dir.join("nested")).unwrap();
std::fs::write(paths.cache_dir.join("file.bin"), b"abc").unwrap();
std::fs::write(paths.cache_dir.join("nested").join("nested.bin"), b"de").unwrap();
let report = purge_app_cache(&paths, CachePurgeMode::Delete).unwrap();
assert!(report.deleted);
assert_eq!(report.mode, CachePurgeMode::Delete);
assert_eq!(report.summary.total_entries, 2);
assert_eq!(report.summary.deleted_entries, 2);
assert_eq!(report.summary.reclaimed_bytes, 5);
assert!(report.summary.issue_matrix.is_empty());
assert!(paths.cache_dir.exists());
assert_eq!(std::fs::read_dir(&paths.cache_dir).unwrap().count(), 0);
}
#[test]
fn cache_purge_missing_cache_dir_is_empty_success() {
let temp = tempfile::tempdir().unwrap();
let paths = app_paths(&temp);
let report = purge_app_cache(&paths, CachePurgeMode::DryRun).unwrap();
assert!(!report.cache_dir_exists);
assert!(report.preserves_cache_dir);
assert_eq!(report.summary.total_entries, 0);
assert!(report.summary.issue_matrix.is_empty());
assert!(report.entries.is_empty());
}
#[test]
fn cache_purge_rejects_overlap_with_preserved_state() {
let temp = tempfile::tempdir().unwrap();
let mut paths = app_paths(&temp);
paths.cache_dir = paths.state_dir.clone();
std::fs::create_dir_all(&paths.cache_dir).unwrap();
let err = purge_app_cache(&paths, CachePurgeMode::DryRun).unwrap_err();
assert!(err.to_string().contains("overlaps preserved"));
}
#[test]
fn cache_purge_rejects_relative_cache_dir() {
let temp = tempfile::tempdir().unwrap();
let mut paths = app_paths(&temp);
paths.cache_dir = std::path::PathBuf::from("cache");
let err = purge_app_cache(&paths, CachePurgeMode::DryRun).unwrap_err();
assert!(err.to_string().contains("absolute cache directory"));
}
#[test]
fn cache_purge_summary_groups_issue_matrix_by_status_and_reason() {
let entries = vec![
CachePurgeEntry {
path: std::path::PathBuf::from("cache/a"),
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),
},
CachePurgeEntry {
path: std::path::PathBuf::from("cache/b"),
kind: CachePurgeEntryKind::Other,
status: CachePurgeEntryStatus::Skipped,
estimated_bytes: 0,
files: 0,
directories: 0,
reason: Some("unsupported cache entry type".to_string()),
reason_code: Some(CachePurgeEntryReason::UnsupportedEntryType),
},
CachePurgeEntry {
path: std::path::PathBuf::from("cache/c"),
kind: CachePurgeEntryKind::File,
status: CachePurgeEntryStatus::Failed,
estimated_bytes: 12,
files: 1,
directories: 0,
reason: Some("first failure".to_string()),
reason_code: Some(CachePurgeEntryReason::MeasurementFailed),
},
CachePurgeEntry {
path: std::path::PathBuf::from("cache/d"),
kind: CachePurgeEntryKind::Directory,
status: CachePurgeEntryStatus::Failed,
estimated_bytes: 8,
files: 2,
directories: 1,
reason: Some("second failure".to_string()),
reason_code: Some(CachePurgeEntryReason::MeasurementFailed),
},
CachePurgeEntry {
path: std::path::PathBuf::from("cache/e"),
kind: CachePurgeEntryKind::Directory,
status: CachePurgeEntryStatus::Failed,
estimated_bytes: 7,
files: 3,
directories: 1,
reason: Some("delete failed".to_string()),
reason_code: Some(CachePurgeEntryReason::DeleteFailed),
},
];
let summary = CachePurgeSummary::from_entries(&entries);
assert_eq!(summary.total_entries, 5);
assert_eq!(summary.skipped_entries, 2);
assert_eq!(summary.failed_entries, 3);
assert_eq!(
summary.issue_matrix,
vec![
CachePurgeIssueSummary {
status: CachePurgeEntryStatus::Skipped,
reason_code: CachePurgeEntryReason::SymlinkSkipped,
entries: 1,
estimated_bytes: 0,
},
CachePurgeIssueSummary {
status: CachePurgeEntryStatus::Skipped,
reason_code: CachePurgeEntryReason::UnsupportedEntryType,
entries: 1,
estimated_bytes: 0,
},
CachePurgeIssueSummary {
status: CachePurgeEntryStatus::Failed,
reason_code: CachePurgeEntryReason::MeasurementFailed,
entries: 2,
estimated_bytes: 20,
},
CachePurgeIssueSummary {
status: CachePurgeEntryStatus::Failed,
reason_code: CachePurgeEntryReason::DeleteFailed,
entries: 1,
estimated_bytes: 7,
},
]
);
}
}