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
214fn apply_time_policy<K: Eq + std::hash::Hash>(
219 files: &[FileInfo],
220 already_kept: &mut HashSet<usize>,
221 keep_reasons: &mut HashMap<usize, RetentionReason>,
222 count: u32,
223 reason: RetentionReason,
224 key_fn: impl Fn(&FileInfo) -> K,
225) {
226 if count == 0 {
227 return;
228 }
229 let mut covered: HashSet<K> = HashSet::new();
230 for (i, file) in files.iter().enumerate() {
231 if already_kept.contains(&i) {
232 continue;
233 }
234 let key = key_fn(file);
235 if !covered.contains(&key) {
236 covered.insert(key);
237 keep_reasons.insert(i, reason);
238 already_kept.insert(i);
239 if covered.len() >= count as usize {
240 break;
241 }
242 }
243 }
244}
245
246#[must_use]
254pub fn select_files_to_keep_with_reasons(
255 files: &[FileInfo],
256 config: &RetentionConfig,
257) -> HashMap<usize, RetentionReason> {
258 let mut keep_reasons: HashMap<usize, RetentionReason> = HashMap::new();
259 let mut already_kept: HashSet<usize> = HashSet::new();
260
261 for i in 0..config.keep_last.min(files.len()) {
263 keep_reasons.insert(i, RetentionReason::KeepLast);
264 already_kept.insert(i);
265 }
266
267 apply_time_policy(
269 files,
270 &mut already_kept,
271 &mut keep_reasons,
272 config.keep_hourly,
273 RetentionReason::Hourly,
274 |f| get_hour_key(f.created),
275 );
276 apply_time_policy(
277 files,
278 &mut already_kept,
279 &mut keep_reasons,
280 config.keep_daily,
281 RetentionReason::Daily,
282 |f| f.created.date_naive(),
283 );
284 apply_time_policy(
285 files,
286 &mut already_kept,
287 &mut keep_reasons,
288 config.keep_weekly,
289 RetentionReason::Weekly,
290 |f| get_week_key(f.created.date_naive()),
291 );
292 apply_time_policy(
293 files,
294 &mut already_kept,
295 &mut keep_reasons,
296 config.keep_monthly,
297 RetentionReason::Monthly,
298 |f| get_month_key(f.created.date_naive()),
299 );
300 apply_time_policy(
301 files,
302 &mut already_kept,
303 &mut keep_reasons,
304 config.keep_yearly,
305 RetentionReason::Yearly,
306 |f| get_year_key(f.created.date_naive()),
307 );
308
309 keep_reasons
310}
311
312pub fn move_to_trash(file: &Path, dry_run: bool, trash_cmd: Option<&str>) -> Result<()> {
320 if dry_run {
321 println!("Would move to trash: {}", file.display());
322 } else if let Some(cmd) = trash_cmd {
323 let escaped_path = shell_escape::escape(file.to_string_lossy());
324 let full_cmd = if cmd.contains("{}") {
325 cmd.replace("{}", &escaped_path)
326 } else {
327 format!("{cmd} {escaped_path}")
328 };
329 let status = Command::new("sh")
330 .arg("-c")
331 .arg(&full_cmd)
332 .status()
333 .context("Failed to execute trash command")?;
334 if !status.success() {
335 anyhow::bail!(
336 "Trash command failed with exit code: {}",
337 status.code().unwrap_or(-1)
338 );
339 }
340 println!("Moved to trash: {}", file.display());
341 } else {
342 trash::delete(file).context("Failed to move file to trash")?;
343 println!("Moved to trash: {}", file.display());
344 }
345
346 Ok(())
347}
348
349pub fn rotate_files(
357 dir: &Path,
358 config: &RetentionConfig,
359 dry_run: bool,
360 trash_cmd: Option<&str>,
361) -> Result<(usize, usize)> {
362 if config.keep_last == 0 {
363 anyhow::bail!("keep-last must be at least 1");
364 }
365
366 let files = scan_files(dir)?;
368
369 if files.is_empty() {
370 return Ok((0, 0));
371 }
372
373 let keep_reasons = select_files_to_keep_with_reasons(&files, config);
375
376 for (i, file) in files.iter().enumerate() {
378 if let Some(reason) = keep_reasons.get(&i) {
379 let prefix = if dry_run { "Would keep" } else { "Keeping" };
380 println!("{prefix}: {} ({reason})", file.path.display());
381 }
382 }
383
384 let mut moved_count = 0;
386 for (i, file) in files.iter().enumerate() {
387 if !keep_reasons.contains_key(&i) {
388 move_to_trash(&file.path, dry_run, trash_cmd)?;
389 moved_count += 1;
390 }
391 }
392
393 Ok((keep_reasons.len(), moved_count))
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use chrono::TimeZone;
400
401 fn make_file_info_with_time(name: &str, dt: DateTime<Local>) -> FileInfo {
402 FileInfo {
403 path: PathBuf::from(name),
404 created: dt,
405 }
406 }
407
408 fn make_file_info(name: &str, date: NaiveDate) -> FileInfo {
409 let datetime = Local
410 .from_local_datetime(&date.and_hms_opt(12, 0, 0).unwrap())
411 .single()
412 .unwrap();
413 FileInfo {
414 path: PathBuf::from(name),
415 created: datetime,
416 }
417 }
418
419 fn select_files_to_keep(files: &[FileInfo], config: &RetentionConfig) -> HashSet<usize> {
420 select_files_to_keep_with_reasons(files, config)
421 .into_keys()
422 .collect()
423 }
424
425 fn zero_config() -> RetentionConfig {
426 RetentionConfig {
427 keep_last: 0,
428 keep_hourly: 0,
429 keep_daily: 0,
430 keep_weekly: 0,
431 keep_monthly: 0,
432 keep_yearly: 0,
433 }
434 }
435
436 #[test]
437 fn test_default_config() {
438 let config = RetentionConfig::default();
439 assert_eq!(config.keep_last, 5);
440 assert_eq!(config.keep_hourly, 24);
441 assert_eq!(config.keep_daily, 7);
442 assert_eq!(config.keep_weekly, 4);
443 assert_eq!(config.keep_monthly, 12);
444 assert_eq!(config.keep_yearly, 10);
445 }
446
447 #[test]
448 fn test_get_hour_key() {
449 let dt = Local.with_ymd_and_hms(2024, 6, 15, 14, 30, 0).unwrap();
450 assert_eq!(get_hour_key(dt), (2024, 6, 15, 14));
451 }
452
453 #[test]
454 fn test_get_week_key() {
455 let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
456 let (year, week) = get_week_key(date);
457 assert_eq!(year, 2024);
458 assert!(week >= 1 && week <= 53);
459 }
460
461 #[test]
462 fn test_get_month_key() {
463 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
464 assert_eq!(get_month_key(date), (2024, 6));
465 }
466
467 #[test]
468 fn test_get_year_key() {
469 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
470 assert_eq!(get_year_key(date), 2024);
471 }
472
473 #[test]
474 fn test_keep_last_n_files() {
475 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
476 let config = RetentionConfig {
477 keep_last: 3,
478 ..zero_config()
479 };
480
481 let files: Vec<FileInfo> = (0..5)
483 .map(|i| {
484 let dt = now - chrono::Duration::minutes(i as i64);
485 FileInfo {
486 path: PathBuf::from(format!("file{}.txt", i)),
487 created: dt,
488 }
489 })
490 .collect();
491
492 let keep = select_files_to_keep(&files, &config);
493 assert_eq!(keep.len(), 3);
494 assert!(keep.contains(&0));
495 assert!(keep.contains(&1));
496 assert!(keep.contains(&2));
497 }
498
499 #[test]
500 fn test_keep_one_per_hour() {
501 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
502 let config = RetentionConfig {
503 keep_hourly: 5,
504 ..zero_config()
505 };
506
507 let files = vec![
510 make_file_info_with_time("file1.txt", now),
511 make_file_info_with_time("file2.txt", now - chrono::Duration::hours(1)),
512 make_file_info_with_time(
513 "file4.txt",
514 now - chrono::Duration::hours(2) + chrono::Duration::minutes(30),
515 ), make_file_info_with_time("file3.txt", now - chrono::Duration::hours(2)), ];
518
519 let keep = select_files_to_keep(&files, &config);
520 assert_eq!(keep.len(), 3); assert!(keep.contains(&0)); assert!(keep.contains(&1)); assert!(keep.contains(&2)); assert!(!keep.contains(&3)); }
526
527 #[test]
528 fn test_keep_one_per_day() {
529 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
530 let today = now.date_naive();
531 let config = RetentionConfig {
532 keep_daily: 5,
533 ..zero_config()
534 };
535
536 let files = vec![
539 make_file_info("file1.txt", today),
540 make_file_info("file2.txt", today - chrono::Duration::days(1)),
541 make_file_info("file3.txt", today - chrono::Duration::days(2)), make_file_info("file4.txt", today - chrono::Duration::days(2)), ];
544
545 let keep = select_files_to_keep(&files, &config);
546 assert_eq!(keep.len(), 3);
547 assert!(keep.contains(&0)); assert!(keep.contains(&1)); assert!(keep.contains(&2)); assert!(!keep.contains(&3)); }
552
553 #[test]
554 fn test_keep_one_per_week() {
555 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(); let today = now.date_naive();
557 let config = RetentionConfig {
558 keep_weekly: 4,
559 ..zero_config()
560 };
561
562 let files = vec![
564 make_file_info("file1.txt", today),
565 make_file_info("file2.txt", today - chrono::Duration::weeks(1)),
566 make_file_info("file3.txt", today - chrono::Duration::weeks(2)),
567 make_file_info(
568 "file4.txt",
569 today - chrono::Duration::weeks(2) + chrono::Duration::days(1),
570 ), ];
572
573 let keep = select_files_to_keep(&files, &config);
574 assert_eq!(keep.len(), 3);
575 }
576
577 #[test]
578 fn test_keep_one_per_month() {
579 let config = RetentionConfig {
580 keep_monthly: 6,
581 ..zero_config()
582 };
583
584 let files = vec![
587 make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
588 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()),
591 ];
592
593 let keep = select_files_to_keep(&files, &config);
594 assert_eq!(keep.len(), 3);
595 assert!(keep.contains(&0)); assert!(keep.contains(&1)); assert!(!keep.contains(&2)); assert!(keep.contains(&3)); }
600
601 #[test]
602 fn test_keep_one_per_year() {
603 let config = RetentionConfig {
604 keep_yearly: 5,
605 ..zero_config()
606 };
607
608 let files = vec![
611 make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
612 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()),
615 ];
616
617 let keep = select_files_to_keep(&files, &config);
618 assert_eq!(keep.len(), 3);
619 assert!(keep.contains(&0)); assert!(keep.contains(&1)); assert!(!keep.contains(&2)); assert!(keep.contains(&3)); }
624
625 #[test]
626 fn test_empty_files() {
627 let config = RetentionConfig::default();
628 let files: Vec<FileInfo> = vec![];
629
630 let keep = select_files_to_keep(&files, &config);
631 assert!(keep.is_empty());
632 }
633
634 #[test]
635 fn test_old_file_kept_when_within_n_unique_days() {
636 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
637 let config = RetentionConfig {
638 keep_daily: 5,
639 ..zero_config()
640 };
641
642 let files = vec![make_file_info(
644 "old_file.txt",
645 now.date_naive() - chrono::Duration::days(10),
646 )];
647
648 let keep = select_files_to_keep(&files, &config);
649 assert_eq!(keep.len(), 1);
650 assert!(keep.contains(&0));
651 }
652
653 #[test]
654 fn test_daily_skips_gaps() {
655 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
658 let today = now.date_naive();
659 let config = RetentionConfig {
660 keep_daily: 3,
661 ..zero_config()
662 };
663
664 let files = vec![
665 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)), ];
669
670 let keep = select_files_to_keep(&files, &config);
671 assert_eq!(keep.len(), 3);
672 assert!(keep.contains(&0));
673 assert!(keep.contains(&1));
674 assert!(keep.contains(&2));
675 }
676
677 #[test]
678 fn test_daily_limits_to_n_most_recent_days() {
679 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
681 let today = now.date_naive();
682 let config = RetentionConfig {
683 keep_daily: 2,
684 ..zero_config()
685 };
686
687 let files = vec![
688 make_file_info("file0.txt", today),
689 make_file_info("file1.txt", today - chrono::Duration::days(5)),
690 make_file_info("file2.txt", today - chrono::Duration::days(10)),
691 ];
692
693 let keep = select_files_to_keep(&files, &config);
694 assert_eq!(keep.len(), 2);
695 assert!(keep.contains(&0)); assert!(keep.contains(&1)); assert!(!keep.contains(&2)); }
699
700 #[test]
701 fn test_hourly_skips_gaps() {
702 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
705 let config = RetentionConfig {
706 keep_hourly: 2,
707 ..zero_config()
708 };
709
710 let files = vec![
711 make_file_info_with_time("file0.txt", now),
712 make_file_info_with_time("file1.txt", now - chrono::Duration::hours(4)),
713 ];
714
715 let keep = select_files_to_keep(&files, &config);
716 assert_eq!(keep.len(), 2);
717 assert!(keep.contains(&0));
718 assert!(keep.contains(&1));
719 }
720
721 #[test]
722 fn test_weekly_skips_gaps() {
723 let config = RetentionConfig {
725 keep_weekly: 2,
726 ..zero_config()
727 };
728
729 let files = vec![
730 make_file_info("file0.txt", NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
731 make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 5, 18).unwrap()), ];
733
734 let keep = select_files_to_keep(&files, &config);
735 assert_eq!(keep.len(), 2);
736 assert!(keep.contains(&0));
737 assert!(keep.contains(&1));
738 }
739
740 #[test]
741 fn test_combined_retention_policies() {
742 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
743 let config = RetentionConfig {
744 keep_last: 2,
745 keep_hourly: 3,
746 keep_daily: 3,
747 keep_weekly: 2,
748 keep_monthly: 2,
749 keep_yearly: 1,
750 };
751
752 let files = vec![
753 make_file_info_with_time("file1.txt", now),
754 make_file_info_with_time("file2.txt", now - chrono::Duration::hours(1)),
755 make_file_info_with_time("file3.txt", now - chrono::Duration::days(1)),
756 make_file_info_with_time("file4.txt", now - chrono::Duration::days(10)),
757 make_file_info_with_time("file5.txt", now - chrono::Duration::days(40)),
758 ];
759
760 let keep = select_files_to_keep(&files, &config);
761 assert_eq!(keep.len(), 5); }
763
764 #[test]
765 fn test_keep_last_more_than_files() {
766 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
767 let config = RetentionConfig {
768 keep_last: 100,
769 ..zero_config()
770 };
771
772 let files = vec![
773 make_file_info("file1.txt", now.date_naive()),
774 make_file_info("file2.txt", now.date_naive() - chrono::Duration::days(1)),
775 ];
776
777 let keep = select_files_to_keep(&files, &config);
778 assert_eq!(keep.len(), 2);
779 }
780
781 #[test]
782 fn test_iso_week_year_boundary() {
783 let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
786 let (year, week) = get_week_key(date);
787 assert_eq!(year, 2025);
789 assert_eq!(week, 1);
790 }
791
792 #[test]
798 fn test_sequential_policies_single_reason() {
799 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
801 let config = RetentionConfig {
802 keep_last: 1,
803 keep_hourly: 2,
804 keep_daily: 2,
805 ..zero_config()
806 };
807
808 let files = vec![
809 make_file_info_with_time("file0.txt", now), make_file_info_with_time("file1.txt", now - chrono::Duration::hours(1)), ];
812
813 let reasons = select_files_to_keep_with_reasons(&files, &config);
814
815 assert_eq!(*reasons.get(&0).unwrap(), RetentionReason::KeepLast);
817
818 assert_eq!(*reasons.get(&1).unwrap(), RetentionReason::Hourly);
820 }
821
822 #[test]
823 fn test_sequential_later_policy_sees_remaining_files() {
824 let now = Local.with_ymd_and_hms(2024, 6, 15, 14, 0, 0).unwrap();
826 let config = RetentionConfig {
827 keep_hourly: 3,
828 keep_daily: 2,
829 ..zero_config()
830 };
831
832 let files = vec![
833 make_file_info_with_time("file0.txt", now),
834 make_file_info_with_time("file1.txt", now - chrono::Duration::hours(1)),
835 make_file_info_with_time("file2.txt", now - chrono::Duration::days(1)),
836 ];
837
838 let reasons = select_files_to_keep_with_reasons(&files, &config);
839
840 assert_eq!(reasons.len(), 3);
841
842 assert_eq!(*reasons.get(&0).unwrap(), RetentionReason::Hourly);
844 assert_eq!(*reasons.get(&1).unwrap(), RetentionReason::Hourly);
845 assert_eq!(*reasons.get(&2).unwrap(), RetentionReason::Hourly);
846 }
847
848 #[test]
849 fn test_sequential_weekly_then_monthly() {
850 let config = RetentionConfig {
852 keep_weekly: 2,
853 keep_monthly: 3,
854 ..zero_config()
855 };
856
857 let files = vec![
858 make_file_info("file0.txt", NaiveDate::from_ymd_opt(2024, 6, 28).unwrap()),
859 make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 6, 21).unwrap()),
860 make_file_info("file2.txt", NaiveDate::from_ymd_opt(2024, 5, 15).unwrap()),
861 ];
862
863 let reasons = select_files_to_keep_with_reasons(&files, &config);
864
865 assert_eq!(reasons.len(), 3);
866
867 assert_eq!(*reasons.get(&0).unwrap(), RetentionReason::Weekly);
869 assert_eq!(*reasons.get(&1).unwrap(), RetentionReason::Weekly);
870
871 assert_eq!(*reasons.get(&2).unwrap(), RetentionReason::Monthly);
873 }
874
875 #[test]
876 fn test_sequential_monthly_then_yearly() {
877 let config = RetentionConfig {
879 keep_monthly: 2,
880 keep_yearly: 3,
881 ..zero_config()
882 };
883
884 let files = vec![
885 make_file_info("file0.txt", NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
886 make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 5, 15).unwrap()),
887 make_file_info("file2.txt", NaiveDate::from_ymd_opt(2023, 12, 1).unwrap()),
888 ];
889
890 let reasons = select_files_to_keep_with_reasons(&files, &config);
891
892 assert_eq!(reasons.len(), 3);
893
894 assert_eq!(*reasons.get(&0).unwrap(), RetentionReason::Monthly);
895 assert_eq!(*reasons.get(&1).unwrap(), RetentionReason::Monthly);
896 assert_eq!(*reasons.get(&2).unwrap(), RetentionReason::Yearly);
897 }
898
899 #[test]
900 fn test_sequential_full_chain() {
901 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
903 let config = RetentionConfig {
904 keep_last: 1,
905 keep_hourly: 2,
906 keep_daily: 2,
907 keep_weekly: 2,
908 keep_monthly: 2,
909 keep_yearly: 2,
910 };
911
912 let files = vec![
913 make_file_info_with_time("file0.txt", now),
914 make_file_info_with_time("file1.txt", now - chrono::Duration::minutes(30)),
915 make_file_info_with_time("file2.txt", now - chrono::Duration::hours(5)),
916 make_file_info_with_time("file3.txt", now - chrono::Duration::days(2)),
917 make_file_info_with_time("file4.txt", now - chrono::Duration::weeks(2)),
918 make_file_info("file5.txt", NaiveDate::from_ymd_opt(2023, 7, 15).unwrap()),
919 ];
920
921 let keep = select_files_to_keep(&files, &config);
922 let reasons = select_files_to_keep_with_reasons(&files, &config);
923
924 assert_eq!(keep.len(), 6);
926
927 assert_eq!(*reasons.get(&0).unwrap(), RetentionReason::KeepLast);
929 }
930
931 #[test]
932 fn test_sequential_same_period_keeps_newest() {
933 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
935 let config = RetentionConfig {
936 keep_daily: 2,
937 ..zero_config()
938 };
939
940 let files = vec![
942 make_file_info_with_time("file0.txt", now),
943 make_file_info_with_time("file1.txt", now - chrono::Duration::hours(2)),
944 make_file_info_with_time("file2.txt", now - chrono::Duration::hours(4)),
945 make_file_info_with_time("file3.txt", now - chrono::Duration::days(1)),
946 ];
947
948 let keep = select_files_to_keep(&files, &config);
949
950 assert_eq!(keep.len(), 2);
951 assert!(keep.contains(&0)); assert!(!keep.contains(&1)); assert!(!keep.contains(&2)); assert!(keep.contains(&3)); }
956
957 #[test]
960 fn test_resolve_config_all_defaults() {
961 let config = resolve_config(None, None, None, None, None, None, None);
963 assert_eq!(config, RetentionConfig::default());
964 }
965
966 #[test]
967 fn test_resolve_config_file_values() {
968 let file_config = RetentionFileConfig {
970 keep_last: Some(10),
971 keep_hourly: Some(48),
972 keep_daily: None,
973 keep_weekly: Some(8),
974 keep_monthly: None,
975 keep_yearly: Some(5),
976 };
977
978 let config = resolve_config(None, None, None, None, None, None, Some(&file_config));
979
980 assert_eq!(config.keep_last, 10);
981 assert_eq!(config.keep_hourly, 48);
982 assert_eq!(config.keep_daily, 7); assert_eq!(config.keep_weekly, 8);
984 assert_eq!(config.keep_monthly, 12); assert_eq!(config.keep_yearly, 5);
986 }
987
988 #[test]
989 fn test_resolve_config_cli_overrides_file() {
990 let file_config = RetentionFileConfig {
992 keep_last: Some(10),
993 keep_hourly: Some(48),
994 keep_daily: Some(14),
995 keep_weekly: Some(8),
996 keep_monthly: Some(24),
997 keep_yearly: Some(5),
998 };
999
1000 let config = resolve_config(
1001 Some(3), None, Some(30), None, None, Some(2), Some(&file_config),
1008 );
1009
1010 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); }
1017
1018 #[test]
1019 fn test_resolve_config_cli_only() {
1020 let config = resolve_config(Some(1), Some(12), Some(3), Some(2), Some(6), Some(3), None);
1022
1023 assert_eq!(config.keep_last, 1);
1024 assert_eq!(config.keep_hourly, 12);
1025 assert_eq!(config.keep_daily, 3);
1026 assert_eq!(config.keep_weekly, 2);
1027 assert_eq!(config.keep_monthly, 6);
1028 assert_eq!(config.keep_yearly, 3);
1029 }
1030
1031 #[test]
1032 fn test_retention_file_config_parse_toml() {
1033 let toml_content = r#"
1034keep-last = 10
1035keep-hourly = 48
1036keep-daily = 14
1037"#;
1038 let config: RetentionFileConfig = toml::from_str(toml_content).unwrap();
1039
1040 assert_eq!(config.keep_last, Some(10));
1041 assert_eq!(config.keep_hourly, Some(48));
1042 assert_eq!(config.keep_daily, Some(14));
1043 assert_eq!(config.keep_weekly, None);
1044 assert_eq!(config.keep_monthly, None);
1045 assert_eq!(config.keep_yearly, None);
1046 }
1047
1048 #[test]
1049 fn test_retention_file_config_empty_toml() {
1050 let toml_content = "";
1051 let config: RetentionFileConfig = toml::from_str(toml_content).unwrap();
1052
1053 assert_eq!(config.keep_last, None);
1054 assert_eq!(config.keep_hourly, None);
1055 }
1056
1057 #[test]
1058 fn test_read_retention_file_not_exists() {
1059 let dir = std::env::temp_dir().join("prune_backup_test_no_file");
1060 let _ = std::fs::create_dir(&dir);
1061 let _ = std::fs::remove_file(dir.join(RETENTION_FILE_NAME));
1063
1064 let result = read_retention_file(&dir);
1065 assert!(result.is_ok());
1066 assert!(result.unwrap().is_none());
1067
1068 let _ = std::fs::remove_dir(&dir);
1069 }
1070
1071 #[test]
1072 fn test_read_retention_file_exists() {
1073 let dir = std::env::temp_dir().join("prune_backup_test_with_file");
1074 let _ = std::fs::create_dir_all(&dir);
1075
1076 let file_path = dir.join(RETENTION_FILE_NAME);
1077 std::fs::write(&file_path, "keep-last = 3\nkeep-daily = 10\n").unwrap();
1078
1079 let result = read_retention_file(&dir);
1080 assert!(result.is_ok());
1081 let config = result.unwrap().unwrap();
1082 assert_eq!(config.keep_last, Some(3));
1083 assert_eq!(config.keep_daily, Some(10));
1084 assert_eq!(config.keep_hourly, None);
1085
1086 let _ = std::fs::remove_file(&file_path);
1087 let _ = std::fs::remove_dir(&dir);
1088 }
1089
1090 #[test]
1091 fn test_read_retention_file_invalid_toml() {
1092 let dir = std::env::temp_dir().join("prune_backup_test_invalid");
1093 let _ = std::fs::create_dir_all(&dir);
1094
1095 let file_path = dir.join(RETENTION_FILE_NAME);
1096 std::fs::write(&file_path, "this is not valid toml {{{{").unwrap();
1097
1098 let result = read_retention_file(&dir);
1099 assert!(result.is_err());
1100
1101 let _ = std::fs::remove_file(&file_path);
1102 let _ = std::fs::remove_dir(&dir);
1103 }
1104}