use std::path::{Path, PathBuf};
use crate::audit::{AuditAction, AuditActorSource, AuditEvent, AuditService};
use crate::db::Database;
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RemovalMethod {
#[default]
Trash,
PermanentDelete,
}
impl RemovalMethod {
#[must_use]
pub const fn past_tense(self) -> &'static str {
match self {
Self::Trash => "Trashed",
Self::PermanentDelete => "Deleted",
}
}
}
pub fn check_removability(path: &Path) -> Result<()> {
if !path.exists() {
return Err(Error::PathNotFound(path.to_path_buf()));
}
Ok(())
}
pub fn remove(path: &Path, method: RemovalMethod) -> Result<RemovalOutcome> {
check_removability(path)?;
match method {
RemovalMethod::Trash => trash(path),
RemovalMethod::PermanentDelete => permanent_delete(path),
}
}
fn trash(path: &Path) -> Result<RemovalOutcome> {
match trash::delete(path) {
Ok(()) => {
tracing::info!(?path, "Moved to trash successfully");
Ok(RemovalOutcome::Trashed)
}
Err(e) => {
tracing::warn!(?path, error = %e, "Failed to move to trash");
Err(Error::Trash {
path: path.to_path_buf(),
message: e.to_string(),
})
}
}
}
fn permanent_delete(path: &Path) -> Result<RemovalOutcome> {
let result = if path.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
};
match result {
Ok(()) => {
tracing::info!(?path, "Permanently deleted");
Ok(RemovalOutcome::Deleted)
}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
tracing::warn!(?path, "Permission denied");
Err(Error::PermissionDenied(path.to_path_buf()))
}
Err(e) => Err(Error::Filesystem {
path: path.to_path_buf(),
source: e,
}),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[must_use = "removal outcome should be checked"]
#[non_exhaustive]
pub enum RemovalOutcome {
Trashed,
Deleted,
}
#[derive(Debug, Clone)]
pub struct DryRunFailure {
pub path: PathBuf,
pub reason: String,
}
#[derive(Debug, Clone)]
#[must_use]
pub struct DryRunResult {
pub removable_count: usize,
pub total_count: usize,
pub failures: Vec<DryRunFailure>,
}
pub fn dry_run_approved(db: &Database, root_id: i64) -> Result<DryRunResult> {
let approved = db.list_entries_by_root_and_status(root_id, "approved")?;
let total_count = approved.len();
let mut removable_count = 0;
let mut failures = Vec::new();
for entry in &approved {
match check_removability(&entry.path) {
Ok(()) => removable_count += 1,
Err(e) => {
failures.push(DryRunFailure {
path: entry.path.clone(),
reason: e.to_string(),
});
}
}
}
Ok(DryRunResult {
removable_count,
total_count,
failures,
})
}
pub fn remove_approved(db: &Database) -> Result<RemovalSummary> {
let audit = AuditService::new(db);
let user = AuditService::current_user();
let approved_entries = db.list_entries(Some("approved"))?;
let mut removed_count = 0;
let mut blocked_count = 0;
let mut total_bytes_freed = 0i64;
for entry in approved_entries {
let path = entry.path.clone();
tracing::debug!(path = ?entry.path, is_dir = entry.is_dir, "Processing approved entry for removal");
match remove(&path, RemovalMethod::PermanentDelete) {
Ok(RemovalOutcome::Deleted) => {
db.update_entry_status(entry.id, "removed")?;
removed_count += 1;
total_bytes_freed += entry.size_bytes;
tracing::info!(path = ?entry.path, bytes = entry.size_bytes, "Entry removed successfully");
let details = format!("Permanently deleted {} bytes", entry.size_bytes);
audit.record_event(&AuditEvent {
user: &user,
actor_source: AuditActorSource::Daemon,
action: AuditAction::Remove,
target_path: Some(entry.path.as_path()),
details: Some(&details),
entry_id: Some(entry.id),
root_id: Some(entry.root_id),
status_before: Some("approved"),
status_after: Some("removed"),
outcome: Some("removed"),
})?;
}
Ok(RemovalOutcome::Trashed) => {
tracing::warn!(path = ?entry.path, "Unexpected removal outcome: Trashed");
}
Err(Error::PermissionDenied(_)) => {
db.update_entry_status(entry.id, "blocked")?;
blocked_count += 1;
tracing::warn!(path = ?entry.path, "Removal blocked: permission denied");
audit.record_event(&AuditEvent {
user: &user,
actor_source: AuditActorSource::Daemon,
action: AuditAction::Remove,
target_path: Some(entry.path.as_path()),
details: Some("Blocked: permission denied"),
entry_id: Some(entry.id),
root_id: Some(entry.root_id),
status_before: Some("approved"),
status_after: Some("blocked"),
outcome: Some("blocked"),
})?;
}
Err(e) => {
db.update_entry_status(entry.id, "blocked")?;
blocked_count += 1;
tracing::warn!(path = ?entry.path, error = %e, "Removal blocked: filesystem error");
let details = format!("Blocked: {e}");
audit.record_event(&AuditEvent {
user: &user,
actor_source: AuditActorSource::Daemon,
action: AuditAction::Remove,
target_path: Some(entry.path.as_path()),
details: Some(&details),
entry_id: Some(entry.id),
root_id: Some(entry.root_id),
status_before: Some("approved"),
status_after: Some("blocked"),
outcome: Some("blocked"),
})?;
}
}
}
Ok(RemovalSummary {
removed_count,
blocked_count,
total_bytes_freed,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[must_use]
#[non_exhaustive]
pub struct RemovalSummary {
removed_count: usize,
blocked_count: usize,
total_bytes_freed: i64,
}
impl RemovalSummary {
#[must_use]
pub const fn removed_count(&self) -> usize {
self.removed_count
}
#[must_use]
pub const fn blocked_count(&self) -> usize {
self.blocked_count
}
#[must_use]
pub const fn total_bytes_freed(&self) -> i64 {
self.total_bytes_freed
}
#[allow(dead_code)]
pub const fn empty() -> Self {
Self {
removed_count: 0,
blocked_count: 0,
total_bytes_freed: 0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::AuditService;
use crate::db::Database;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use tempfile::TempDir;
fn temp_database() -> (Database, TempDir) {
let temp_dir = TempDir::with_prefix("stagecrew-removal-test-").expect(
"failed to create temp directory for removal test - check disk space and permissions",
);
let db_path = temp_dir.path().join("test.db");
let db = Database::open(&db_path)
.expect("failed to open test database - check permissions and disk space");
(db, temp_dir)
}
fn create_test_directory(root: &Path, name: &str, file_count: usize) -> (PathBuf, i64) {
let dir_path = root.join(name);
fs::create_dir(&dir_path)
.expect("failed to create test directory structure - check disk space and permissions");
let mut total_size = 0i64;
for i in 0..file_count {
let file_path = dir_path.join(format!("file{i}.txt"));
let content = format!("Test content {i}");
fs::write(&file_path, &content)
.expect("failed to write test data to file - disk may be full");
#[allow(clippy::cast_possible_wrap)]
{
total_size += content.len() as i64;
}
}
(dir_path, total_size)
}
#[test]
fn remove_approved_processes_approved_entries() {
let (db, _temp_dir) = temp_database();
let test_root = TempDir::with_prefix("stagecrew-removal-files-").expect(
"failed to create temp directory for removal test - check disk space and permissions",
);
let (dir1_path, dir1_size) = create_test_directory(test_root.path(), "dir1", 3);
let (dir2_path, dir2_size) = create_test_directory(test_root.path(), "dir2", 2);
let root_id = db
.insert_root(test_root.path())
.expect("failed to insert root - database connection may be lost");
let now = jiff::Timestamp::now().as_second();
let entry1_id = db
.upsert_entry(
root_id,
&dir1_path,
test_root.path(),
true,
dir1_size,
Some(now),
)
.expect("failed to insert test entry - database connection may be lost");
let entry2_id = db
.upsert_entry(
root_id,
&dir2_path,
test_root.path(),
true,
dir2_size,
Some(now),
)
.expect("failed to insert test entry - database connection may be lost");
db.update_entry_status(entry1_id, "approved")
.expect("failed to update entry status - database connection may be lost");
db.update_entry_status(entry2_id, "approved")
.expect("failed to update entry status - database connection may be lost");
assert!(dir1_path.exists());
assert!(dir2_path.exists());
let summary = remove_approved(&db)
.expect("failed to remove approved entries - check permissions and disk space");
assert_eq!(summary.removed_count(), 2, "Expected 2 entries removed");
assert_eq!(summary.blocked_count(), 0, "Expected no blocked entries");
assert_eq!(
summary.total_bytes_freed(),
dir1_size + dir2_size,
"Expected total bytes freed to match sum of entry sizes"
);
assert!(!dir1_path.exists(), "Directory should be removed");
assert!(!dir2_path.exists(), "Directory should be removed");
let entry1 = db
.get_entry_by_path(&dir1_path)
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after insertion - verify test database is working");
assert_eq!(entry1.status, "removed", "Status should be 'removed'");
let entry2 = db
.get_entry_by_path(&dir2_path)
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after insertion - verify test database is working");
assert_eq!(entry2.status, "removed", "Status should be 'removed'");
let audit = AuditService::new(&db);
let entries = audit
.list_recent(10)
.expect("failed to query recent audit entries - database connection may be lost");
assert_eq!(entries.len(), 2, "Expected 2 audit entries");
for entry in &entries {
assert_eq!(entry.action, "remove");
assert!(entry.details.is_some());
assert!(
entry
.details
.as_ref()
.expect("expected audit entry to have details - verify audit trail is working")
.contains("Permanently deleted")
);
}
}
#[test]
fn remove_approved_handles_permission_denied() {
let (db, _temp_dir) = temp_database();
let test_root = TempDir::with_prefix("stagecrew-removal-files-").expect(
"failed to create temp directory for removal test - check disk space and permissions",
);
let (dir_path, dir_size) = create_test_directory(test_root.path(), "protected", 2);
let mut perms = fs::metadata(&dir_path)
.expect("failed to read file permissions - check file exists and is accessible")
.permissions();
perms.set_mode(0o444); fs::set_permissions(&dir_path, perms)
.expect("failed to set file permissions for test - check filesystem support");
let root_id = db
.insert_root(test_root.path())
.expect("failed to insert root - database connection may be lost");
let now = jiff::Timestamp::now().as_second();
let entry_id = db
.upsert_entry(
root_id,
&dir_path,
test_root.path(),
true,
dir_size,
Some(now),
)
.expect("failed to insert test entry - database connection may be lost");
db.update_entry_status(entry_id, "approved")
.expect("failed to update entry status - database connection may be lost");
let summary = remove_approved(&db)
.expect("failed to remove approved entries - check permissions and disk space");
assert_eq!(summary.removed_count(), 0, "Expected no entries removed");
assert_eq!(summary.blocked_count(), 1, "Expected 1 blocked entry");
assert_eq!(summary.total_bytes_freed(), 0, "Expected no bytes freed");
assert!(dir_path.exists(), "Directory should still exist");
let entry = db
.get_entry_by_path(&dir_path)
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after insertion - verify test database is working");
assert_eq!(entry.status, "blocked", "Status should be 'blocked'");
let audit = AuditService::new(&db);
let entries = audit
.list_recent(10)
.expect("failed to query recent audit entries - database connection may be lost");
assert_eq!(entries.len(), 1, "Expected 1 audit entry");
assert_eq!(entries[0].action, "remove");
assert!(
entries[0]
.details
.as_ref()
.expect("expected audit entry to have details - verify audit trail is working")
.contains("permission denied")
);
let mut perms = fs::metadata(&dir_path)
.expect("failed to read file permissions - check file exists and is accessible")
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&dir_path, perms)
.expect("failed to set file permissions for test - check filesystem support");
}
#[test]
fn remove_approved_handles_nonexistent_path() {
let (db, _temp_dir) = temp_database();
let root_id = db
.insert_root(Path::new("/nonexistent"))
.expect("failed to insert root - database connection may be lost");
let entry_path = Path::new("/nonexistent/path/to/directory");
let now = jiff::Timestamp::now().as_second();
let entry_id = db
.upsert_entry(
root_id,
entry_path,
Path::new("/nonexistent/path/to"),
true,
1024,
Some(now),
)
.expect("failed to insert test entry - database connection may be lost");
db.update_entry_status(entry_id, "approved")
.expect("failed to update entry status - database connection may be lost");
let summary = remove_approved(&db)
.expect("failed to remove approved entries - check permissions and disk space");
assert_eq!(summary.removed_count(), 0, "Expected no entries removed");
assert_eq!(summary.blocked_count(), 1, "Expected 1 blocked entry");
assert_eq!(summary.total_bytes_freed(), 0, "Expected no bytes freed");
let entry = db
.get_entry_by_path(entry_path)
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after insertion - verify test database is working");
assert_eq!(entry.status, "blocked", "Status should be 'blocked'");
let audit = AuditService::new(&db);
let entries = audit
.list_recent(10)
.expect("failed to query recent audit entries - database connection may be lost");
assert_eq!(entries.len(), 1, "Expected 1 audit entry");
assert_eq!(entries[0].action, "remove");
assert!(entries[0].details.is_some());
}
#[test]
fn remove_approved_handles_mixed_success_and_failure() {
let (db, _temp_dir) = temp_database();
let test_root = TempDir::with_prefix("stagecrew-removal-files-").expect(
"failed to create temp directory for removal test - check disk space and permissions",
);
let (dir1_path, dir1_size) = create_test_directory(test_root.path(), "normal", 2);
let (dir2_path, dir2_size) = create_test_directory(test_root.path(), "protected", 2);
let mut perms = fs::metadata(&dir2_path)
.expect("failed to read file permissions - check file exists and is accessible")
.permissions();
perms.set_mode(0o444);
fs::set_permissions(&dir2_path, perms)
.expect("failed to set file permissions for test - check filesystem support");
let root_id = db
.insert_root(test_root.path())
.expect("failed to insert root - database connection may be lost");
let now = jiff::Timestamp::now().as_second();
let entry1_id = db
.upsert_entry(
root_id,
&dir1_path,
test_root.path(),
true,
dir1_size,
Some(now),
)
.expect("failed to insert test entry - database connection may be lost");
let entry2_id = db
.upsert_entry(
root_id,
&dir2_path,
test_root.path(),
true,
dir2_size,
Some(now),
)
.expect("failed to insert test entry - database connection may be lost");
db.update_entry_status(entry1_id, "approved")
.expect("failed to update entry status - database connection may be lost");
db.update_entry_status(entry2_id, "approved")
.expect("failed to update entry status - database connection may be lost");
let summary = remove_approved(&db)
.expect("failed to remove approved entries - check permissions and disk space");
assert_eq!(summary.removed_count(), 1, "Expected 1 entry removed");
assert_eq!(summary.blocked_count(), 1, "Expected 1 blocked entry");
assert_eq!(
summary.total_bytes_freed(),
dir1_size,
"Expected bytes freed from successful removal only"
);
assert!(!dir1_path.exists());
assert!(dir2_path.exists());
let entry1 = db
.get_entry_by_path(&dir1_path)
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after insertion - verify test database is working");
assert_eq!(entry1.status, "removed");
let entry2 = db
.get_entry_by_path(&dir2_path)
.expect("failed to query entry from database - connection may be lost")
.expect("expected entry to exist after insertion - verify test database is working");
assert_eq!(entry2.status, "blocked");
let mut perms = fs::metadata(&dir2_path)
.expect("failed to read file permissions - check file exists and is accessible")
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&dir2_path, perms)
.expect("failed to set file permissions for test - check filesystem support");
}
#[test]
fn remove_approved_returns_empty_summary_when_no_approved() {
let (db, _temp_dir) = temp_database();
let root_id = db
.insert_root(Path::new("/data"))
.expect("failed to insert root - database connection may be lost");
let now = jiff::Timestamp::now().as_second();
let entry1_id = db
.upsert_entry(
root_id,
Path::new("/data/path1"),
Path::new("/data"),
false,
1024,
Some(now),
)
.expect("failed to insert test entry - database connection may be lost");
let entry2_id = db
.upsert_entry(
root_id,
Path::new("/data/path2"),
Path::new("/data"),
false,
2048,
Some(now),
)
.expect("failed to insert test entry - database connection may be lost");
db.update_entry_status(entry1_id, "tracked")
.expect("failed to update entry status - database connection may be lost");
db.update_entry_status(entry2_id, "pending")
.expect("failed to update entry status - database connection may be lost");
let summary = remove_approved(&db)
.expect("failed to remove approved entries - check permissions and disk space");
assert_eq!(summary.removed_count(), 0);
assert_eq!(summary.blocked_count(), 0);
assert_eq!(summary.total_bytes_freed(), 0);
let audit = AuditService::new(&db);
let entries = audit
.list_recent(10)
.expect("failed to query recent audit entries - database connection may be lost");
assert_eq!(entries.len(), 0, "Expected no audit entries");
}
#[test]
fn remove_approved_records_audit_entries_with_entry_id() {
let (db, _temp_dir) = temp_database();
let test_root = TempDir::with_prefix("stagecrew-removal-files-").expect(
"failed to create temp directory for removal test - check disk space and permissions",
);
let (dir_path, dir_size) = create_test_directory(test_root.path(), "dir", 2);
let root_id = db
.insert_root(test_root.path())
.expect("failed to insert root - database connection may be lost");
let now = jiff::Timestamp::now().as_second();
let entry_id = db
.upsert_entry(
root_id,
&dir_path,
test_root.path(),
true,
dir_size,
Some(now),
)
.expect("failed to insert test entry - database connection may be lost");
db.update_entry_status(entry_id, "approved")
.expect("failed to update entry status - database connection may be lost");
let _summary = remove_approved(&db)
.expect("failed to remove approved entries - check permissions and disk space");
let audit = AuditService::new(&db);
let entries = audit
.list_recent(10)
.expect("failed to query recent audit entries - database connection may be lost");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].entry_id, Some(entry_id));
assert_eq!(
entries[0].target_path,
Some(dir_path.to_string_lossy().into_owned())
);
}
#[test]
fn check_removability_succeeds_for_existing_file() {
let temp_dir = TempDir::with_prefix("stagecrew-removability-test-").expect(
"failed to create temp directory for removability test - check disk space and permissions",
);
let file_path = temp_dir.path().join("exists.txt");
fs::write(&file_path, "test content")
.expect("failed to write test file - disk may be full");
assert!(
check_removability(&file_path).is_ok(),
"Existing file should pass removability check"
);
}
#[test]
fn check_removability_succeeds_for_existing_directory() {
let temp_dir = TempDir::with_prefix("stagecrew-removability-test-").expect(
"failed to create temp directory for removability test - check disk space and permissions",
);
let dir_path = temp_dir.path().join("subdir");
fs::create_dir(&dir_path)
.expect("failed to create test directory - check disk space and permissions");
assert!(
check_removability(&dir_path).is_ok(),
"Existing directory should pass removability check"
);
}
#[test]
fn check_removability_fails_for_nonexistent_path() {
let result = check_removability(Path::new("/nonexistent/path/to/file.txt"));
assert!(result.is_err(), "Nonexistent path should fail");
assert!(
matches!(result, Err(Error::PathNotFound(_))),
"Error should be PathNotFound"
);
}
#[test]
fn dry_run_approved_with_no_approved_entries() {
let (db, _temp_dir) = temp_database();
let root_id = db
.insert_root(Path::new("/data"))
.expect("failed to insert root - database connection may be lost");
let now = jiff::Timestamp::now().as_second();
let entry_id = db
.upsert_entry(
root_id,
Path::new("/data/file.txt"),
Path::new("/data"),
false,
1024,
Some(now),
)
.expect("failed to insert test entry - database connection may be lost");
db.update_entry_status(entry_id, "tracked")
.expect("failed to update entry status - database connection may be lost");
let result = dry_run_approved(&db, root_id)
.expect("dry run should succeed even with no approved entries");
assert_eq!(result.total_count, 0);
assert_eq!(result.removable_count, 0);
assert!(result.failures.is_empty());
}
#[test]
fn dry_run_approved_all_entries_exist() {
let (db, _temp_dir) = temp_database();
let test_root = TempDir::with_prefix("stagecrew-dryrun-test-").expect(
"failed to create temp directory for dry run test - check disk space and permissions",
);
let (dir1_path, dir1_size) = create_test_directory(test_root.path(), "dir1", 2);
let (dir2_path, dir2_size) = create_test_directory(test_root.path(), "dir2", 2);
let root_id = db
.insert_root(test_root.path())
.expect("failed to insert root - database connection may be lost");
let now = jiff::Timestamp::now().as_second();
let id1 = db
.upsert_entry(
root_id,
&dir1_path,
test_root.path(),
true,
dir1_size,
Some(now),
)
.expect("failed to insert test entry - database connection may be lost");
let id2 = db
.upsert_entry(
root_id,
&dir2_path,
test_root.path(),
true,
dir2_size,
Some(now),
)
.expect("failed to insert test entry - database connection may be lost");
db.update_entry_status(id1, "approved")
.expect("failed to update entry status - database connection may be lost");
db.update_entry_status(id2, "approved")
.expect("failed to update entry status - database connection may be lost");
let result = dry_run_approved(&db, root_id)
.expect("dry run should succeed when entries exist on disk");
assert_eq!(result.total_count, 2);
assert_eq!(result.removable_count, 2);
assert!(result.failures.is_empty());
}
#[test]
fn dry_run_approved_mixed_existing_and_nonexistent() {
let (db, _temp_dir) = temp_database();
let test_root = TempDir::with_prefix("stagecrew-dryrun-test-").expect(
"failed to create temp directory for dry run test - check disk space and permissions",
);
let (dir_path, dir_size) = create_test_directory(test_root.path(), "exists", 2);
let missing_path = test_root.path().join("gone");
let root_id = db
.insert_root(test_root.path())
.expect("failed to insert root - database connection may be lost");
let now = jiff::Timestamp::now().as_second();
let id1 = db
.upsert_entry(
root_id,
&dir_path,
test_root.path(),
true,
dir_size,
Some(now),
)
.expect("failed to insert test entry - database connection may be lost");
let id2 = db
.upsert_entry(root_id, &missing_path, test_root.path(), true, 0, Some(now))
.expect("failed to insert test entry - database connection may be lost");
db.update_entry_status(id1, "approved")
.expect("failed to update entry status - database connection may be lost");
db.update_entry_status(id2, "approved")
.expect("failed to update entry status - database connection may be lost");
let result =
dry_run_approved(&db, root_id).expect("dry run should succeed even with mixed results");
assert_eq!(result.total_count, 2);
assert_eq!(result.removable_count, 1);
assert_eq!(result.failures.len(), 1);
assert_eq!(result.failures[0].path, missing_path);
}
#[test]
fn dry_run_approved_ignores_non_approved_entries() {
let (db, _temp_dir) = temp_database();
let test_root = TempDir::with_prefix("stagecrew-dryrun-test-").expect(
"failed to create temp directory for dry run test - check disk space and permissions",
);
let (dir_path, dir_size) = create_test_directory(test_root.path(), "dir", 2);
let root_id = db
.insert_root(test_root.path())
.expect("failed to insert root - database connection may be lost");
let now = jiff::Timestamp::now().as_second();
let id1 = db
.upsert_entry(
root_id,
&dir_path,
test_root.path(),
true,
dir_size,
Some(now),
)
.expect("failed to insert test entry - database connection may be lost");
let _ = id1;
let result = dry_run_approved(&db, root_id)
.expect("dry run should succeed with no approved entries");
assert_eq!(
result.total_count, 0,
"Tracked entries should not be checked"
);
}
}