Skip to main content

prune_backup/
lib.rs

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/// Reason why a file was kept by the retention policy.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum RetentionReason {
13    /// Kept as one of the last N files
14    KeepLast,
15    /// Kept as the representative for an hour
16    Hourly,
17    /// Kept as the representative for a day
18    Daily,
19    /// Kept as the representative for a week
20    Weekly,
21    /// Kept as the representative for a month
22    Monthly,
23    /// Kept as the representative for a year
24    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/// Configuration read from a `.retention` file in TOML format.
70/// All fields are optional; missing fields will use CLI args or defaults.
71#[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
82/// The name of the retention configuration file.
83pub const RETENTION_FILE_NAME: &str = ".retention";
84
85/// Reads a `.retention` file from the given directory.
86///
87/// # Returns
88/// - `Ok(Some(config))` if the file exists and was parsed successfully
89/// - `Ok(None)` if the file does not exist
90///
91/// # Errors
92/// Returns an error if the file exists but cannot be read or parsed as valid TOML.
93pub 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/// Resolves the final retention configuration from CLI args and file config.
110///
111/// Priority (highest to lowest):
112/// 1. CLI argument (if provided by user)
113/// 2. File config value (if present in .retention file)
114/// 3. Built-in default
115#[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
149/// Gets the modification time of a file, falling back to creation time.
150///
151/// Uses modification time as primary because it's more reliable across platforms
152/// and is what backup tools typically track for file age.
153///
154/// # Errors
155/// Returns an error if file metadata cannot be read or no timestamp is available.
156pub 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
165/// Scans a directory for files and returns them sorted by creation time (newest first).
166///
167/// # Errors
168/// Returns an error if the directory cannot be read.
169pub 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        // Skip directories and hidden files
177        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    // Sort by creation time, newest first
192    files.sort_by(|a, b| b.created.cmp(&a.created));
193    Ok(files)
194}
195
196/// Returns (year, month, day, hour) as a unique key for the hour
197fn get_hour_key(dt: DateTime<Local>) -> (i32, u32, u32, u32) {
198    (dt.year(), dt.month(), dt.day(), dt.hour())
199}
200
201/// Returns (year, week) using ISO week system
202fn 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/// Applies a time-based retention policy, keeping the newest un-kept file per period.
215///
216/// Iterates files newest-first, skipping already-kept files. For each new period
217/// encountered, keeps the first (newest) un-kept file until `count` periods are covered.
218fn 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/// Selects files to keep with reasons, following Proxmox-style retention semantics.
247///
248/// Policies are applied sequentially in order: keep-last, keep-hourly, keep-daily,
249/// keep-weekly, keep-monthly, keep-yearly. Each policy only considers files not
250/// already kept by a previous policy. Within each time period, the newest (latest)
251/// file is kept. Time-based policies count only periods that have un-kept files —
252/// gaps without backups do not consume slots.
253#[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    // 1. Keep last N files
262    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    // 2-6. Time-based policies: keep newest un-kept file per period
268    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
312/// Moves a file to the system trash, or uses a custom command if provided.
313///
314/// When using a custom command, `{}` in the command is replaced with the file path.
315/// If `{}` is not present, the file path is appended to the command.
316///
317/// # Errors
318/// Returns an error if the file cannot be moved to trash.
319pub 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
349/// Rotates files in a directory based on retention policies.
350///
351/// # Errors
352/// Returns an error if:
353/// - `keep_last` is 0 (must be at least 1)
354/// - The directory cannot be read
355/// - Files cannot be moved to trash
356pub 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    // Scan files
367    let files = scan_files(dir)?;
368
369    if files.is_empty() {
370        return Ok((0, 0));
371    }
372
373    // Determine which files to keep and why
374    let keep_reasons = select_files_to_keep_with_reasons(&files, config);
375
376    // Print kept files with reasons
377    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    // Move files that are not in keep set to system trash
385    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        // Create 5 files with different times
482        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        // Create files in different hours (sorted newest-first like scan_files does)
508        // file4 is newer than file3 but in the same hour
509        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            ), // hour 10, newer
516            make_file_info_with_time("file3.txt", now - chrono::Duration::hours(2)), // hour 10, older
517        ];
518
519        let keep = select_files_to_keep(&files, &config);
520        assert_eq!(keep.len(), 3); // 3 unique hours
521        assert!(keep.contains(&0)); // hour 12
522        assert!(keep.contains(&1)); // hour 11
523        assert!(keep.contains(&2)); // hour 10 (newest file in that hour)
524        assert!(!keep.contains(&3)); // same hour as file4, not kept (older)
525    }
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        // Create files for different days (sorted newest-first like scan_files does)
537        // file3 and file4 are on the same day, but file4 is older
538        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)), // newer on day -2
542            make_file_info("file4.txt", today - chrono::Duration::days(2)), // older on day -2 (same day)
543        ];
544
545        let keep = select_files_to_keep(&files, &config);
546        assert_eq!(keep.len(), 3);
547        assert!(keep.contains(&0)); // today
548        assert!(keep.contains(&1)); // yesterday
549        assert!(keep.contains(&2)); // 2 days ago (newest file on that day)
550        assert!(!keep.contains(&3)); // duplicate day, not kept (older)
551    }
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(); // Saturday
556        let today = now.date_naive();
557        let config = RetentionConfig {
558            keep_weekly: 4,
559            ..zero_config()
560        };
561
562        // Create files spanning different weeks
563        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            ), // same week as file3
571        ];
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        // Create files in different months (sorted newest-first like scan_files does)
585        // file2 and file3 are in the same month (May), but file3 is older
586        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()), // May, newer
589            make_file_info("file3.txt", NaiveDate::from_ymd_opt(2024, 5, 5).unwrap()), // May, older
590            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)); // June
596        assert!(keep.contains(&1)); // May (newest file in that month)
597        assert!(!keep.contains(&2)); // May duplicate, not kept (older)
598        assert!(keep.contains(&3)); // April
599    }
600
601    #[test]
602    fn test_keep_one_per_year() {
603        let config = RetentionConfig {
604            keep_yearly: 5,
605            ..zero_config()
606        };
607
608        // Create files in different years (sorted newest-first like scan_files does)
609        // file2 and file3 are in the same year (2023), but file3 is older
610        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()), // 2023, newer
613            make_file_info("file3.txt", NaiveDate::from_ymd_opt(2023, 1, 5).unwrap()), // 2023, older
614            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)); // 2024
620        assert!(keep.contains(&1)); // 2023 (newest file in that year)
621        assert!(!keep.contains(&2)); // 2023 duplicate, not kept (older)
622        assert!(keep.contains(&3)); // 2022
623    }
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        // File is 10 days old, but it's the only file so it occupies 1 of 5 allowed days
643        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        // keep_daily=3 with gaps: files on day 0, day 3, day 7
656        // All 3 should be kept because N counts days with files, not calendar days
657        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), // day 0
666            make_file_info("file1.txt", today - chrono::Duration::days(3)), // day 3 (gap on 1,2)
667            make_file_info("file2.txt", today - chrono::Duration::days(7)), // day 7 (gap on 4,5,6)
668        ];
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        // keep_daily=2 with 4 files on 3 different days -> only 2 most recent days kept
680        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)); // most recent day
696        assert!(keep.contains(&1)); // 2nd most recent day
697        assert!(!keep.contains(&2)); // 3rd day, exceeds limit
698    }
699
700    #[test]
701    fn test_hourly_skips_gaps() {
702        // keep_hourly=2 with files at hour 12 and hour 8 (gap at 9,10,11)
703        // Both should be kept because N counts hours with files
704        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        // keep_weekly=2 with files in week 0 and week 4 (gap of 3 weeks)
724        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()), // ~4 weeks back
732        ];
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); // All files kept by various policies
762    }
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        // Test that ISO week handles year boundaries correctly
784        // Dec 31, 2024 is in ISO week 1 of 2025
785        let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
786        let (year, week) = get_week_key(date);
787        // The ISO week year for Dec 31, 2024 should be 2025
788        assert_eq!(year, 2025);
789        assert_eq!(week, 1);
790    }
791
792    // ==================== SEQUENTIAL RETENTION TESTS ====================
793    // These tests verify that retention policies are applied sequentially
794    // (Proxmox-style): each policy only considers files not already kept
795    // by a previous policy. Within a period, the newest file is kept.
796
797    #[test]
798    fn test_sequential_policies_single_reason() {
799        // Files kept by an earlier policy are skipped by later policies.
800        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), // hour 12, day 15
810            make_file_info_with_time("file1.txt", now - chrono::Duration::hours(1)), // hour 11, day 15
811        ];
812
813        let reasons = select_files_to_keep_with_reasons(&files, &config);
814
815        // file0: keep-last (claimed first, skipped by hourly)
816        assert_eq!(*reasons.get(&0).unwrap(), RetentionReason::KeepLast);
817
818        // file1: hourly (keep-last didn't reach it, hourly claims it)
819        assert_eq!(*reasons.get(&1).unwrap(), RetentionReason::Hourly);
820    }
821
822    #[test]
823    fn test_sequential_later_policy_sees_remaining_files() {
824        // Later policies pick from files not already kept.
825        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        // All claimed by hourly (3 unique hours, hourly runs before daily)
843        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        // Weekly claims first, monthly picks from remaining.
851        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        // file0, file1: weekly (two different weeks)
868        assert_eq!(*reasons.get(&0).unwrap(), RetentionReason::Weekly);
869        assert_eq!(*reasons.get(&1).unwrap(), RetentionReason::Weekly);
870
871        // file2: monthly (May) — only un-kept file left for monthly
872        assert_eq!(*reasons.get(&2).unwrap(), RetentionReason::Monthly);
873    }
874
875    #[test]
876    fn test_sequential_monthly_then_yearly() {
877        // Monthly claims first, yearly picks from remaining.
878        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        // Each policy claims files in order; all files kept with single reasons.
902        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        // All files should be kept
925        assert_eq!(keep.len(), 6);
926
927        // file0: keep-last (claimed first)
928        assert_eq!(*reasons.get(&0).unwrap(), RetentionReason::KeepLast);
929    }
930
931    #[test]
932    fn test_sequential_same_period_keeps_newest() {
933        // Within a period, the newest (latest) file is kept.
934        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        // Multiple files on the same day - newest should be kept
941        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)); // newest on day 15
952        assert!(!keep.contains(&1)); // not newest on day 15
953        assert!(!keep.contains(&2)); // not newest on day 15
954        assert!(keep.contains(&3)); // newest on day 14
955    }
956
957    // ==================== RETENTION FILE CONFIG TESTS ====================
958
959    #[test]
960    fn test_resolve_config_all_defaults() {
961        // No CLI args, no file config -> all defaults
962        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        // File config values should be used when no CLI args
969        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); // default
983        assert_eq!(config.keep_weekly, 8);
984        assert_eq!(config.keep_monthly, 12); // default
985        assert_eq!(config.keep_yearly, 5);
986    }
987
988    #[test]
989    fn test_resolve_config_cli_overrides_file() {
990        // CLI args should override file config
991        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),  // CLI override
1002            None,     // use file
1003            Some(30), // CLI override
1004            None,     // use file
1005            None,     // use file
1006            Some(2),  // CLI override
1007            Some(&file_config),
1008        );
1009
1010        assert_eq!(config.keep_last, 3); // CLI
1011        assert_eq!(config.keep_hourly, 48); // file
1012        assert_eq!(config.keep_daily, 30); // CLI
1013        assert_eq!(config.keep_weekly, 8); // file
1014        assert_eq!(config.keep_monthly, 24); // file
1015        assert_eq!(config.keep_yearly, 2); // CLI
1016    }
1017
1018    #[test]
1019    fn test_resolve_config_cli_only() {
1020        // CLI args with no file config
1021        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        // Ensure no .retention file exists
1062        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}