1use anyhow::{Context, Result};
2use chrono::{DateTime, Datelike, Local, NaiveDate, Timelike};
3use serde::Deserialize;
4use std::collections::{HashMap, HashSet};
5use std::fmt;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum RetentionReason {
13 KeepLast,
15 Hourly,
17 Daily,
19 Weekly,
21 Monthly,
23 Yearly,
25}
26
27impl fmt::Display for RetentionReason {
28 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29 match self {
30 Self::KeepLast => write!(f, "keep-last"),
31 Self::Hourly => write!(f, "hourly"),
32 Self::Daily => write!(f, "daily"),
33 Self::Weekly => write!(f, "weekly"),
34 Self::Monthly => write!(f, "monthly"),
35 Self::Yearly => write!(f, "yearly"),
36 }
37 }
38}
39
40#[derive(Debug, Clone)]
41pub struct FileInfo {
42 pub path: PathBuf,
43 pub created: DateTime<Local>,
44}
45
46#[derive(Debug, Clone, PartialEq)]
47pub struct RetentionConfig {
48 pub keep_last: usize,
49 pub keep_hourly: u32,
50 pub keep_daily: u32,
51 pub keep_weekly: u32,
52 pub keep_monthly: u32,
53 pub keep_yearly: u32,
54}
55
56impl Default for RetentionConfig {
57 fn default() -> Self {
58 Self {
59 keep_last: 5,
60 keep_hourly: 24,
61 keep_daily: 7,
62 keep_weekly: 4,
63 keep_monthly: 12,
64 keep_yearly: 10,
65 }
66 }
67}
68
69#[derive(Debug, Clone, Default, Deserialize)]
72#[serde(rename_all = "kebab-case")]
73pub struct RetentionFileConfig {
74 pub keep_last: Option<usize>,
75 pub keep_hourly: Option<u32>,
76 pub keep_daily: Option<u32>,
77 pub keep_weekly: Option<u32>,
78 pub keep_monthly: Option<u32>,
79 pub keep_yearly: Option<u32>,
80}
81
82pub const RETENTION_FILE_NAME: &str = ".retention";
84
85pub fn read_retention_file(dir: &Path) -> Result<Option<RetentionFileConfig>> {
94 let file_path = dir.join(RETENTION_FILE_NAME);
95
96 if !file_path.exists() {
97 return Ok(None);
98 }
99
100 let contents = fs::read_to_string(&file_path)
101 .with_context(|| format!("Failed to read {}", file_path.display()))?;
102
103 let config: RetentionFileConfig = toml::from_str(&contents)
104 .with_context(|| format!("Failed to parse {} as TOML", file_path.display()))?;
105
106 Ok(Some(config))
107}
108
109#[must_use]
116pub fn resolve_config(
117 cli_keep_last: Option<usize>,
118 cli_keep_hourly: Option<u32>,
119 cli_keep_daily: Option<u32>,
120 cli_keep_weekly: Option<u32>,
121 cli_keep_monthly: Option<u32>,
122 cli_keep_yearly: Option<u32>,
123 file_config: Option<&RetentionFileConfig>,
124) -> RetentionConfig {
125 let defaults = RetentionConfig::default();
126
127 RetentionConfig {
128 keep_last: cli_keep_last
129 .or_else(|| file_config.and_then(|f| f.keep_last))
130 .unwrap_or(defaults.keep_last),
131 keep_hourly: cli_keep_hourly
132 .or_else(|| file_config.and_then(|f| f.keep_hourly))
133 .unwrap_or(defaults.keep_hourly),
134 keep_daily: cli_keep_daily
135 .or_else(|| file_config.and_then(|f| f.keep_daily))
136 .unwrap_or(defaults.keep_daily),
137 keep_weekly: cli_keep_weekly
138 .or_else(|| file_config.and_then(|f| f.keep_weekly))
139 .unwrap_or(defaults.keep_weekly),
140 keep_monthly: cli_keep_monthly
141 .or_else(|| file_config.and_then(|f| f.keep_monthly))
142 .unwrap_or(defaults.keep_monthly),
143 keep_yearly: cli_keep_yearly
144 .or_else(|| file_config.and_then(|f| f.keep_yearly))
145 .unwrap_or(defaults.keep_yearly),
146 }
147}
148
149pub fn get_file_creation_time(path: &Path) -> Result<DateTime<Local>> {
157 let metadata = fs::metadata(path).context("Failed to read file metadata")?;
158 let mtime = metadata
159 .modified()
160 .or_else(|_| metadata.created())
161 .context("Failed to get file modification/creation time")?;
162 Ok(DateTime::from(mtime))
163}
164
165pub fn scan_files(dir: &Path) -> Result<Vec<FileInfo>> {
170 let mut files = Vec::new();
171
172 for entry in fs::read_dir(dir).context("Failed to read directory")? {
173 let entry = entry.context("Failed to read directory entry")?;
174 let path = entry.path();
175
176 if path.is_dir()
178 || path
179 .file_name()
180 .is_some_and(|n| n.to_string_lossy().starts_with('.'))
181 {
182 continue;
183 }
184
185 match get_file_creation_time(&path) {
186 Ok(created) => files.push(FileInfo { path, created }),
187 Err(e) => eprintln!("Warning: Skipping {}: {e}", path.display()),
188 }
189 }
190
191 files.sort_by(|a, b| b.created.cmp(&a.created));
193 Ok(files)
194}
195
196fn get_hour_key(dt: DateTime<Local>) -> (i32, u32, u32, u32) {
198 (dt.year(), dt.month(), dt.day(), dt.hour())
199}
200
201fn get_week_key(date: NaiveDate) -> (i32, u32) {
203 (date.iso_week().year(), date.iso_week().week())
204}
205
206fn get_month_key(date: NaiveDate) -> (i32, u32) {
207 (date.year(), date.month())
208}
209
210fn get_year_key(date: NaiveDate) -> i32 {
211 date.year()
212}
213
214#[must_use]
221pub fn select_files_to_keep_with_reasons(
222 files: &[FileInfo],
223 config: &RetentionConfig,
224) -> HashMap<usize, HashSet<RetentionReason>> {
225 let mut keep_reasons: HashMap<usize, HashSet<RetentionReason>> = HashMap::new();
226
227 let mut add_reason = |i: usize, reason: RetentionReason| {
229 keep_reasons.entry(i).or_default().insert(reason);
230 };
231
232 for i in 0..config.keep_last.min(files.len()) {
234 add_reason(i, RetentionReason::KeepLast);
235 }
236
237 if config.keep_hourly > 0 {
240 let mut allowed_hours: HashSet<(i32, u32, u32, u32)> = HashSet::new();
242 for file in files {
243 allowed_hours.insert(get_hour_key(file.created));
244 if allowed_hours.len() >= config.keep_hourly as usize {
245 break;
246 }
247 }
248 let mut covered_hours: HashSet<(i32, u32, u32, u32)> = HashSet::new();
250 for (i, file) in files.iter().enumerate().rev() {
251 let hour_key = get_hour_key(file.created);
252 if allowed_hours.contains(&hour_key) && !covered_hours.contains(&hour_key) {
253 covered_hours.insert(hour_key);
254 add_reason(i, RetentionReason::Hourly);
255 }
256 }
257 }
258
259 if config.keep_daily > 0 {
262 let mut allowed_days: HashSet<NaiveDate> = HashSet::new();
264 for file in files {
265 allowed_days.insert(file.created.date_naive());
266 if allowed_days.len() >= config.keep_daily as usize {
267 break;
268 }
269 }
270 let mut covered_days: HashSet<NaiveDate> = HashSet::new();
272 for (i, file) in files.iter().enumerate().rev() {
273 let file_date = file.created.date_naive();
274 if allowed_days.contains(&file_date) && !covered_days.contains(&file_date) {
275 covered_days.insert(file_date);
276 add_reason(i, RetentionReason::Daily);
277 }
278 }
279 }
280
281 if config.keep_weekly > 0 {
284 let mut allowed_weeks: HashSet<(i32, u32)> = HashSet::new();
286 for file in files {
287 allowed_weeks.insert(get_week_key(file.created.date_naive()));
288 if allowed_weeks.len() >= config.keep_weekly as usize {
289 break;
290 }
291 }
292 let mut covered_weeks: HashSet<(i32, u32)> = HashSet::new();
294 for (i, file) in files.iter().enumerate().rev() {
295 let week_key = get_week_key(file.created.date_naive());
296 if allowed_weeks.contains(&week_key) && !covered_weeks.contains(&week_key) {
297 covered_weeks.insert(week_key);
298 add_reason(i, RetentionReason::Weekly);
299 }
300 }
301 }
302
303 if config.keep_monthly > 0 {
306 let mut allowed_months: HashSet<(i32, u32)> = HashSet::new();
308 for file in files {
309 allowed_months.insert(get_month_key(file.created.date_naive()));
310 if allowed_months.len() >= config.keep_monthly as usize {
311 break;
312 }
313 }
314 let mut covered_months: HashSet<(i32, u32)> = HashSet::new();
316 for (i, file) in files.iter().enumerate().rev() {
317 let month_key = get_month_key(file.created.date_naive());
318 if allowed_months.contains(&month_key) && !covered_months.contains(&month_key) {
319 covered_months.insert(month_key);
320 add_reason(i, RetentionReason::Monthly);
321 }
322 }
323 }
324
325 if config.keep_yearly > 0 {
328 let mut allowed_years: HashSet<i32> = HashSet::new();
330 for file in files {
331 allowed_years.insert(get_year_key(file.created.date_naive()));
332 if allowed_years.len() >= config.keep_yearly as usize {
333 break;
334 }
335 }
336 let mut covered_years: HashSet<i32> = HashSet::new();
338 for (i, file) in files.iter().enumerate().rev() {
339 let year_key = get_year_key(file.created.date_naive());
340 if allowed_years.contains(&year_key) && !covered_years.contains(&year_key) {
341 covered_years.insert(year_key);
342 add_reason(i, RetentionReason::Yearly);
343 }
344 }
345 }
346
347 keep_reasons
348}
349
350#[must_use]
351pub fn select_files_to_keep(files: &[FileInfo], config: &RetentionConfig) -> HashSet<usize> {
352 select_files_to_keep_with_reasons(files, config)
353 .into_keys()
354 .collect()
355}
356
357pub fn move_to_trash(file: &Path, dry_run: bool, trash_cmd: Option<&str>) -> Result<()> {
365 if dry_run {
366 println!("Would move to trash: {}", file.display());
367 } else if let Some(cmd) = trash_cmd {
368 let escaped_path = shell_escape::escape(file.to_string_lossy());
369 let full_cmd = if cmd.contains("{}") {
370 cmd.replace("{}", &escaped_path)
371 } else {
372 format!("{cmd} {escaped_path}")
373 };
374 let status = Command::new("sh")
375 .arg("-c")
376 .arg(&full_cmd)
377 .status()
378 .context("Failed to execute trash command")?;
379 if !status.success() {
380 anyhow::bail!(
381 "Trash command failed with exit code: {}",
382 status.code().unwrap_or(-1)
383 );
384 }
385 println!("Moved to trash: {}", file.display());
386 } else {
387 trash::delete(file).context("Failed to move file to trash")?;
388 println!("Moved to trash: {}", file.display());
389 }
390
391 Ok(())
392}
393
394pub fn rotate_files(
402 dir: &Path,
403 config: &RetentionConfig,
404 dry_run: bool,
405 trash_cmd: Option<&str>,
406) -> Result<(usize, usize)> {
407 if config.keep_last == 0 {
408 anyhow::bail!("keep-last must be at least 1");
409 }
410
411 let files = scan_files(dir)?;
413
414 if files.is_empty() {
415 return Ok((0, 0));
416 }
417
418 let keep_reasons = select_files_to_keep_with_reasons(&files, config);
420
421 for (i, file) in files.iter().enumerate() {
423 if let Some(reasons) = keep_reasons.get(&i) {
424 let prefix = if dry_run { "Would keep" } else { "Keeping" };
425 let reasons_str: Vec<String> = reasons.iter().map(ToString::to_string).collect();
426 let reasons_display = reasons_str.join(", ");
427 println!("{prefix}: {} ({reasons_display})", file.path.display());
428 }
429 }
430
431 let mut moved_count = 0;
433 for (i, file) in files.iter().enumerate() {
434 if !keep_reasons.contains_key(&i) {
435 move_to_trash(&file.path, dry_run, trash_cmd)?;
436 moved_count += 1;
437 }
438 }
439
440 Ok((keep_reasons.len(), moved_count))
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446 use chrono::TimeZone;
447
448 fn make_file_info_with_time(name: &str, dt: DateTime<Local>) -> FileInfo {
449 FileInfo {
450 path: PathBuf::from(name),
451 created: dt,
452 }
453 }
454
455 fn make_file_info(name: &str, date: NaiveDate) -> FileInfo {
456 let datetime = Local
457 .from_local_datetime(&date.and_hms_opt(12, 0, 0).unwrap())
458 .single()
459 .unwrap();
460 FileInfo {
461 path: PathBuf::from(name),
462 created: datetime,
463 }
464 }
465
466 fn zero_config() -> RetentionConfig {
467 RetentionConfig {
468 keep_last: 0,
469 keep_hourly: 0,
470 keep_daily: 0,
471 keep_weekly: 0,
472 keep_monthly: 0,
473 keep_yearly: 0,
474 }
475 }
476
477 #[test]
478 fn test_default_config() {
479 let config = RetentionConfig::default();
480 assert_eq!(config.keep_last, 5);
481 assert_eq!(config.keep_hourly, 24);
482 assert_eq!(config.keep_daily, 7);
483 assert_eq!(config.keep_weekly, 4);
484 assert_eq!(config.keep_monthly, 12);
485 assert_eq!(config.keep_yearly, 10);
486 }
487
488 #[test]
489 fn test_get_hour_key() {
490 let dt = Local.with_ymd_and_hms(2024, 6, 15, 14, 30, 0).unwrap();
491 assert_eq!(get_hour_key(dt), (2024, 6, 15, 14));
492 }
493
494 #[test]
495 fn test_get_week_key() {
496 let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
497 let (year, week) = get_week_key(date);
498 assert_eq!(year, 2024);
499 assert!(week >= 1 && week <= 53);
500 }
501
502 #[test]
503 fn test_get_month_key() {
504 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
505 assert_eq!(get_month_key(date), (2024, 6));
506 }
507
508 #[test]
509 fn test_get_year_key() {
510 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
511 assert_eq!(get_year_key(date), 2024);
512 }
513
514 #[test]
515 fn test_keep_last_n_files() {
516 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
517 let config = RetentionConfig {
518 keep_last: 3,
519 ..zero_config()
520 };
521
522 let files: Vec<FileInfo> = (0..5)
524 .map(|i| {
525 let dt = now - chrono::Duration::minutes(i as i64);
526 FileInfo {
527 path: PathBuf::from(format!("file{}.txt", i)),
528 created: dt,
529 }
530 })
531 .collect();
532
533 let keep = select_files_to_keep(&files, &config);
534 assert_eq!(keep.len(), 3);
535 assert!(keep.contains(&0));
536 assert!(keep.contains(&1));
537 assert!(keep.contains(&2));
538 }
539
540 #[test]
541 fn test_keep_one_per_hour() {
542 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
543 let config = RetentionConfig {
544 keep_hourly: 5,
545 ..zero_config()
546 };
547
548 let files = vec![
551 make_file_info_with_time("file1.txt", now),
552 make_file_info_with_time("file2.txt", now - chrono::Duration::hours(1)),
553 make_file_info_with_time(
554 "file4.txt",
555 now - chrono::Duration::hours(2) + chrono::Duration::minutes(30),
556 ), make_file_info_with_time("file3.txt", now - chrono::Duration::hours(2)), ];
559
560 let keep = select_files_to_keep(&files, &config);
561 assert_eq!(keep.len(), 3); assert!(keep.contains(&0)); assert!(keep.contains(&1)); assert!(keep.contains(&3)); assert!(!keep.contains(&2)); }
567
568 #[test]
569 fn test_keep_one_per_day() {
570 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
571 let today = now.date_naive();
572 let config = RetentionConfig {
573 keep_daily: 5,
574 ..zero_config()
575 };
576
577 let files = vec![
580 make_file_info("file1.txt", today),
581 make_file_info("file2.txt", today - chrono::Duration::days(1)),
582 make_file_info("file3.txt", today - chrono::Duration::days(2)), make_file_info("file4.txt", today - chrono::Duration::days(2)), ];
585
586 let keep = select_files_to_keep(&files, &config);
587 assert_eq!(keep.len(), 3);
588 assert!(keep.contains(&0)); assert!(keep.contains(&1)); assert!(keep.contains(&3)); assert!(!keep.contains(&2)); }
593
594 #[test]
595 fn test_keep_one_per_week() {
596 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(); let today = now.date_naive();
598 let config = RetentionConfig {
599 keep_weekly: 4,
600 ..zero_config()
601 };
602
603 let files = vec![
605 make_file_info("file1.txt", today),
606 make_file_info("file2.txt", today - chrono::Duration::weeks(1)),
607 make_file_info("file3.txt", today - chrono::Duration::weeks(2)),
608 make_file_info(
609 "file4.txt",
610 today - chrono::Duration::weeks(2) + chrono::Duration::days(1),
611 ), ];
613
614 let keep = select_files_to_keep(&files, &config);
615 assert_eq!(keep.len(), 3);
616 }
617
618 #[test]
619 fn test_keep_one_per_month() {
620 let config = RetentionConfig {
621 keep_monthly: 6,
622 ..zero_config()
623 };
624
625 let files = vec![
628 make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
629 make_file_info("file2.txt", NaiveDate::from_ymd_opt(2024, 5, 10).unwrap()), make_file_info("file3.txt", NaiveDate::from_ymd_opt(2024, 5, 5).unwrap()), make_file_info("file4.txt", NaiveDate::from_ymd_opt(2024, 4, 20).unwrap()),
632 ];
633
634 let keep = select_files_to_keep(&files, &config);
635 assert_eq!(keep.len(), 3);
636 assert!(keep.contains(&0)); assert!(keep.contains(&2)); assert!(!keep.contains(&1)); assert!(keep.contains(&3)); }
641
642 #[test]
643 fn test_keep_one_per_year() {
644 let config = RetentionConfig {
645 keep_yearly: 5,
646 ..zero_config()
647 };
648
649 let files = vec![
652 make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
653 make_file_info("file2.txt", NaiveDate::from_ymd_opt(2023, 3, 10).unwrap()), make_file_info("file3.txt", NaiveDate::from_ymd_opt(2023, 1, 5).unwrap()), make_file_info("file4.txt", NaiveDate::from_ymd_opt(2022, 12, 20).unwrap()),
656 ];
657
658 let keep = select_files_to_keep(&files, &config);
659 assert_eq!(keep.len(), 3);
660 assert!(keep.contains(&0)); assert!(keep.contains(&2)); assert!(!keep.contains(&1)); assert!(keep.contains(&3)); }
665
666 #[test]
667 fn test_empty_files() {
668 let config = RetentionConfig::default();
669 let files: Vec<FileInfo> = vec![];
670
671 let keep = select_files_to_keep(&files, &config);
672 assert!(keep.is_empty());
673 }
674
675 #[test]
676 fn test_old_file_kept_when_within_n_unique_days() {
677 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
678 let config = RetentionConfig {
679 keep_daily: 5,
680 ..zero_config()
681 };
682
683 let files = vec![make_file_info(
685 "old_file.txt",
686 now.date_naive() - chrono::Duration::days(10),
687 )];
688
689 let keep = select_files_to_keep(&files, &config);
690 assert_eq!(keep.len(), 1);
691 assert!(keep.contains(&0));
692 }
693
694 #[test]
695 fn test_daily_skips_gaps() {
696 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
699 let today = now.date_naive();
700 let config = RetentionConfig {
701 keep_daily: 3,
702 ..zero_config()
703 };
704
705 let files = vec![
706 make_file_info("file0.txt", today), make_file_info("file1.txt", today - chrono::Duration::days(3)), make_file_info("file2.txt", today - chrono::Duration::days(7)), ];
710
711 let keep = select_files_to_keep(&files, &config);
712 assert_eq!(keep.len(), 3);
713 assert!(keep.contains(&0));
714 assert!(keep.contains(&1));
715 assert!(keep.contains(&2));
716 }
717
718 #[test]
719 fn test_daily_limits_to_n_most_recent_days() {
720 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
722 let today = now.date_naive();
723 let config = RetentionConfig {
724 keep_daily: 2,
725 ..zero_config()
726 };
727
728 let files = vec![
729 make_file_info("file0.txt", today),
730 make_file_info("file1.txt", today - chrono::Duration::days(5)),
731 make_file_info("file2.txt", today - chrono::Duration::days(10)),
732 ];
733
734 let keep = select_files_to_keep(&files, &config);
735 assert_eq!(keep.len(), 2);
736 assert!(keep.contains(&0)); assert!(keep.contains(&1)); assert!(!keep.contains(&2)); }
740
741 #[test]
742 fn test_hourly_skips_gaps() {
743 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
746 let config = RetentionConfig {
747 keep_hourly: 2,
748 ..zero_config()
749 };
750
751 let files = vec![
752 make_file_info_with_time("file0.txt", now),
753 make_file_info_with_time("file1.txt", now - chrono::Duration::hours(4)),
754 ];
755
756 let keep = select_files_to_keep(&files, &config);
757 assert_eq!(keep.len(), 2);
758 assert!(keep.contains(&0));
759 assert!(keep.contains(&1));
760 }
761
762 #[test]
763 fn test_weekly_skips_gaps() {
764 let config = RetentionConfig {
766 keep_weekly: 2,
767 ..zero_config()
768 };
769
770 let files = vec![
771 make_file_info("file0.txt", NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
772 make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 5, 18).unwrap()), ];
774
775 let keep = select_files_to_keep(&files, &config);
776 assert_eq!(keep.len(), 2);
777 assert!(keep.contains(&0));
778 assert!(keep.contains(&1));
779 }
780
781 #[test]
782 fn test_combined_retention_policies() {
783 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
784 let config = RetentionConfig {
785 keep_last: 2,
786 keep_hourly: 3,
787 keep_daily: 3,
788 keep_weekly: 2,
789 keep_monthly: 2,
790 keep_yearly: 1,
791 };
792
793 let files = vec![
794 make_file_info_with_time("file1.txt", now),
795 make_file_info_with_time("file2.txt", now - chrono::Duration::hours(1)),
796 make_file_info_with_time("file3.txt", now - chrono::Duration::days(1)),
797 make_file_info_with_time("file4.txt", now - chrono::Duration::days(10)),
798 make_file_info_with_time("file5.txt", now - chrono::Duration::days(40)),
799 ];
800
801 let keep = select_files_to_keep(&files, &config);
802 assert_eq!(keep.len(), 5); }
804
805 #[test]
806 fn test_keep_last_more_than_files() {
807 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
808 let config = RetentionConfig {
809 keep_last: 100,
810 ..zero_config()
811 };
812
813 let files = vec![
814 make_file_info("file1.txt", now.date_naive()),
815 make_file_info("file2.txt", now.date_naive() - chrono::Duration::days(1)),
816 ];
817
818 let keep = select_files_to_keep(&files, &config);
819 assert_eq!(keep.len(), 2);
820 }
821
822 #[test]
823 fn test_iso_week_year_boundary() {
824 let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
827 let (year, week) = get_week_key(date);
828 assert_eq!(year, 2025);
830 assert_eq!(week, 1);
831 }
832
833 #[test]
838 fn test_independent_policies_multiple_reasons() {
839 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
841 let config = RetentionConfig {
842 keep_last: 1,
843 keep_hourly: 2,
844 keep_daily: 2,
845 ..zero_config()
846 };
847
848 let files = vec![
851 make_file_info_with_time("file0.txt", now), make_file_info_with_time("file1.txt", now - chrono::Duration::hours(1)), ];
854
855 let reasons = select_files_to_keep_with_reasons(&files, &config);
856
857 let file0_reasons = reasons.get(&0).unwrap();
859 assert!(file0_reasons.contains(&RetentionReason::KeepLast));
860 assert!(file0_reasons.contains(&RetentionReason::Hourly));
861
862 let file1_reasons = reasons.get(&1).unwrap();
864 assert!(file1_reasons.contains(&RetentionReason::Hourly));
865 assert!(file1_reasons.contains(&RetentionReason::Daily));
866 }
867
868 #[test]
869 fn test_independent_policies_overlapping_periods() {
870 let now = Local.with_ymd_and_hms(2024, 6, 15, 14, 0, 0).unwrap();
872 let config = RetentionConfig {
873 keep_hourly: 3,
874 keep_daily: 2,
875 ..zero_config()
876 };
877
878 let files = vec![
879 make_file_info_with_time("file0.txt", now),
880 make_file_info_with_time("file1.txt", now - chrono::Duration::hours(1)),
881 make_file_info_with_time("file2.txt", now - chrono::Duration::days(1)),
882 ];
883
884 let keep = select_files_to_keep(&files, &config);
885 let reasons = select_files_to_keep_with_reasons(&files, &config);
886
887 assert_eq!(keep.len(), 3);
888
889 let file0_reasons = reasons.get(&0).unwrap();
891 assert!(file0_reasons.contains(&RetentionReason::Hourly));
892 assert!(!file0_reasons.contains(&RetentionReason::Daily)); let file1_reasons = reasons.get(&1).unwrap();
896 assert!(file1_reasons.contains(&RetentionReason::Hourly));
897 assert!(file1_reasons.contains(&RetentionReason::Daily));
898
899 assert!(reasons.get(&2).unwrap().contains(&RetentionReason::Daily));
901 }
902
903 #[test]
904 fn test_independent_weekly_and_monthly() {
905 let config = RetentionConfig {
907 keep_weekly: 2,
908 keep_monthly: 3,
909 ..zero_config()
910 };
911
912 let files = vec![
913 make_file_info("file0.txt", NaiveDate::from_ymd_opt(2024, 6, 28).unwrap()),
914 make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 6, 21).unwrap()),
915 make_file_info("file2.txt", NaiveDate::from_ymd_opt(2024, 5, 15).unwrap()),
916 ];
917
918 let keep = select_files_to_keep(&files, &config);
919 let reasons = select_files_to_keep_with_reasons(&files, &config);
920
921 assert_eq!(keep.len(), 3);
922
923 let file0_reasons = reasons.get(&0).unwrap();
925 assert!(file0_reasons.contains(&RetentionReason::Weekly));
926
927 let file1_reasons = reasons.get(&1).unwrap();
929 assert!(file1_reasons.contains(&RetentionReason::Weekly));
930 assert!(file1_reasons.contains(&RetentionReason::Monthly));
931
932 assert!(reasons.get(&2).unwrap().contains(&RetentionReason::Monthly));
934 }
935
936 #[test]
937 fn test_independent_monthly_and_yearly() {
938 let config = RetentionConfig {
940 keep_monthly: 2,
941 keep_yearly: 3,
942 ..zero_config()
943 };
944
945 let files = vec![
946 make_file_info("file0.txt", NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
947 make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 5, 15).unwrap()),
948 make_file_info("file2.txt", NaiveDate::from_ymd_opt(2023, 12, 1).unwrap()),
949 ];
950
951 let keep = select_files_to_keep(&files, &config);
952 let reasons = select_files_to_keep_with_reasons(&files, &config);
953
954 assert_eq!(keep.len(), 3);
955
956 let file0_reasons = reasons.get(&0).unwrap();
958 assert!(file0_reasons.contains(&RetentionReason::Monthly));
959
960 let file1_reasons = reasons.get(&1).unwrap();
962 assert!(file1_reasons.contains(&RetentionReason::Monthly));
963 assert!(file1_reasons.contains(&RetentionReason::Yearly));
964
965 assert!(reasons.get(&2).unwrap().contains(&RetentionReason::Yearly));
967 }
968
969 #[test]
970 fn test_independent_full_chain() {
971 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
974 let config = RetentionConfig {
975 keep_last: 1,
976 keep_hourly: 2,
977 keep_daily: 2,
978 keep_weekly: 2,
979 keep_monthly: 2,
980 keep_yearly: 2,
981 };
982
983 let files = vec![
984 make_file_info_with_time("file0.txt", now),
985 make_file_info_with_time("file1.txt", now - chrono::Duration::minutes(30)),
986 make_file_info_with_time("file2.txt", now - chrono::Duration::hours(5)),
987 make_file_info_with_time("file3.txt", now - chrono::Duration::days(2)),
988 make_file_info_with_time("file4.txt", now - chrono::Duration::weeks(2)),
989 make_file_info("file5.txt", NaiveDate::from_ymd_opt(2023, 7, 15).unwrap()),
990 ];
991
992 let keep = select_files_to_keep(&files, &config);
993 let reasons = select_files_to_keep_with_reasons(&files, &config);
994
995 assert_eq!(keep.len(), 6);
996
997 let file0_reasons = reasons.get(&0).unwrap();
999 assert!(file0_reasons.contains(&RetentionReason::KeepLast));
1000 assert!(file0_reasons.contains(&RetentionReason::Hourly));
1001
1002 assert!(keep.contains(&0));
1004 assert!(keep.contains(&1));
1005 assert!(keep.contains(&2));
1006 assert!(keep.contains(&3));
1007 assert!(keep.contains(&4));
1008 assert!(keep.contains(&5));
1009 }
1010
1011 #[test]
1012 fn test_independent_same_period_keeps_oldest() {
1013 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
1015 let config = RetentionConfig {
1016 keep_daily: 2,
1017 ..zero_config()
1018 };
1019
1020 let files = vec![
1022 make_file_info_with_time("file0.txt", now),
1023 make_file_info_with_time("file1.txt", now - chrono::Duration::hours(2)),
1024 make_file_info_with_time("file2.txt", now - chrono::Duration::hours(4)),
1025 make_file_info_with_time("file3.txt", now - chrono::Duration::days(1)),
1026 ];
1027
1028 let keep = select_files_to_keep(&files, &config);
1029
1030 assert_eq!(keep.len(), 2);
1031 assert!(keep.contains(&2)); assert!(!keep.contains(&0)); assert!(!keep.contains(&1)); assert!(keep.contains(&3)); }
1036
1037 #[test]
1040 fn test_resolve_config_all_defaults() {
1041 let config = resolve_config(None, None, None, None, None, None, None);
1043 assert_eq!(config, RetentionConfig::default());
1044 }
1045
1046 #[test]
1047 fn test_resolve_config_file_values() {
1048 let file_config = RetentionFileConfig {
1050 keep_last: Some(10),
1051 keep_hourly: Some(48),
1052 keep_daily: None,
1053 keep_weekly: Some(8),
1054 keep_monthly: None,
1055 keep_yearly: Some(5),
1056 };
1057
1058 let config = resolve_config(None, None, None, None, None, None, Some(&file_config));
1059
1060 assert_eq!(config.keep_last, 10);
1061 assert_eq!(config.keep_hourly, 48);
1062 assert_eq!(config.keep_daily, 7); assert_eq!(config.keep_weekly, 8);
1064 assert_eq!(config.keep_monthly, 12); assert_eq!(config.keep_yearly, 5);
1066 }
1067
1068 #[test]
1069 fn test_resolve_config_cli_overrides_file() {
1070 let file_config = RetentionFileConfig {
1072 keep_last: Some(10),
1073 keep_hourly: Some(48),
1074 keep_daily: Some(14),
1075 keep_weekly: Some(8),
1076 keep_monthly: Some(24),
1077 keep_yearly: Some(5),
1078 };
1079
1080 let config = resolve_config(
1081 Some(3), None, Some(30), None, None, Some(2), Some(&file_config),
1088 );
1089
1090 assert_eq!(config.keep_last, 3); assert_eq!(config.keep_hourly, 48); assert_eq!(config.keep_daily, 30); assert_eq!(config.keep_weekly, 8); assert_eq!(config.keep_monthly, 24); assert_eq!(config.keep_yearly, 2); }
1097
1098 #[test]
1099 fn test_resolve_config_cli_only() {
1100 let config = resolve_config(Some(1), Some(12), Some(3), Some(2), Some(6), Some(3), None);
1102
1103 assert_eq!(config.keep_last, 1);
1104 assert_eq!(config.keep_hourly, 12);
1105 assert_eq!(config.keep_daily, 3);
1106 assert_eq!(config.keep_weekly, 2);
1107 assert_eq!(config.keep_monthly, 6);
1108 assert_eq!(config.keep_yearly, 3);
1109 }
1110
1111 #[test]
1112 fn test_retention_file_config_parse_toml() {
1113 let toml_content = r#"
1114keep-last = 10
1115keep-hourly = 48
1116keep-daily = 14
1117"#;
1118 let config: RetentionFileConfig = toml::from_str(toml_content).unwrap();
1119
1120 assert_eq!(config.keep_last, Some(10));
1121 assert_eq!(config.keep_hourly, Some(48));
1122 assert_eq!(config.keep_daily, Some(14));
1123 assert_eq!(config.keep_weekly, None);
1124 assert_eq!(config.keep_monthly, None);
1125 assert_eq!(config.keep_yearly, None);
1126 }
1127
1128 #[test]
1129 fn test_retention_file_config_empty_toml() {
1130 let toml_content = "";
1131 let config: RetentionFileConfig = toml::from_str(toml_content).unwrap();
1132
1133 assert_eq!(config.keep_last, None);
1134 assert_eq!(config.keep_hourly, None);
1135 }
1136
1137 #[test]
1138 fn test_read_retention_file_not_exists() {
1139 let dir = std::env::temp_dir().join("prune_backup_test_no_file");
1140 let _ = std::fs::create_dir(&dir);
1141 let _ = std::fs::remove_file(dir.join(RETENTION_FILE_NAME));
1143
1144 let result = read_retention_file(&dir);
1145 assert!(result.is_ok());
1146 assert!(result.unwrap().is_none());
1147
1148 let _ = std::fs::remove_dir(&dir);
1149 }
1150
1151 #[test]
1152 fn test_read_retention_file_exists() {
1153 let dir = std::env::temp_dir().join("prune_backup_test_with_file");
1154 let _ = std::fs::create_dir_all(&dir);
1155
1156 let file_path = dir.join(RETENTION_FILE_NAME);
1157 std::fs::write(&file_path, "keep-last = 3\nkeep-daily = 10\n").unwrap();
1158
1159 let result = read_retention_file(&dir);
1160 assert!(result.is_ok());
1161 let config = result.unwrap().unwrap();
1162 assert_eq!(config.keep_last, Some(3));
1163 assert_eq!(config.keep_daily, Some(10));
1164 assert_eq!(config.keep_hourly, None);
1165
1166 let _ = std::fs::remove_file(&file_path);
1167 let _ = std::fs::remove_dir(&dir);
1168 }
1169
1170 #[test]
1171 fn test_read_retention_file_invalid_toml() {
1172 let dir = std::env::temp_dir().join("prune_backup_test_invalid");
1173 let _ = std::fs::create_dir_all(&dir);
1174
1175 let file_path = dir.join(RETENTION_FILE_NAME);
1176 std::fs::write(&file_path, "this is not valid toml {{{{").unwrap();
1177
1178 let result = read_retention_file(&dir);
1179 assert!(result.is_err());
1180
1181 let _ = std::fs::remove_file(&file_path);
1182 let _ = std::fs::remove_dir(&dir);
1183 }
1184}