| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | use std::path::{Path, PathBuf}; |
| 7 | use std::time::{Duration, SystemTime}; |
| 8 | |
| 9 | use forge_core::Result; |
| 10 | |
| 11 | use super::{BACKUP_DIR_PREFIX, MANIFEST_FILE, PARTIAL_PREFIX}; |
| 12 | |
| 13 | |
| 14 | pub const PARTIAL_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60); |
| 15 | |
| 16 | |
| 17 | #[derive(Debug, Default, PartialEq, Eq)] |
| 18 | pub struct PruneReport { |
| 19 | pub sets_removed: Vec<PathBuf>, |
| 20 | pub partials_removed: Vec<PathBuf>, |
| 21 | } |
| 22 | |
| 23 | |
| 24 | |
| 25 | pub fn prune(dir: &Path, keep: usize, now: SystemTime) -> Result<PruneReport> { |
| 26 | let mut report = PruneReport::default(); |
| 27 | let mut sets: Vec<(String, PathBuf)> = Vec::new(); |
| 28 | for entry in std::fs::read_dir(dir)? { |
| 29 | let entry = entry?; |
| 30 | let Some(name) = entry.file_name().to_str().map(str::to_owned) else { |
| 31 | continue; |
| 32 | }; |
| 33 | |
| 34 | if !entry.file_type()?.is_dir() { |
| 35 | continue; |
| 36 | } |
| 37 | let path = entry.path(); |
| 38 | if name.starts_with(PARTIAL_PREFIX) { |
| 39 | let modified = entry.metadata()?.modified()?; |
| 40 | let age = now.duration_since(modified).unwrap_or(Duration::ZERO); |
| 41 | if age >= PARTIAL_MIN_AGE { |
| 42 | std::fs::remove_dir_all(&path)?; |
| 43 | report.partials_removed.push(path); |
| 44 | } |
| 45 | } else if name.starts_with(BACKUP_DIR_PREFIX) && is_complete_set(&path) { |
| 46 | sets.push((name, path)); |
| 47 | } |
| 48 | } |
| 49 | sets.sort(); |
| 50 | let excess = sets.len().saturating_sub(keep); |
| 51 | for (_, path) in sets.into_iter().take(excess) { |
| 52 | std::fs::remove_dir_all(&path)?; |
| 53 | report.sets_removed.push(path); |
| 54 | } |
| 55 | Ok(report) |
| 56 | } |
| 57 | |
| 58 | |
| 59 | fn is_complete_set(path: &Path) -> bool { |
| 60 | std::fs::symlink_metadata(path.join(MANIFEST_FILE)).is_ok_and(|meta| meta.file_type().is_file()) |
| 61 | } |
| 62 | |
| 63 | #[cfg(test)] |
| 64 | mod tests { |
| 65 | use super::*; |
| 66 | |
| 67 | fn make_set(root: &Path, stamp: &str) -> PathBuf { |
| 68 | let set = root.join(format!("{BACKUP_DIR_PREFIX}{stamp}")); |
| 69 | std::fs::create_dir_all(&set).expect("set dir"); |
| 70 | std::fs::write(set.join(MANIFEST_FILE), b"{}").expect("manifest"); |
| 71 | set |
| 72 | } |
| 73 | |
| 74 | fn make_partial(root: &Path, name: &str, age: Duration) -> PathBuf { |
| 75 | let dir = root.join(format!("{PARTIAL_PREFIX}{name}")); |
| 76 | std::fs::create_dir_all(&dir).expect("partial dir"); |
| 77 | std::fs::write(dir.join("half.bin"), b"x").expect("file"); |
| 78 | let when = SystemTime::now() - age; |
| 79 | std::fs::File::open(&dir) |
| 80 | .expect("open") |
| 81 | .set_modified(when) |
| 82 | .expect("mtime"); |
| 83 | dir |
| 84 | } |
| 85 | |
| 86 | #[test] |
| 87 | fn keeps_the_newest_complete_sets_and_leaves_foreign_entries() { |
| 88 | let dir = tempfile::tempdir().expect("tempdir"); |
| 89 | let root = dir.path(); |
| 90 | let old = make_set(root, "20260101T000000Z"); |
| 91 | let mid = make_set(root, "20260102T000000Z"); |
| 92 | let new = make_set(root, "20260103T000000Z"); |
| 93 | |
| 94 | |
| 95 | std::fs::write(root.join("notes.txt"), b"keep me").expect("file"); |
| 96 | std::fs::create_dir_all(root.join("photos")).expect("dir"); |
| 97 | let incomplete = root.join("forge-backup-20250101T000000Z"); |
| 98 | std::fs::create_dir_all(&incomplete).expect("incomplete"); |
| 99 | std::fs::write(root.join("forge-backup-20240101T000000Z"), b"file").expect("file"); |
| 100 | |
| 101 | let report = prune(root, 2, SystemTime::now()).expect("prune"); |
| 102 | assert_eq!(report.sets_removed, vec![old.clone()]); |
| 103 | assert!(report.partials_removed.is_empty()); |
| 104 | assert!(!old.exists()); |
| 105 | assert!(mid.exists() && new.exists()); |
| 106 | assert!(root.join("notes.txt").exists()); |
| 107 | assert!(root.join("photos").is_dir()); |
| 108 | assert!( |
| 109 | incomplete.is_dir(), |
| 110 | "a set without a manifest is not ours to delete" |
| 111 | ); |
| 112 | assert!(root.join("forge-backup-20240101T000000Z").is_file()); |
| 113 | } |
| 114 | |
| 115 | #[test] |
| 116 | fn keep_larger_than_the_count_removes_nothing() { |
| 117 | let dir = tempfile::tempdir().expect("tempdir"); |
| 118 | make_set(dir.path(), "20260101T000000Z"); |
| 119 | let report = prune(dir.path(), 7, SystemTime::now()).expect("prune"); |
| 120 | assert_eq!(report, PruneReport::default()); |
| 121 | } |
| 122 | |
| 123 | #[test] |
| 124 | fn removes_only_stale_partials() { |
| 125 | let dir = tempfile::tempdir().expect("tempdir"); |
| 126 | let root = dir.path(); |
| 127 | let stale = make_partial( |
| 128 | root, |
| 129 | "forge-backup-20260101T000000Z-1", |
| 130 | Duration::from_secs(3 * 86400), |
| 131 | ); |
| 132 | let fresh = make_partial( |
| 133 | root, |
| 134 | "forge-backup-20260102T000000Z-2", |
| 135 | Duration::from_secs(3600), |
| 136 | ); |
| 137 | let report = prune(root, 7, SystemTime::now()).expect("prune"); |
| 138 | assert_eq!(report.partials_removed, vec![stale.clone()]); |
| 139 | assert!(!stale.exists()); |
| 140 | assert!(fresh.exists(), "a recent partial may still be in progress"); |
| 141 | } |
| 142 | |
| 143 | #[cfg(unix)] |
| 144 | #[test] |
| 145 | fn symlinks_are_never_followed_or_removed() { |
| 146 | let dir = tempfile::tempdir().expect("tempdir"); |
| 147 | let root = dir.path().join("backups"); |
| 148 | std::fs::create_dir_all(&root).expect("root"); |
| 149 | let outside = dir.path().join("outside"); |
| 150 | let target = make_set(&outside, "20260101T000000Z"); |
| 151 | std::os::unix::fs::symlink(&target, root.join("forge-backup-20250101T000000Z")) |
| 152 | .expect("symlink"); |
| 153 | let report = prune(&root, 1, SystemTime::now()).expect("prune"); |
| 154 | assert!(report.sets_removed.is_empty()); |
| 155 | assert!( |
| 156 | target.join(MANIFEST_FILE).is_file(), |
| 157 | "the symlink target is untouched" |
| 158 | ); |
| 159 | assert!(root.join("forge-backup-20250101T000000Z").is_symlink()); |
| 160 | } |
| 161 | } |