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]
219pub fn select_files_to_keep_with_reasons(
220 files: &[FileInfo],
221 config: &RetentionConfig,
222 now: DateTime<Local>,
223) -> HashMap<usize, HashSet<RetentionReason>> {
224 let mut keep_reasons: HashMap<usize, HashSet<RetentionReason>> = HashMap::new();
225 let today = now.date_naive();
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 hour_boundary = now - chrono::Duration::hours(i64::from(config.keep_hourly));
241 let mut covered_hours: HashSet<(i32, u32, u32, u32)> = HashSet::new();
242 for (i, file) in files.iter().enumerate().rev() {
243 let file_datetime = file.created;
244 let hour_key = get_hour_key(file_datetime);
245 if file_datetime >= hour_boundary && !covered_hours.contains(&hour_key) {
246 covered_hours.insert(hour_key);
247 add_reason(i, RetentionReason::Hourly);
248 }
249 }
250 }
251
252 if config.keep_daily > 0 {
255 let day_boundary = today - chrono::Duration::days(i64::from(config.keep_daily));
256 let mut covered_days: HashSet<NaiveDate> = HashSet::new();
257 for (i, file) in files.iter().enumerate().rev() {
258 let file_date = file.created.date_naive();
259 if file_date >= day_boundary && !covered_days.contains(&file_date) {
260 covered_days.insert(file_date);
261 add_reason(i, RetentionReason::Daily);
262 }
263 }
264 }
265
266 if config.keep_weekly > 0 {
269 let week_boundary = today - chrono::Duration::weeks(i64::from(config.keep_weekly));
270 let mut covered_weeks: HashSet<(i32, u32)> = HashSet::new();
271 for (i, file) in files.iter().enumerate().rev() {
272 let file_date = file.created.date_naive();
273 let week_key = get_week_key(file_date);
274 if file_date >= week_boundary && !covered_weeks.contains(&week_key) {
275 covered_weeks.insert(week_key);
276 add_reason(i, RetentionReason::Weekly);
277 }
278 }
279 }
280
281 if config.keep_monthly > 0 {
284 let month_boundary = today - chrono::Duration::days(i64::from(config.keep_monthly) * 30);
285 let mut covered_months: HashSet<(i32, u32)> = HashSet::new();
286 for (i, file) in files.iter().enumerate().rev() {
287 let file_date = file.created.date_naive();
288 let month_key = get_month_key(file_date);
289 if file_date >= month_boundary && !covered_months.contains(&month_key) {
290 covered_months.insert(month_key);
291 add_reason(i, RetentionReason::Monthly);
292 }
293 }
294 }
295
296 if config.keep_yearly > 0 {
299 let year_boundary = today - chrono::Duration::days(i64::from(config.keep_yearly) * 365);
300 let mut covered_years: HashSet<i32> = HashSet::new();
301 for (i, file) in files.iter().enumerate().rev() {
302 let file_date = file.created.date_naive();
303 let year_key = get_year_key(file_date);
304 if file_date >= year_boundary && !covered_years.contains(&year_key) {
305 covered_years.insert(year_key);
306 add_reason(i, RetentionReason::Yearly);
307 }
308 }
309 }
310
311 keep_reasons
312}
313
314#[must_use]
315pub fn select_files_to_keep_with_datetime(
316 files: &[FileInfo],
317 config: &RetentionConfig,
318 now: DateTime<Local>,
319) -> HashSet<usize> {
320 select_files_to_keep_with_reasons(files, config, now)
321 .into_keys()
322 .collect()
323}
324
325#[must_use]
326pub fn select_files_to_keep(files: &[FileInfo], config: &RetentionConfig) -> HashSet<usize> {
327 let now = Local::now();
328 select_files_to_keep_with_datetime(files, config, now)
329}
330
331pub fn move_to_trash(file: &Path, dry_run: bool, trash_cmd: Option<&str>) -> Result<()> {
339 if dry_run {
340 println!("Would move to trash: {}", file.display());
341 } else if let Some(cmd) = trash_cmd {
342 let escaped_path = shell_escape::escape(file.to_string_lossy());
343 let full_cmd = if cmd.contains("{}") {
344 cmd.replace("{}", &escaped_path)
345 } else {
346 format!("{cmd} {escaped_path}")
347 };
348 let status = Command::new("sh")
349 .arg("-c")
350 .arg(&full_cmd)
351 .status()
352 .context("Failed to execute trash command")?;
353 if !status.success() {
354 anyhow::bail!(
355 "Trash command failed with exit code: {}",
356 status.code().unwrap_or(-1)
357 );
358 }
359 println!("Moved to trash: {}", file.display());
360 } else {
361 trash::delete(file).context("Failed to move file to trash")?;
362 println!("Moved to trash: {}", file.display());
363 }
364
365 Ok(())
366}
367
368pub fn rotate_files(
376 dir: &Path,
377 config: &RetentionConfig,
378 dry_run: bool,
379 trash_cmd: Option<&str>,
380) -> Result<(usize, usize)> {
381 if config.keep_last == 0 {
382 anyhow::bail!("keep-last must be at least 1");
383 }
384
385 let files = scan_files(dir)?;
387
388 if files.is_empty() {
389 return Ok((0, 0));
390 }
391
392 let now = Local::now();
394 let keep_reasons = select_files_to_keep_with_reasons(&files, config, now);
395
396 for (i, file) in files.iter().enumerate() {
398 if let Some(reasons) = keep_reasons.get(&i) {
399 let prefix = if dry_run { "Would keep" } else { "Keeping" };
400 let reasons_str: Vec<String> = reasons.iter().map(ToString::to_string).collect();
401 let reasons_display = reasons_str.join(", ");
402 println!("{prefix}: {} ({reasons_display})", file.path.display());
403 }
404 }
405
406 let mut moved_count = 0;
408 for (i, file) in files.iter().enumerate() {
409 if !keep_reasons.contains_key(&i) {
410 move_to_trash(&file.path, dry_run, trash_cmd)?;
411 moved_count += 1;
412 }
413 }
414
415 Ok((keep_reasons.len(), moved_count))
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use chrono::TimeZone;
422
423 fn make_file_info_with_time(name: &str, dt: DateTime<Local>) -> FileInfo {
424 FileInfo {
425 path: PathBuf::from(name),
426 created: dt,
427 }
428 }
429
430 fn make_file_info(name: &str, date: NaiveDate) -> FileInfo {
431 let datetime = Local
432 .from_local_datetime(&date.and_hms_opt(12, 0, 0).unwrap())
433 .single()
434 .unwrap();
435 FileInfo {
436 path: PathBuf::from(name),
437 created: datetime,
438 }
439 }
440
441 fn zero_config() -> RetentionConfig {
442 RetentionConfig {
443 keep_last: 0,
444 keep_hourly: 0,
445 keep_daily: 0,
446 keep_weekly: 0,
447 keep_monthly: 0,
448 keep_yearly: 0,
449 }
450 }
451
452 #[test]
453 fn test_default_config() {
454 let config = RetentionConfig::default();
455 assert_eq!(config.keep_last, 5);
456 assert_eq!(config.keep_hourly, 24);
457 assert_eq!(config.keep_daily, 7);
458 assert_eq!(config.keep_weekly, 4);
459 assert_eq!(config.keep_monthly, 12);
460 assert_eq!(config.keep_yearly, 10);
461 }
462
463 #[test]
464 fn test_get_hour_key() {
465 let dt = Local.with_ymd_and_hms(2024, 6, 15, 14, 30, 0).unwrap();
466 assert_eq!(get_hour_key(dt), (2024, 6, 15, 14));
467 }
468
469 #[test]
470 fn test_get_week_key() {
471 let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
472 let (year, week) = get_week_key(date);
473 assert_eq!(year, 2024);
474 assert!(week >= 1 && week <= 53);
475 }
476
477 #[test]
478 fn test_get_month_key() {
479 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
480 assert_eq!(get_month_key(date), (2024, 6));
481 }
482
483 #[test]
484 fn test_get_year_key() {
485 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
486 assert_eq!(get_year_key(date), 2024);
487 }
488
489 #[test]
490 fn test_keep_last_n_files() {
491 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
492 let config = RetentionConfig {
493 keep_last: 3,
494 ..zero_config()
495 };
496
497 let files: Vec<FileInfo> = (0..5)
499 .map(|i| {
500 let dt = now - chrono::Duration::minutes(i as i64);
501 FileInfo {
502 path: PathBuf::from(format!("file{}.txt", i)),
503 created: dt,
504 }
505 })
506 .collect();
507
508 let keep = select_files_to_keep_with_datetime(&files, &config, now);
509 assert_eq!(keep.len(), 3);
510 assert!(keep.contains(&0));
511 assert!(keep.contains(&1));
512 assert!(keep.contains(&2));
513 }
514
515 #[test]
516 fn test_keep_one_per_hour() {
517 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
518 let config = RetentionConfig {
519 keep_hourly: 5,
520 ..zero_config()
521 };
522
523 let files = vec![
526 make_file_info_with_time("file1.txt", now),
527 make_file_info_with_time("file2.txt", now - chrono::Duration::hours(1)),
528 make_file_info_with_time(
529 "file4.txt",
530 now - chrono::Duration::hours(2) + chrono::Duration::minutes(30),
531 ), make_file_info_with_time("file3.txt", now - chrono::Duration::hours(2)), ];
534
535 let keep = select_files_to_keep_with_datetime(&files, &config, now);
536 assert_eq!(keep.len(), 3); assert!(keep.contains(&0)); assert!(keep.contains(&1)); assert!(keep.contains(&3)); assert!(!keep.contains(&2)); }
542
543 #[test]
544 fn test_keep_one_per_day() {
545 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
546 let today = now.date_naive();
547 let config = RetentionConfig {
548 keep_daily: 5,
549 ..zero_config()
550 };
551
552 let files = vec![
555 make_file_info("file1.txt", today),
556 make_file_info("file2.txt", today - chrono::Duration::days(1)),
557 make_file_info("file3.txt", today - chrono::Duration::days(2)), make_file_info("file4.txt", today - chrono::Duration::days(2)), ];
560
561 let keep = select_files_to_keep_with_datetime(&files, &config, now);
562 assert_eq!(keep.len(), 3);
563 assert!(keep.contains(&0)); assert!(keep.contains(&1)); assert!(keep.contains(&3)); assert!(!keep.contains(&2)); }
568
569 #[test]
570 fn test_keep_one_per_week() {
571 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(); let today = now.date_naive();
573 let config = RetentionConfig {
574 keep_weekly: 4,
575 ..zero_config()
576 };
577
578 let files = vec![
580 make_file_info("file1.txt", today),
581 make_file_info("file2.txt", today - chrono::Duration::weeks(1)),
582 make_file_info("file3.txt", today - chrono::Duration::weeks(2)),
583 make_file_info(
584 "file4.txt",
585 today - chrono::Duration::weeks(2) + chrono::Duration::days(1),
586 ), ];
588
589 let keep = select_files_to_keep_with_datetime(&files, &config, now);
590 assert_eq!(keep.len(), 3);
591 }
592
593 #[test]
594 fn test_keep_one_per_month() {
595 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
596 let config = RetentionConfig {
597 keep_monthly: 6,
598 ..zero_config()
599 };
600
601 let files = vec![
604 make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
605 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()),
608 ];
609
610 let keep = select_files_to_keep_with_datetime(&files, &config, now);
611 assert_eq!(keep.len(), 3);
612 assert!(keep.contains(&0)); assert!(keep.contains(&2)); assert!(!keep.contains(&1)); assert!(keep.contains(&3)); }
617
618 #[test]
619 fn test_keep_one_per_year() {
620 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
621 let config = RetentionConfig {
622 keep_yearly: 5,
623 ..zero_config()
624 };
625
626 let files = vec![
629 make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
630 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()),
633 ];
634
635 let keep = select_files_to_keep_with_datetime(&files, &config, now);
636 assert_eq!(keep.len(), 3);
637 assert!(keep.contains(&0)); assert!(keep.contains(&2)); assert!(!keep.contains(&1)); assert!(keep.contains(&3)); }
642
643 #[test]
644 fn test_empty_files() {
645 let now = Local::now();
646 let config = RetentionConfig::default();
647 let files: Vec<FileInfo> = vec![];
648
649 let keep = select_files_to_keep_with_datetime(&files, &config, now);
650 assert!(keep.is_empty());
651 }
652
653 #[test]
654 fn test_files_outside_retention_window() {
655 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
656 let config = RetentionConfig {
657 keep_daily: 5,
658 ..zero_config()
659 };
660
661 let files = vec![make_file_info(
663 "old_file.txt",
664 now.date_naive() - chrono::Duration::days(10),
665 )];
666
667 let keep = select_files_to_keep_with_datetime(&files, &config, now);
668 assert!(keep.is_empty());
669 }
670
671 #[test]
672 fn test_combined_retention_policies() {
673 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
674 let config = RetentionConfig {
675 keep_last: 2,
676 keep_hourly: 3,
677 keep_daily: 3,
678 keep_weekly: 2,
679 keep_monthly: 2,
680 keep_yearly: 1,
681 };
682
683 let files = vec![
684 make_file_info_with_time("file1.txt", now),
685 make_file_info_with_time("file2.txt", now - chrono::Duration::hours(1)),
686 make_file_info_with_time("file3.txt", now - chrono::Duration::days(1)),
687 make_file_info_with_time("file4.txt", now - chrono::Duration::days(10)),
688 make_file_info_with_time("file5.txt", now - chrono::Duration::days(40)),
689 ];
690
691 let keep = select_files_to_keep_with_datetime(&files, &config, now);
692 assert_eq!(keep.len(), 5); }
694
695 #[test]
696 fn test_keep_last_more_than_files() {
697 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
698 let config = RetentionConfig {
699 keep_last: 100,
700 ..zero_config()
701 };
702
703 let files = vec![
704 make_file_info("file1.txt", now.date_naive()),
705 make_file_info("file2.txt", now.date_naive() - chrono::Duration::days(1)),
706 ];
707
708 let keep = select_files_to_keep_with_datetime(&files, &config, now);
709 assert_eq!(keep.len(), 2);
710 }
711
712 #[test]
713 fn test_iso_week_year_boundary() {
714 let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
717 let (year, week) = get_week_key(date);
718 assert_eq!(year, 2025);
720 assert_eq!(week, 1);
721 }
722
723 #[test]
728 fn test_independent_policies_multiple_reasons() {
729 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
731 let config = RetentionConfig {
732 keep_last: 1,
733 keep_hourly: 2,
734 keep_daily: 2,
735 ..zero_config()
736 };
737
738 let files = vec![
741 make_file_info_with_time("file0.txt", now), make_file_info_with_time("file1.txt", now - chrono::Duration::hours(1)), ];
744
745 let reasons = select_files_to_keep_with_reasons(&files, &config, now);
746
747 let file0_reasons = reasons.get(&0).unwrap();
749 assert!(file0_reasons.contains(&RetentionReason::KeepLast));
750 assert!(file0_reasons.contains(&RetentionReason::Hourly));
751
752 let file1_reasons = reasons.get(&1).unwrap();
754 assert!(file1_reasons.contains(&RetentionReason::Hourly));
755 assert!(file1_reasons.contains(&RetentionReason::Daily));
756 }
757
758 #[test]
759 fn test_independent_policies_overlapping_periods() {
760 let now = Local.with_ymd_and_hms(2024, 6, 15, 14, 0, 0).unwrap();
762 let config = RetentionConfig {
763 keep_hourly: 3,
764 keep_daily: 2,
765 ..zero_config()
766 };
767
768 let files = vec![
769 make_file_info_with_time("file0.txt", now),
770 make_file_info_with_time("file1.txt", now - chrono::Duration::hours(1)),
771 make_file_info_with_time("file2.txt", now - chrono::Duration::days(1)),
772 ];
773
774 let keep = select_files_to_keep_with_datetime(&files, &config, now);
775 let reasons = select_files_to_keep_with_reasons(&files, &config, now);
776
777 assert_eq!(keep.len(), 3);
778
779 let file0_reasons = reasons.get(&0).unwrap();
781 assert!(file0_reasons.contains(&RetentionReason::Hourly));
782 assert!(!file0_reasons.contains(&RetentionReason::Daily)); let file1_reasons = reasons.get(&1).unwrap();
786 assert!(file1_reasons.contains(&RetentionReason::Hourly));
787 assert!(file1_reasons.contains(&RetentionReason::Daily));
788
789 assert!(reasons.get(&2).unwrap().contains(&RetentionReason::Daily));
791 }
792
793 #[test]
794 fn test_independent_weekly_and_monthly() {
795 let now = Local.with_ymd_and_hms(2024, 6, 28, 12, 0, 0).unwrap();
797 let config = RetentionConfig {
798 keep_weekly: 2,
799 keep_monthly: 3,
800 ..zero_config()
801 };
802
803 let files = vec![
804 make_file_info("file0.txt", NaiveDate::from_ymd_opt(2024, 6, 28).unwrap()),
805 make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 6, 21).unwrap()),
806 make_file_info("file2.txt", NaiveDate::from_ymd_opt(2024, 5, 15).unwrap()),
807 ];
808
809 let keep = select_files_to_keep_with_datetime(&files, &config, now);
810 let reasons = select_files_to_keep_with_reasons(&files, &config, now);
811
812 assert_eq!(keep.len(), 3);
813
814 let file0_reasons = reasons.get(&0).unwrap();
816 assert!(file0_reasons.contains(&RetentionReason::Weekly));
817
818 let file1_reasons = reasons.get(&1).unwrap();
820 assert!(file1_reasons.contains(&RetentionReason::Weekly));
821 assert!(file1_reasons.contains(&RetentionReason::Monthly));
822
823 assert!(reasons.get(&2).unwrap().contains(&RetentionReason::Monthly));
825 }
826
827 #[test]
828 fn test_independent_monthly_and_yearly() {
829 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
831 let config = RetentionConfig {
832 keep_monthly: 2,
833 keep_yearly: 3,
834 ..zero_config()
835 };
836
837 let files = vec![
838 make_file_info("file0.txt", NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
839 make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 5, 15).unwrap()),
840 make_file_info("file2.txt", NaiveDate::from_ymd_opt(2023, 12, 1).unwrap()),
841 ];
842
843 let keep = select_files_to_keep_with_datetime(&files, &config, now);
844 let reasons = select_files_to_keep_with_reasons(&files, &config, now);
845
846 assert_eq!(keep.len(), 3);
847
848 let file0_reasons = reasons.get(&0).unwrap();
850 assert!(file0_reasons.contains(&RetentionReason::Monthly));
851
852 let file1_reasons = reasons.get(&1).unwrap();
854 assert!(file1_reasons.contains(&RetentionReason::Monthly));
855 assert!(file1_reasons.contains(&RetentionReason::Yearly));
856
857 assert!(reasons.get(&2).unwrap().contains(&RetentionReason::Yearly));
859 }
860
861 #[test]
862 fn test_independent_full_chain() {
863 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
865 let config = RetentionConfig {
866 keep_last: 1,
867 keep_hourly: 1,
868 keep_daily: 1,
869 keep_weekly: 1,
870 keep_monthly: 1,
871 keep_yearly: 1,
872 };
873
874 let files = vec![
875 make_file_info_with_time("file0.txt", now),
876 make_file_info_with_time("file1.txt", now - chrono::Duration::minutes(30)),
877 make_file_info_with_time("file2.txt", now - chrono::Duration::hours(5)),
878 make_file_info_with_time("file3.txt", now - chrono::Duration::days(2)),
879 make_file_info_with_time("file4.txt", now - chrono::Duration::weeks(2)),
880 make_file_info("file5.txt", NaiveDate::from_ymd_opt(2023, 7, 15).unwrap()),
881 ];
882
883 let keep = select_files_to_keep_with_datetime(&files, &config, now);
884 let reasons = select_files_to_keep_with_reasons(&files, &config, now);
885
886 assert_eq!(keep.len(), 6);
887
888 let file0_reasons = reasons.get(&0).unwrap();
890 assert!(file0_reasons.contains(&RetentionReason::KeepLast));
891 assert!(file0_reasons.contains(&RetentionReason::Hourly));
892
893 assert!(keep.contains(&0));
895 assert!(keep.contains(&1));
896 assert!(keep.contains(&2));
897 assert!(keep.contains(&3));
898 assert!(keep.contains(&4));
899 assert!(keep.contains(&5));
900 }
901
902 #[test]
903 fn test_independent_same_period_keeps_oldest() {
904 let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
906 let config = RetentionConfig {
907 keep_daily: 2,
908 ..zero_config()
909 };
910
911 let files = vec![
913 make_file_info_with_time("file0.txt", now),
914 make_file_info_with_time("file1.txt", now - chrono::Duration::hours(2)),
915 make_file_info_with_time("file2.txt", now - chrono::Duration::hours(4)),
916 make_file_info_with_time("file3.txt", now - chrono::Duration::days(1)),
917 ];
918
919 let keep = select_files_to_keep_with_datetime(&files, &config, now);
920
921 assert_eq!(keep.len(), 2);
922 assert!(keep.contains(&2)); assert!(!keep.contains(&0)); assert!(!keep.contains(&1)); assert!(keep.contains(&3)); }
927
928 #[test]
931 fn test_resolve_config_all_defaults() {
932 let config = resolve_config(None, None, None, None, None, None, None);
934 assert_eq!(config, RetentionConfig::default());
935 }
936
937 #[test]
938 fn test_resolve_config_file_values() {
939 let file_config = RetentionFileConfig {
941 keep_last: Some(10),
942 keep_hourly: Some(48),
943 keep_daily: None,
944 keep_weekly: Some(8),
945 keep_monthly: None,
946 keep_yearly: Some(5),
947 };
948
949 let config = resolve_config(None, None, None, None, None, None, Some(&file_config));
950
951 assert_eq!(config.keep_last, 10);
952 assert_eq!(config.keep_hourly, 48);
953 assert_eq!(config.keep_daily, 7); assert_eq!(config.keep_weekly, 8);
955 assert_eq!(config.keep_monthly, 12); assert_eq!(config.keep_yearly, 5);
957 }
958
959 #[test]
960 fn test_resolve_config_cli_overrides_file() {
961 let file_config = RetentionFileConfig {
963 keep_last: Some(10),
964 keep_hourly: Some(48),
965 keep_daily: Some(14),
966 keep_weekly: Some(8),
967 keep_monthly: Some(24),
968 keep_yearly: Some(5),
969 };
970
971 let config = resolve_config(
972 Some(3), None, Some(30), None, None, Some(2), Some(&file_config),
979 );
980
981 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); }
988
989 #[test]
990 fn test_resolve_config_cli_only() {
991 let config = resolve_config(Some(1), Some(12), Some(3), Some(2), Some(6), Some(3), None);
993
994 assert_eq!(config.keep_last, 1);
995 assert_eq!(config.keep_hourly, 12);
996 assert_eq!(config.keep_daily, 3);
997 assert_eq!(config.keep_weekly, 2);
998 assert_eq!(config.keep_monthly, 6);
999 assert_eq!(config.keep_yearly, 3);
1000 }
1001
1002 #[test]
1003 fn test_retention_file_config_parse_toml() {
1004 let toml_content = r#"
1005keep-last = 10
1006keep-hourly = 48
1007keep-daily = 14
1008"#;
1009 let config: RetentionFileConfig = toml::from_str(toml_content).unwrap();
1010
1011 assert_eq!(config.keep_last, Some(10));
1012 assert_eq!(config.keep_hourly, Some(48));
1013 assert_eq!(config.keep_daily, Some(14));
1014 assert_eq!(config.keep_weekly, None);
1015 assert_eq!(config.keep_monthly, None);
1016 assert_eq!(config.keep_yearly, None);
1017 }
1018
1019 #[test]
1020 fn test_retention_file_config_empty_toml() {
1021 let toml_content = "";
1022 let config: RetentionFileConfig = toml::from_str(toml_content).unwrap();
1023
1024 assert_eq!(config.keep_last, None);
1025 assert_eq!(config.keep_hourly, None);
1026 }
1027
1028 #[test]
1029 fn test_read_retention_file_not_exists() {
1030 let dir = std::env::temp_dir().join("prune_backup_test_no_file");
1031 let _ = std::fs::create_dir(&dir);
1032 let _ = std::fs::remove_file(dir.join(RETENTION_FILE_NAME));
1034
1035 let result = read_retention_file(&dir);
1036 assert!(result.is_ok());
1037 assert!(result.unwrap().is_none());
1038
1039 let _ = std::fs::remove_dir(&dir);
1040 }
1041
1042 #[test]
1043 fn test_read_retention_file_exists() {
1044 let dir = std::env::temp_dir().join("prune_backup_test_with_file");
1045 let _ = std::fs::create_dir_all(&dir);
1046
1047 let file_path = dir.join(RETENTION_FILE_NAME);
1048 std::fs::write(&file_path, "keep-last = 3\nkeep-daily = 10\n").unwrap();
1049
1050 let result = read_retention_file(&dir);
1051 assert!(result.is_ok());
1052 let config = result.unwrap().unwrap();
1053 assert_eq!(config.keep_last, Some(3));
1054 assert_eq!(config.keep_daily, Some(10));
1055 assert_eq!(config.keep_hourly, None);
1056
1057 let _ = std::fs::remove_file(&file_path);
1058 let _ = std::fs::remove_dir(&dir);
1059 }
1060
1061 #[test]
1062 fn test_read_retention_file_invalid_toml() {
1063 let dir = std::env::temp_dir().join("prune_backup_test_invalid");
1064 let _ = std::fs::create_dir_all(&dir);
1065
1066 let file_path = dir.join(RETENTION_FILE_NAME);
1067 std::fs::write(&file_path, "this is not valid toml {{{{").unwrap();
1068
1069 let result = read_retention_file(&dir);
1070 assert!(result.is_err());
1071
1072 let _ = std::fs::remove_file(&file_path);
1073 let _ = std::fs::remove_dir(&dir);
1074 }
1075}