prune_backup/
lib.rs

1use anyhow::{Context, Result};
2use chrono::{DateTime, Datelike, Local, NaiveDate, Timelike};
3use std::collections::{HashMap, HashSet};
4use std::fmt;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8/// Reason why a file was kept by the retention policy.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum RetentionReason {
11    /// Kept as one of the last N files
12    KeepLast,
13    /// Kept as the representative for an hour
14    Hourly,
15    /// Kept as the representative for a day
16    Daily,
17    /// Kept as the representative for a week
18    Weekly,
19    /// Kept as the representative for a month
20    Monthly,
21    /// Kept as the representative for a year
22    Yearly,
23}
24
25impl fmt::Display for RetentionReason {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        match self {
28            Self::KeepLast => write!(f, "keep-last"),
29            Self::Hourly => write!(f, "hourly"),
30            Self::Daily => write!(f, "daily"),
31            Self::Weekly => write!(f, "weekly"),
32            Self::Monthly => write!(f, "monthly"),
33            Self::Yearly => write!(f, "yearly"),
34        }
35    }
36}
37
38#[derive(Debug, Clone)]
39pub struct FileInfo {
40    pub path: PathBuf,
41    pub created: DateTime<Local>,
42}
43
44#[derive(Debug, Clone, PartialEq)]
45pub struct RetentionConfig {
46    pub keep_last: usize,
47    pub keep_hourly: u32,
48    pub keep_daily: u32,
49    pub keep_weekly: u32,
50    pub keep_monthly: u32,
51    pub keep_yearly: u32,
52}
53
54impl Default for RetentionConfig {
55    fn default() -> Self {
56        Self {
57            keep_last: 5,
58            keep_hourly: 24,
59            keep_daily: 7,
60            keep_weekly: 4,
61            keep_monthly: 12,
62            keep_yearly: 10,
63        }
64    }
65}
66
67/// Gets the modification time of a file, falling back to creation time.
68///
69/// Uses modification time as primary because it's more reliable across platforms
70/// and is what backup tools typically track for file age.
71///
72/// # Errors
73/// Returns an error if file metadata cannot be read or no timestamp is available.
74pub fn get_file_creation_time(path: &Path) -> Result<DateTime<Local>> {
75    let metadata = fs::metadata(path).context("Failed to read file metadata")?;
76    let mtime = metadata
77        .modified()
78        .or_else(|_| metadata.created())
79        .context("Failed to get file modification/creation time")?;
80    Ok(DateTime::from(mtime))
81}
82
83/// Scans a directory for files and returns them sorted by creation time (newest first).
84///
85/// # Errors
86/// Returns an error if the directory cannot be read.
87pub fn scan_files(dir: &Path) -> Result<Vec<FileInfo>> {
88    let mut files = Vec::new();
89
90    for entry in fs::read_dir(dir).context("Failed to read directory")? {
91        let entry = entry.context("Failed to read directory entry")?;
92        let path = entry.path();
93
94        // Skip directories and hidden files
95        if path.is_dir()
96            || path
97                .file_name()
98                .is_some_and(|n| n.to_string_lossy().starts_with('.'))
99        {
100            continue;
101        }
102
103        match get_file_creation_time(&path) {
104            Ok(created) => files.push(FileInfo { path, created }),
105            Err(e) => eprintln!("Warning: Skipping {}: {e}", path.display()),
106        }
107    }
108
109    // Sort by creation time, newest first
110    files.sort_by(|a, b| b.created.cmp(&a.created));
111    Ok(files)
112}
113
114/// Returns (year, month, day, hour) as a unique key for the hour
115fn get_hour_key(dt: DateTime<Local>) -> (i32, u32, u32, u32) {
116    (dt.year(), dt.month(), dt.day(), dt.hour())
117}
118
119/// Returns (year, week) using ISO week system
120fn get_week_key(date: NaiveDate) -> (i32, u32) {
121    (date.iso_week().year(), date.iso_week().week())
122}
123
124fn get_month_key(date: NaiveDate) -> (i32, u32) {
125    (date.year(), date.month())
126}
127
128fn get_year_key(date: NaiveDate) -> i32 {
129    date.year()
130}
131
132/// Selects files to keep with reasons, using a specific datetime as "now".
133#[must_use]
134pub fn select_files_to_keep_with_reasons(
135    files: &[FileInfo],
136    config: &RetentionConfig,
137    now: DateTime<Local>,
138) -> HashMap<usize, RetentionReason> {
139    let mut keep_reasons: HashMap<usize, RetentionReason> = HashMap::new();
140    let today = now.date_naive();
141
142    // 1. Keep last N files (processed first)
143    for i in 0..config.keep_last.min(files.len()) {
144        keep_reasons.insert(i, RetentionReason::KeepLast);
145    }
146
147    // 2. Keep 1 file per hour for N hours (oldest file in each hour)
148    // Only consider files not already kept by previous policies
149    // Iterate in reverse (oldest first) to keep oldest file per period
150    if config.keep_hourly > 0 {
151        let hour_boundary = now - chrono::Duration::hours(i64::from(config.keep_hourly));
152        let mut covered_hours: HashSet<(i32, u32, u32, u32)> = HashSet::new();
153        for (i, file) in files.iter().enumerate().rev() {
154            if keep_reasons.contains_key(&i) {
155                continue; // Skip files already kept by earlier policies
156            }
157            let file_datetime = file.created;
158            let hour_key = get_hour_key(file_datetime);
159            if file_datetime >= hour_boundary && !covered_hours.contains(&hour_key) {
160                covered_hours.insert(hour_key);
161                keep_reasons.insert(i, RetentionReason::Hourly);
162            }
163        }
164    }
165
166    // 3. Keep 1 file per day for N days (oldest file in each day)
167    // Only consider files not already kept by previous policies
168    // Iterate in reverse (oldest first) to keep oldest file per period
169    if config.keep_daily > 0 {
170        let day_boundary = today - chrono::Duration::days(i64::from(config.keep_daily));
171        let mut covered_days: HashSet<NaiveDate> = HashSet::new();
172        for (i, file) in files.iter().enumerate().rev() {
173            if keep_reasons.contains_key(&i) {
174                continue; // Skip files already kept by earlier policies
175            }
176            let file_date = file.created.date_naive();
177            if file_date >= day_boundary && !covered_days.contains(&file_date) {
178                covered_days.insert(file_date);
179                keep_reasons.insert(i, RetentionReason::Daily);
180            }
181        }
182    }
183
184    // 4. Keep 1 file per week for N weeks (ISO week system, oldest file in each week)
185    // Only consider files not already kept by previous policies
186    // Iterate in reverse (oldest first) to keep oldest file per period
187    if config.keep_weekly > 0 {
188        let week_boundary = today - chrono::Duration::weeks(i64::from(config.keep_weekly));
189        let mut covered_weeks: HashSet<(i32, u32)> = HashSet::new();
190        for (i, file) in files.iter().enumerate().rev() {
191            if keep_reasons.contains_key(&i) {
192                continue; // Skip files already kept by earlier policies
193            }
194            let file_date = file.created.date_naive();
195            let week_key = get_week_key(file_date);
196            if file_date >= week_boundary && !covered_weeks.contains(&week_key) {
197                covered_weeks.insert(week_key);
198                keep_reasons.insert(i, RetentionReason::Weekly);
199            }
200        }
201    }
202
203    // 5. Keep 1 file per month for N months (oldest file in each month)
204    // Only consider files not already kept by previous policies
205    // Iterate in reverse (oldest first) to keep oldest file per period
206    if config.keep_monthly > 0 {
207        let month_boundary = today - chrono::Duration::days(i64::from(config.keep_monthly) * 30);
208        let mut covered_months: HashSet<(i32, u32)> = HashSet::new();
209        for (i, file) in files.iter().enumerate().rev() {
210            if keep_reasons.contains_key(&i) {
211                continue; // Skip files already kept by earlier policies
212            }
213            let file_date = file.created.date_naive();
214            let month_key = get_month_key(file_date);
215            if file_date >= month_boundary && !covered_months.contains(&month_key) {
216                covered_months.insert(month_key);
217                keep_reasons.insert(i, RetentionReason::Monthly);
218            }
219        }
220    }
221
222    // 6. Keep 1 file per year for N years (oldest file in each year)
223    // Only consider files not already kept by previous policies
224    // Iterate in reverse (oldest first) to keep oldest file per period
225    if config.keep_yearly > 0 {
226        let year_boundary = today - chrono::Duration::days(i64::from(config.keep_yearly) * 365);
227        let mut covered_years: HashSet<i32> = HashSet::new();
228        for (i, file) in files.iter().enumerate().rev() {
229            if keep_reasons.contains_key(&i) {
230                continue; // Skip files already kept by earlier policies
231            }
232            let file_date = file.created.date_naive();
233            let year_key = get_year_key(file_date);
234            if file_date >= year_boundary && !covered_years.contains(&year_key) {
235                covered_years.insert(year_key);
236                keep_reasons.insert(i, RetentionReason::Yearly);
237            }
238        }
239    }
240
241    keep_reasons
242}
243
244#[must_use]
245pub fn select_files_to_keep_with_datetime(
246    files: &[FileInfo],
247    config: &RetentionConfig,
248    now: DateTime<Local>,
249) -> HashSet<usize> {
250    select_files_to_keep_with_reasons(files, config, now)
251        .into_keys()
252        .collect()
253}
254
255#[must_use]
256pub fn select_files_to_keep(files: &[FileInfo], config: &RetentionConfig) -> HashSet<usize> {
257    let now = Local::now();
258    select_files_to_keep_with_datetime(files, config, now)
259}
260
261/// Moves a file to the trash directory.
262///
263/// # Errors
264/// Returns an error if the file cannot be moved or has no filename.
265pub fn move_to_trash(file: &Path, trash_dir: &Path, dry_run: bool) -> Result<()> {
266    let file_name = file.file_name().context("Failed to get file name")?;
267    let dest = trash_dir.join(file_name);
268
269    if dry_run {
270        println!("Would move: {} -> {}", file.display(), dest.display());
271    } else {
272        // Handle name conflicts by appending a number
273        let mut final_dest = dest.clone();
274        let mut counter = 1;
275        while final_dest.exists() {
276            let stem = dest.file_stem().unwrap_or_default().to_string_lossy();
277            let ext = dest
278                .extension()
279                .map(|e| format!(".{}", e.to_string_lossy()))
280                .unwrap_or_default();
281            final_dest = trash_dir.join(format!("{stem}_{counter}{ext}"));
282            counter += 1;
283        }
284        fs::rename(file, &final_dest).context("Failed to move file to trash")?;
285        println!("Moved: {} -> {}", file.display(), final_dest.display());
286    }
287
288    Ok(())
289}
290
291/// Rotates files in a directory based on retention policies.
292///
293/// # Errors
294/// Returns an error if:
295/// - `keep_last` is 0 (must be at least 1)
296/// - The directory cannot be read
297/// - Trash directory cannot be created
298/// - Files cannot be moved
299pub fn rotate_files(dir: &Path, config: &RetentionConfig, dry_run: bool) -> Result<(usize, usize)> {
300    if config.keep_last == 0 {
301        anyhow::bail!("keep-last must be at least 1");
302    }
303
304    // Create trash directory
305    let trash_dir = dir.join(".trash");
306    if !dry_run && !trash_dir.exists() {
307        fs::create_dir(&trash_dir).context("Failed to create .trash directory")?;
308    }
309
310    // Scan files
311    let files = scan_files(dir)?;
312
313    if files.is_empty() {
314        return Ok((0, 0));
315    }
316
317    // Determine which files to keep and why
318    let now = Local::now();
319    let keep_reasons = select_files_to_keep_with_reasons(&files, config, now);
320
321    // Print kept files with reasons
322    for (i, file) in files.iter().enumerate() {
323        if let Some(reason) = keep_reasons.get(&i) {
324            let prefix = if dry_run { "Would keep" } else { "Keeping" };
325            println!("{prefix}: {} ({reason})", file.path.display());
326        }
327    }
328
329    // Move files that are not in keep set
330    let mut moved_count = 0;
331    for (i, file) in files.iter().enumerate() {
332        if !keep_reasons.contains_key(&i) {
333            move_to_trash(&file.path, &trash_dir, dry_run)?;
334            moved_count += 1;
335        }
336    }
337
338    Ok((keep_reasons.len(), moved_count))
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use chrono::TimeZone;
345
346    fn make_file_info_with_time(name: &str, dt: DateTime<Local>) -> FileInfo {
347        FileInfo {
348            path: PathBuf::from(name),
349            created: dt,
350        }
351    }
352
353    fn make_file_info(name: &str, date: NaiveDate) -> FileInfo {
354        let datetime = Local
355            .from_local_datetime(&date.and_hms_opt(12, 0, 0).unwrap())
356            .single()
357            .unwrap();
358        FileInfo {
359            path: PathBuf::from(name),
360            created: datetime,
361        }
362    }
363
364    fn zero_config() -> RetentionConfig {
365        RetentionConfig {
366            keep_last: 0,
367            keep_hourly: 0,
368            keep_daily: 0,
369            keep_weekly: 0,
370            keep_monthly: 0,
371            keep_yearly: 0,
372        }
373    }
374
375    #[test]
376    fn test_default_config() {
377        let config = RetentionConfig::default();
378        assert_eq!(config.keep_last, 5);
379        assert_eq!(config.keep_hourly, 24);
380        assert_eq!(config.keep_daily, 7);
381        assert_eq!(config.keep_weekly, 4);
382        assert_eq!(config.keep_monthly, 12);
383        assert_eq!(config.keep_yearly, 10);
384    }
385
386    #[test]
387    fn test_get_hour_key() {
388        let dt = Local.with_ymd_and_hms(2024, 6, 15, 14, 30, 0).unwrap();
389        assert_eq!(get_hour_key(dt), (2024, 6, 15, 14));
390    }
391
392    #[test]
393    fn test_get_week_key() {
394        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
395        let (year, week) = get_week_key(date);
396        assert_eq!(year, 2024);
397        assert!(week >= 1 && week <= 53);
398    }
399
400    #[test]
401    fn test_get_month_key() {
402        let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
403        assert_eq!(get_month_key(date), (2024, 6));
404    }
405
406    #[test]
407    fn test_get_year_key() {
408        let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
409        assert_eq!(get_year_key(date), 2024);
410    }
411
412    #[test]
413    fn test_keep_last_n_files() {
414        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
415        let config = RetentionConfig {
416            keep_last: 3,
417            ..zero_config()
418        };
419
420        // Create 5 files with different times
421        let files: Vec<FileInfo> = (0..5)
422            .map(|i| {
423                let dt = now - chrono::Duration::minutes(i as i64);
424                FileInfo {
425                    path: PathBuf::from(format!("file{}.txt", i)),
426                    created: dt,
427                }
428            })
429            .collect();
430
431        let keep = select_files_to_keep_with_datetime(&files, &config, now);
432        assert_eq!(keep.len(), 3);
433        assert!(keep.contains(&0));
434        assert!(keep.contains(&1));
435        assert!(keep.contains(&2));
436    }
437
438    #[test]
439    fn test_keep_one_per_hour() {
440        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
441        let config = RetentionConfig {
442            keep_hourly: 5,
443            ..zero_config()
444        };
445
446        // Create files in different hours (sorted newest-first like scan_files does)
447        // file4 is newer than file3 but in the same hour
448        let files = vec![
449            make_file_info_with_time("file1.txt", now),
450            make_file_info_with_time("file2.txt", now - chrono::Duration::hours(1)),
451            make_file_info_with_time(
452                "file4.txt",
453                now - chrono::Duration::hours(2) + chrono::Duration::minutes(30),
454            ), // hour 10, newer
455            make_file_info_with_time("file3.txt", now - chrono::Duration::hours(2)), // hour 10, older
456        ];
457
458        let keep = select_files_to_keep_with_datetime(&files, &config, now);
459        assert_eq!(keep.len(), 3); // 3 unique hours
460        assert!(keep.contains(&0)); // hour 12
461        assert!(keep.contains(&1)); // hour 11
462        assert!(keep.contains(&3)); // hour 10 (oldest file in that hour)
463        assert!(!keep.contains(&2)); // same hour as file3, not kept (newer)
464    }
465
466    #[test]
467    fn test_keep_one_per_day() {
468        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
469        let today = now.date_naive();
470        let config = RetentionConfig {
471            keep_daily: 5,
472            ..zero_config()
473        };
474
475        // Create files for different days (sorted newest-first like scan_files does)
476        // file3 and file4 are on the same day, but file4 is older
477        let files = vec![
478            make_file_info("file1.txt", today),
479            make_file_info("file2.txt", today - chrono::Duration::days(1)),
480            make_file_info("file3.txt", today - chrono::Duration::days(2)), // newer on day -2
481            make_file_info("file4.txt", today - chrono::Duration::days(2)), // older on day -2 (same day)
482        ];
483
484        let keep = select_files_to_keep_with_datetime(&files, &config, now);
485        assert_eq!(keep.len(), 3);
486        assert!(keep.contains(&0)); // today
487        assert!(keep.contains(&1)); // yesterday
488        assert!(keep.contains(&3)); // 2 days ago (oldest file on that day)
489        assert!(!keep.contains(&2)); // duplicate day, not kept
490    }
491
492    #[test]
493    fn test_keep_one_per_week() {
494        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(); // Saturday
495        let today = now.date_naive();
496        let config = RetentionConfig {
497            keep_weekly: 4,
498            ..zero_config()
499        };
500
501        // Create files spanning different weeks
502        let files = vec![
503            make_file_info("file1.txt", today),
504            make_file_info("file2.txt", today - chrono::Duration::weeks(1)),
505            make_file_info("file3.txt", today - chrono::Duration::weeks(2)),
506            make_file_info(
507                "file4.txt",
508                today - chrono::Duration::weeks(2) + chrono::Duration::days(1),
509            ), // same week as file3
510        ];
511
512        let keep = select_files_to_keep_with_datetime(&files, &config, now);
513        assert_eq!(keep.len(), 3);
514    }
515
516    #[test]
517    fn test_keep_one_per_month() {
518        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
519        let config = RetentionConfig {
520            keep_monthly: 6,
521            ..zero_config()
522        };
523
524        // Create files in different months (sorted newest-first like scan_files does)
525        // file2 and file3 are in the same month (May), but file3 is older
526        let files = vec![
527            make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
528            make_file_info("file2.txt", NaiveDate::from_ymd_opt(2024, 5, 10).unwrap()), // May, newer
529            make_file_info("file3.txt", NaiveDate::from_ymd_opt(2024, 5, 5).unwrap()), // May, older
530            make_file_info("file4.txt", NaiveDate::from_ymd_opt(2024, 4, 20).unwrap()),
531        ];
532
533        let keep = select_files_to_keep_with_datetime(&files, &config, now);
534        assert_eq!(keep.len(), 3);
535        assert!(keep.contains(&0)); // June
536        assert!(keep.contains(&2)); // May (oldest file in that month)
537        assert!(!keep.contains(&1)); // May duplicate, not kept (newer)
538        assert!(keep.contains(&3)); // April
539    }
540
541    #[test]
542    fn test_keep_one_per_year() {
543        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
544        let config = RetentionConfig {
545            keep_yearly: 5,
546            ..zero_config()
547        };
548
549        // Create files in different years (sorted newest-first like scan_files does)
550        // file2 and file3 are in the same year (2023), but file3 is older
551        let files = vec![
552            make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
553            make_file_info("file2.txt", NaiveDate::from_ymd_opt(2023, 3, 10).unwrap()), // 2023, newer
554            make_file_info("file3.txt", NaiveDate::from_ymd_opt(2023, 1, 5).unwrap()), // 2023, older
555            make_file_info("file4.txt", NaiveDate::from_ymd_opt(2022, 12, 20).unwrap()),
556        ];
557
558        let keep = select_files_to_keep_with_datetime(&files, &config, now);
559        assert_eq!(keep.len(), 3);
560        assert!(keep.contains(&0)); // 2024
561        assert!(keep.contains(&2)); // 2023 (oldest file in that year)
562        assert!(!keep.contains(&1)); // 2023 duplicate, not kept (newer)
563        assert!(keep.contains(&3)); // 2022
564    }
565
566    #[test]
567    fn test_empty_files() {
568        let now = Local::now();
569        let config = RetentionConfig::default();
570        let files: Vec<FileInfo> = vec![];
571
572        let keep = select_files_to_keep_with_datetime(&files, &config, now);
573        assert!(keep.is_empty());
574    }
575
576    #[test]
577    fn test_files_outside_retention_window() {
578        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
579        let config = RetentionConfig {
580            keep_daily: 5,
581            ..zero_config()
582        };
583
584        // File is 10 days old, outside the 5-day window
585        let files = vec![make_file_info(
586            "old_file.txt",
587            now.date_naive() - chrono::Duration::days(10),
588        )];
589
590        let keep = select_files_to_keep_with_datetime(&files, &config, now);
591        assert!(keep.is_empty());
592    }
593
594    #[test]
595    fn test_combined_retention_policies() {
596        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
597        let config = RetentionConfig {
598            keep_last: 2,
599            keep_hourly: 3,
600            keep_daily: 3,
601            keep_weekly: 2,
602            keep_monthly: 2,
603            keep_yearly: 1,
604        };
605
606        let files = vec![
607            make_file_info_with_time("file1.txt", now),
608            make_file_info_with_time("file2.txt", now - chrono::Duration::hours(1)),
609            make_file_info_with_time("file3.txt", now - chrono::Duration::days(1)),
610            make_file_info_with_time("file4.txt", now - chrono::Duration::days(10)),
611            make_file_info_with_time("file5.txt", now - chrono::Duration::days(40)),
612        ];
613
614        let keep = select_files_to_keep_with_datetime(&files, &config, now);
615        assert_eq!(keep.len(), 5); // All files kept by various policies
616    }
617
618    #[test]
619    fn test_keep_last_more_than_files() {
620        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
621        let config = RetentionConfig {
622            keep_last: 100,
623            ..zero_config()
624        };
625
626        let files = vec![
627            make_file_info("file1.txt", now.date_naive()),
628            make_file_info("file2.txt", now.date_naive() - chrono::Duration::days(1)),
629        ];
630
631        let keep = select_files_to_keep_with_datetime(&files, &config, now);
632        assert_eq!(keep.len(), 2);
633    }
634
635    #[test]
636    fn test_iso_week_year_boundary() {
637        // Test that ISO week handles year boundaries correctly
638        // Dec 31, 2024 is in ISO week 1 of 2025
639        let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
640        let (year, week) = get_week_key(date);
641        // The ISO week year for Dec 31, 2024 should be 2025
642        assert_eq!(year, 2025);
643        assert_eq!(week, 1);
644    }
645
646    // ==================== CASCADING RETENTION TESTS ====================
647    // These tests verify that retention options are processed in order and
648    // each option only considers backups not already covered by previous options.
649
650    #[test]
651    fn test_cascading_keep_last_excludes_from_hourly() {
652        // Files kept by keep-last should NOT count toward hourly coverage.
653        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 30, 0).unwrap();
654        let config = RetentionConfig {
655            keep_last: 2,
656            keep_hourly: 2,
657            ..zero_config()
658        };
659
660        // 3 files all in hour 12, plus 1 file in hour 11
661        let files = vec![
662            make_file_info_with_time("file0.txt", now),
663            make_file_info_with_time("file1.txt", now - chrono::Duration::minutes(10)),
664            make_file_info_with_time("file2.txt", now - chrono::Duration::minutes(20)),
665            make_file_info_with_time("file3.txt", now - chrono::Duration::hours(1)),
666        ];
667
668        let keep = select_files_to_keep_with_datetime(&files, &config, now);
669
670        assert_eq!(keep.len(), 4);
671        assert!(keep.contains(&0)); // keep-last
672        assert!(keep.contains(&1)); // keep-last
673        assert!(keep.contains(&2)); // hourly (first hour 12 from hourly's perspective)
674        assert!(keep.contains(&3)); // hourly (hour 11)
675    }
676
677    #[test]
678    fn test_cascading_hourly_excludes_from_daily() {
679        // Files kept by keep-hourly should NOT count toward daily coverage.
680        let now = Local.with_ymd_and_hms(2024, 6, 15, 14, 0, 0).unwrap();
681        let config = RetentionConfig {
682            keep_hourly: 2,
683            keep_daily: 2,
684            ..zero_config()
685        };
686
687        let files = vec![
688            make_file_info_with_time("file0.txt", now),
689            make_file_info_with_time("file1.txt", now - chrono::Duration::hours(1)),
690            make_file_info_with_time("file2.txt", now - chrono::Duration::days(1)),
691        ];
692
693        let keep = select_files_to_keep_with_datetime(&files, &config, now);
694
695        assert_eq!(keep.len(), 3);
696        assert!(keep.contains(&0)); // hourly
697        assert!(keep.contains(&1)); // hourly
698        assert!(keep.contains(&2)); // daily
699    }
700
701    #[test]
702    fn test_cascading_daily_excludes_from_weekly() {
703        // Files kept by keep-daily should NOT count toward weekly coverage.
704        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(); // Saturday
705        let config = RetentionConfig {
706            keep_daily: 3,
707            keep_weekly: 2,
708            ..zero_config()
709        };
710
711        let files = vec![
712            make_file_info("file0.txt", now.date_naive()),
713            make_file_info("file1.txt", now.date_naive() - chrono::Duration::days(1)),
714            make_file_info("file2.txt", now.date_naive() - chrono::Duration::days(2)),
715            make_file_info("file3.txt", now.date_naive() - chrono::Duration::weeks(1)),
716        ];
717
718        let keep = select_files_to_keep_with_datetime(&files, &config, now);
719
720        assert_eq!(keep.len(), 4);
721        assert!(keep.contains(&3)); // weekly should pick this up
722    }
723
724    #[test]
725    fn test_cascading_weekly_excludes_from_monthly() {
726        // Files kept by keep-weekly should NOT count toward monthly coverage.
727        let now = Local.with_ymd_and_hms(2024, 6, 28, 12, 0, 0).unwrap();
728        let config = RetentionConfig {
729            keep_weekly: 2,
730            keep_monthly: 3,
731            ..zero_config()
732        };
733
734        let files = vec![
735            make_file_info("file0.txt", NaiveDate::from_ymd_opt(2024, 6, 28).unwrap()),
736            make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 6, 21).unwrap()),
737            make_file_info("file2.txt", NaiveDate::from_ymd_opt(2024, 5, 15).unwrap()),
738        ];
739
740        let keep = select_files_to_keep_with_datetime(&files, &config, now);
741
742        assert_eq!(keep.len(), 3);
743        assert!(keep.contains(&2)); // monthly
744    }
745
746    #[test]
747    fn test_cascading_monthly_excludes_from_yearly() {
748        // Files kept by keep-monthly should NOT count toward yearly coverage.
749        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
750        let config = RetentionConfig {
751            keep_monthly: 2,
752            keep_yearly: 3,
753            ..zero_config()
754        };
755
756        let files = vec![
757            make_file_info("file0.txt", NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
758            make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 5, 15).unwrap()),
759            make_file_info("file2.txt", NaiveDate::from_ymd_opt(2023, 12, 1).unwrap()),
760        ];
761
762        let keep = select_files_to_keep_with_datetime(&files, &config, now);
763
764        assert_eq!(keep.len(), 3);
765        assert!(keep.contains(&2)); // yearly
766    }
767
768    #[test]
769    fn test_cascading_full_chain() {
770        // Test the full cascading chain: keep-last -> hourly -> daily -> weekly -> monthly -> yearly
771        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
772        let config = RetentionConfig {
773            keep_last: 1,
774            keep_hourly: 1,
775            keep_daily: 1,
776            keep_weekly: 1,
777            keep_monthly: 1,
778            keep_yearly: 1,
779        };
780
781        let files = vec![
782            make_file_info_with_time("file0.txt", now),
783            make_file_info_with_time("file1.txt", now - chrono::Duration::minutes(30)),
784            make_file_info_with_time("file2.txt", now - chrono::Duration::hours(5)),
785            make_file_info_with_time("file3.txt", now - chrono::Duration::days(2)),
786            make_file_info_with_time("file4.txt", now - chrono::Duration::weeks(2)),
787            make_file_info("file5.txt", NaiveDate::from_ymd_opt(2023, 7, 15).unwrap()),
788        ];
789
790        let keep = select_files_to_keep_with_datetime(&files, &config, now);
791
792        assert_eq!(keep.len(), 6);
793        assert!(keep.contains(&0)); // keep-last
794        assert!(keep.contains(&1)); // hourly
795        assert!(keep.contains(&2)); // daily
796        assert!(keep.contains(&3)); // weekly
797        assert!(keep.contains(&4)); // monthly
798        assert!(keep.contains(&5)); // yearly
799    }
800
801    #[test]
802    fn test_cascading_same_period_multiple_files() {
803        // When multiple files exist in the same period, later policy picks the oldest uncovered file.
804        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
805        let config = RetentionConfig {
806            keep_last: 1,
807            keep_daily: 2,
808            ..zero_config()
809        };
810
811        let files = vec![
812            make_file_info_with_time("file0.txt", now),
813            make_file_info_with_time("file1.txt", now - chrono::Duration::hours(2)),
814            make_file_info_with_time("file2.txt", now - chrono::Duration::hours(4)),
815            make_file_info_with_time("file3.txt", now - chrono::Duration::days(1)),
816        ];
817
818        let keep = select_files_to_keep_with_datetime(&files, &config, now);
819
820        assert_eq!(keep.len(), 3);
821        assert!(keep.contains(&0)); // keep-last
822        assert!(keep.contains(&2)); // daily (oldest uncovered for today)
823        assert!(!keep.contains(&1)); // today already covered by index 2
824        assert!(keep.contains(&3)); // daily (yesterday)
825    }
826}