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/// Selects files to keep with reasons.
215///
216/// Each retention policy is applied independently to all files. A file may be kept
217/// by multiple policies, and all matching policies are tracked in the returned map.
218/// Time-based policies (hourly, daily, etc.) count only periods that have files —
219/// gaps without backups do not consume slots.
220#[must_use]
221pub fn select_files_to_keep_with_reasons(
222    files: &[FileInfo],
223    config: &RetentionConfig,
224) -> HashMap<usize, HashSet<RetentionReason>> {
225    let mut keep_reasons: HashMap<usize, HashSet<RetentionReason>> = HashMap::new();
226
227    // Helper to add a reason for a file
228    let mut add_reason = |i: usize, reason: RetentionReason| {
229        keep_reasons.entry(i).or_default().insert(reason);
230    };
231
232    // 1. Keep last N files
233    for i in 0..config.keep_last.min(files.len()) {
234        add_reason(i, RetentionReason::KeepLast);
235    }
236
237    // 2. Keep 1 file per hour for N hours (oldest file in each hour)
238    // N counts only hours that actually have files (gaps are skipped)
239    if config.keep_hourly > 0 {
240        // First pass: find the N most recent unique hours (files are sorted newest-first)
241        let mut allowed_hours: HashSet<(i32, u32, u32, u32)> = HashSet::new();
242        for file in files {
243            allowed_hours.insert(get_hour_key(file.created));
244            if allowed_hours.len() >= config.keep_hourly as usize {
245                break;
246            }
247        }
248        // Second pass: iterate oldest-first to keep oldest file per hour
249        let mut covered_hours: HashSet<(i32, u32, u32, u32)> = HashSet::new();
250        for (i, file) in files.iter().enumerate().rev() {
251            let hour_key = get_hour_key(file.created);
252            if allowed_hours.contains(&hour_key) && !covered_hours.contains(&hour_key) {
253                covered_hours.insert(hour_key);
254                add_reason(i, RetentionReason::Hourly);
255            }
256        }
257    }
258
259    // 3. Keep 1 file per day for N days (oldest file in each day)
260    // N counts only days that actually have files (gaps are skipped)
261    if config.keep_daily > 0 {
262        // First pass: find the N most recent unique days (files are sorted newest-first)
263        let mut allowed_days: HashSet<NaiveDate> = HashSet::new();
264        for file in files {
265            allowed_days.insert(file.created.date_naive());
266            if allowed_days.len() >= config.keep_daily as usize {
267                break;
268            }
269        }
270        // Second pass: iterate oldest-first to keep oldest file per day
271        let mut covered_days: HashSet<NaiveDate> = HashSet::new();
272        for (i, file) in files.iter().enumerate().rev() {
273            let file_date = file.created.date_naive();
274            if allowed_days.contains(&file_date) && !covered_days.contains(&file_date) {
275                covered_days.insert(file_date);
276                add_reason(i, RetentionReason::Daily);
277            }
278        }
279    }
280
281    // 4. Keep 1 file per week for N weeks (ISO week system, oldest file in each week)
282    // N counts only weeks that actually have files (gaps are skipped)
283    if config.keep_weekly > 0 {
284        // First pass: find the N most recent unique weeks (files are sorted newest-first)
285        let mut allowed_weeks: HashSet<(i32, u32)> = HashSet::new();
286        for file in files {
287            allowed_weeks.insert(get_week_key(file.created.date_naive()));
288            if allowed_weeks.len() >= config.keep_weekly as usize {
289                break;
290            }
291        }
292        // Second pass: iterate oldest-first to keep oldest file per week
293        let mut covered_weeks: HashSet<(i32, u32)> = HashSet::new();
294        for (i, file) in files.iter().enumerate().rev() {
295            let week_key = get_week_key(file.created.date_naive());
296            if allowed_weeks.contains(&week_key) && !covered_weeks.contains(&week_key) {
297                covered_weeks.insert(week_key);
298                add_reason(i, RetentionReason::Weekly);
299            }
300        }
301    }
302
303    // 5. Keep 1 file per month for N months (oldest file in each month)
304    // N counts only months that actually have files (gaps are skipped)
305    if config.keep_monthly > 0 {
306        // First pass: find the N most recent unique months (files are sorted newest-first)
307        let mut allowed_months: HashSet<(i32, u32)> = HashSet::new();
308        for file in files {
309            allowed_months.insert(get_month_key(file.created.date_naive()));
310            if allowed_months.len() >= config.keep_monthly as usize {
311                break;
312            }
313        }
314        // Second pass: iterate oldest-first to keep oldest file per month
315        let mut covered_months: HashSet<(i32, u32)> = HashSet::new();
316        for (i, file) in files.iter().enumerate().rev() {
317            let month_key = get_month_key(file.created.date_naive());
318            if allowed_months.contains(&month_key) && !covered_months.contains(&month_key) {
319                covered_months.insert(month_key);
320                add_reason(i, RetentionReason::Monthly);
321            }
322        }
323    }
324
325    // 6. Keep 1 file per year for N years (oldest file in each year)
326    // N counts only years that actually have files (gaps are skipped)
327    if config.keep_yearly > 0 {
328        // First pass: find the N most recent unique years (files are sorted newest-first)
329        let mut allowed_years: HashSet<i32> = HashSet::new();
330        for file in files {
331            allowed_years.insert(get_year_key(file.created.date_naive()));
332            if allowed_years.len() >= config.keep_yearly as usize {
333                break;
334            }
335        }
336        // Second pass: iterate oldest-first to keep oldest file per year
337        let mut covered_years: HashSet<i32> = HashSet::new();
338        for (i, file) in files.iter().enumerate().rev() {
339            let year_key = get_year_key(file.created.date_naive());
340            if allowed_years.contains(&year_key) && !covered_years.contains(&year_key) {
341                covered_years.insert(year_key);
342                add_reason(i, RetentionReason::Yearly);
343            }
344        }
345    }
346
347    keep_reasons
348}
349
350#[must_use]
351pub fn select_files_to_keep(files: &[FileInfo], config: &RetentionConfig) -> HashSet<usize> {
352    select_files_to_keep_with_reasons(files, config)
353        .into_keys()
354        .collect()
355}
356
357/// Moves a file to the system trash, or uses a custom command if provided.
358///
359/// When using a custom command, `{}` in the command is replaced with the file path.
360/// If `{}` is not present, the file path is appended to the command.
361///
362/// # Errors
363/// Returns an error if the file cannot be moved to trash.
364pub fn move_to_trash(file: &Path, dry_run: bool, trash_cmd: Option<&str>) -> Result<()> {
365    if dry_run {
366        println!("Would move to trash: {}", file.display());
367    } else if let Some(cmd) = trash_cmd {
368        let escaped_path = shell_escape::escape(file.to_string_lossy());
369        let full_cmd = if cmd.contains("{}") {
370            cmd.replace("{}", &escaped_path)
371        } else {
372            format!("{cmd} {escaped_path}")
373        };
374        let status = Command::new("sh")
375            .arg("-c")
376            .arg(&full_cmd)
377            .status()
378            .context("Failed to execute trash command")?;
379        if !status.success() {
380            anyhow::bail!(
381                "Trash command failed with exit code: {}",
382                status.code().unwrap_or(-1)
383            );
384        }
385        println!("Moved to trash: {}", file.display());
386    } else {
387        trash::delete(file).context("Failed to move file to trash")?;
388        println!("Moved to trash: {}", file.display());
389    }
390
391    Ok(())
392}
393
394/// Rotates files in a directory based on retention policies.
395///
396/// # Errors
397/// Returns an error if:
398/// - `keep_last` is 0 (must be at least 1)
399/// - The directory cannot be read
400/// - Files cannot be moved to trash
401pub fn rotate_files(
402    dir: &Path,
403    config: &RetentionConfig,
404    dry_run: bool,
405    trash_cmd: Option<&str>,
406) -> Result<(usize, usize)> {
407    if config.keep_last == 0 {
408        anyhow::bail!("keep-last must be at least 1");
409    }
410
411    // Scan files
412    let files = scan_files(dir)?;
413
414    if files.is_empty() {
415        return Ok((0, 0));
416    }
417
418    // Determine which files to keep and why
419    let keep_reasons = select_files_to_keep_with_reasons(&files, config);
420
421    // Print kept files with reasons
422    for (i, file) in files.iter().enumerate() {
423        if let Some(reasons) = keep_reasons.get(&i) {
424            let prefix = if dry_run { "Would keep" } else { "Keeping" };
425            let reasons_str: Vec<String> = reasons.iter().map(ToString::to_string).collect();
426            let reasons_display = reasons_str.join(", ");
427            println!("{prefix}: {} ({reasons_display})", file.path.display());
428        }
429    }
430
431    // Move files that are not in keep set to system trash
432    let mut moved_count = 0;
433    for (i, file) in files.iter().enumerate() {
434        if !keep_reasons.contains_key(&i) {
435            move_to_trash(&file.path, dry_run, trash_cmd)?;
436            moved_count += 1;
437        }
438    }
439
440    Ok((keep_reasons.len(), moved_count))
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use chrono::TimeZone;
447
448    fn make_file_info_with_time(name: &str, dt: DateTime<Local>) -> FileInfo {
449        FileInfo {
450            path: PathBuf::from(name),
451            created: dt,
452        }
453    }
454
455    fn make_file_info(name: &str, date: NaiveDate) -> FileInfo {
456        let datetime = Local
457            .from_local_datetime(&date.and_hms_opt(12, 0, 0).unwrap())
458            .single()
459            .unwrap();
460        FileInfo {
461            path: PathBuf::from(name),
462            created: datetime,
463        }
464    }
465
466    fn zero_config() -> RetentionConfig {
467        RetentionConfig {
468            keep_last: 0,
469            keep_hourly: 0,
470            keep_daily: 0,
471            keep_weekly: 0,
472            keep_monthly: 0,
473            keep_yearly: 0,
474        }
475    }
476
477    #[test]
478    fn test_default_config() {
479        let config = RetentionConfig::default();
480        assert_eq!(config.keep_last, 5);
481        assert_eq!(config.keep_hourly, 24);
482        assert_eq!(config.keep_daily, 7);
483        assert_eq!(config.keep_weekly, 4);
484        assert_eq!(config.keep_monthly, 12);
485        assert_eq!(config.keep_yearly, 10);
486    }
487
488    #[test]
489    fn test_get_hour_key() {
490        let dt = Local.with_ymd_and_hms(2024, 6, 15, 14, 30, 0).unwrap();
491        assert_eq!(get_hour_key(dt), (2024, 6, 15, 14));
492    }
493
494    #[test]
495    fn test_get_week_key() {
496        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
497        let (year, week) = get_week_key(date);
498        assert_eq!(year, 2024);
499        assert!(week >= 1 && week <= 53);
500    }
501
502    #[test]
503    fn test_get_month_key() {
504        let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
505        assert_eq!(get_month_key(date), (2024, 6));
506    }
507
508    #[test]
509    fn test_get_year_key() {
510        let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
511        assert_eq!(get_year_key(date), 2024);
512    }
513
514    #[test]
515    fn test_keep_last_n_files() {
516        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
517        let config = RetentionConfig {
518            keep_last: 3,
519            ..zero_config()
520        };
521
522        // Create 5 files with different times
523        let files: Vec<FileInfo> = (0..5)
524            .map(|i| {
525                let dt = now - chrono::Duration::minutes(i as i64);
526                FileInfo {
527                    path: PathBuf::from(format!("file{}.txt", i)),
528                    created: dt,
529                }
530            })
531            .collect();
532
533        let keep = select_files_to_keep(&files, &config);
534        assert_eq!(keep.len(), 3);
535        assert!(keep.contains(&0));
536        assert!(keep.contains(&1));
537        assert!(keep.contains(&2));
538    }
539
540    #[test]
541    fn test_keep_one_per_hour() {
542        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
543        let config = RetentionConfig {
544            keep_hourly: 5,
545            ..zero_config()
546        };
547
548        // Create files in different hours (sorted newest-first like scan_files does)
549        // file4 is newer than file3 but in the same hour
550        let files = vec![
551            make_file_info_with_time("file1.txt", now),
552            make_file_info_with_time("file2.txt", now - chrono::Duration::hours(1)),
553            make_file_info_with_time(
554                "file4.txt",
555                now - chrono::Duration::hours(2) + chrono::Duration::minutes(30),
556            ), // hour 10, newer
557            make_file_info_with_time("file3.txt", now - chrono::Duration::hours(2)), // hour 10, older
558        ];
559
560        let keep = select_files_to_keep(&files, &config);
561        assert_eq!(keep.len(), 3); // 3 unique hours
562        assert!(keep.contains(&0)); // hour 12
563        assert!(keep.contains(&1)); // hour 11
564        assert!(keep.contains(&3)); // hour 10 (oldest file in that hour)
565        assert!(!keep.contains(&2)); // same hour as file3, not kept (newer)
566    }
567
568    #[test]
569    fn test_keep_one_per_day() {
570        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
571        let today = now.date_naive();
572        let config = RetentionConfig {
573            keep_daily: 5,
574            ..zero_config()
575        };
576
577        // Create files for different days (sorted newest-first like scan_files does)
578        // file3 and file4 are on the same day, but file4 is older
579        let files = vec![
580            make_file_info("file1.txt", today),
581            make_file_info("file2.txt", today - chrono::Duration::days(1)),
582            make_file_info("file3.txt", today - chrono::Duration::days(2)), // newer on day -2
583            make_file_info("file4.txt", today - chrono::Duration::days(2)), // older on day -2 (same day)
584        ];
585
586        let keep = select_files_to_keep(&files, &config);
587        assert_eq!(keep.len(), 3);
588        assert!(keep.contains(&0)); // today
589        assert!(keep.contains(&1)); // yesterday
590        assert!(keep.contains(&3)); // 2 days ago (oldest file on that day)
591        assert!(!keep.contains(&2)); // duplicate day, not kept
592    }
593
594    #[test]
595    fn test_keep_one_per_week() {
596        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(); // Saturday
597        let today = now.date_naive();
598        let config = RetentionConfig {
599            keep_weekly: 4,
600            ..zero_config()
601        };
602
603        // Create files spanning different weeks
604        let files = vec![
605            make_file_info("file1.txt", today),
606            make_file_info("file2.txt", today - chrono::Duration::weeks(1)),
607            make_file_info("file3.txt", today - chrono::Duration::weeks(2)),
608            make_file_info(
609                "file4.txt",
610                today - chrono::Duration::weeks(2) + chrono::Duration::days(1),
611            ), // same week as file3
612        ];
613
614        let keep = select_files_to_keep(&files, &config);
615        assert_eq!(keep.len(), 3);
616    }
617
618    #[test]
619    fn test_keep_one_per_month() {
620        let config = RetentionConfig {
621            keep_monthly: 6,
622            ..zero_config()
623        };
624
625        // Create files in different months (sorted newest-first like scan_files does)
626        // file2 and file3 are in the same month (May), but file3 is older
627        let files = vec![
628            make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
629            make_file_info("file2.txt", NaiveDate::from_ymd_opt(2024, 5, 10).unwrap()), // May, newer
630            make_file_info("file3.txt", NaiveDate::from_ymd_opt(2024, 5, 5).unwrap()), // May, older
631            make_file_info("file4.txt", NaiveDate::from_ymd_opt(2024, 4, 20).unwrap()),
632        ];
633
634        let keep = select_files_to_keep(&files, &config);
635        assert_eq!(keep.len(), 3);
636        assert!(keep.contains(&0)); // June
637        assert!(keep.contains(&2)); // May (oldest file in that month)
638        assert!(!keep.contains(&1)); // May duplicate, not kept (newer)
639        assert!(keep.contains(&3)); // April
640    }
641
642    #[test]
643    fn test_keep_one_per_year() {
644        let config = RetentionConfig {
645            keep_yearly: 5,
646            ..zero_config()
647        };
648
649        // Create files in different years (sorted newest-first like scan_files does)
650        // file2 and file3 are in the same year (2023), but file3 is older
651        let files = vec![
652            make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
653            make_file_info("file2.txt", NaiveDate::from_ymd_opt(2023, 3, 10).unwrap()), // 2023, newer
654            make_file_info("file3.txt", NaiveDate::from_ymd_opt(2023, 1, 5).unwrap()), // 2023, older
655            make_file_info("file4.txt", NaiveDate::from_ymd_opt(2022, 12, 20).unwrap()),
656        ];
657
658        let keep = select_files_to_keep(&files, &config);
659        assert_eq!(keep.len(), 3);
660        assert!(keep.contains(&0)); // 2024
661        assert!(keep.contains(&2)); // 2023 (oldest file in that year)
662        assert!(!keep.contains(&1)); // 2023 duplicate, not kept (newer)
663        assert!(keep.contains(&3)); // 2022
664    }
665
666    #[test]
667    fn test_empty_files() {
668        let config = RetentionConfig::default();
669        let files: Vec<FileInfo> = vec![];
670
671        let keep = select_files_to_keep(&files, &config);
672        assert!(keep.is_empty());
673    }
674
675    #[test]
676    fn test_old_file_kept_when_within_n_unique_days() {
677        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
678        let config = RetentionConfig {
679            keep_daily: 5,
680            ..zero_config()
681        };
682
683        // File is 10 days old, but it's the only file so it occupies 1 of 5 allowed days
684        let files = vec![make_file_info(
685            "old_file.txt",
686            now.date_naive() - chrono::Duration::days(10),
687        )];
688
689        let keep = select_files_to_keep(&files, &config);
690        assert_eq!(keep.len(), 1);
691        assert!(keep.contains(&0));
692    }
693
694    #[test]
695    fn test_daily_skips_gaps() {
696        // keep_daily=3 with gaps: files on day 0, day 3, day 7
697        // All 3 should be kept because N counts days with files, not calendar days
698        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
699        let today = now.date_naive();
700        let config = RetentionConfig {
701            keep_daily: 3,
702            ..zero_config()
703        };
704
705        let files = vec![
706            make_file_info("file0.txt", today), // day 0
707            make_file_info("file1.txt", today - chrono::Duration::days(3)), // day 3 (gap on 1,2)
708            make_file_info("file2.txt", today - chrono::Duration::days(7)), // day 7 (gap on 4,5,6)
709        ];
710
711        let keep = select_files_to_keep(&files, &config);
712        assert_eq!(keep.len(), 3);
713        assert!(keep.contains(&0));
714        assert!(keep.contains(&1));
715        assert!(keep.contains(&2));
716    }
717
718    #[test]
719    fn test_daily_limits_to_n_most_recent_days() {
720        // keep_daily=2 with 4 files on 3 different days -> only 2 most recent days kept
721        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
722        let today = now.date_naive();
723        let config = RetentionConfig {
724            keep_daily: 2,
725            ..zero_config()
726        };
727
728        let files = vec![
729            make_file_info("file0.txt", today),
730            make_file_info("file1.txt", today - chrono::Duration::days(5)),
731            make_file_info("file2.txt", today - chrono::Duration::days(10)),
732        ];
733
734        let keep = select_files_to_keep(&files, &config);
735        assert_eq!(keep.len(), 2);
736        assert!(keep.contains(&0)); // most recent day
737        assert!(keep.contains(&1)); // 2nd most recent day
738        assert!(!keep.contains(&2)); // 3rd day, exceeds limit
739    }
740
741    #[test]
742    fn test_hourly_skips_gaps() {
743        // keep_hourly=2 with files at hour 12 and hour 8 (gap at 9,10,11)
744        // Both should be kept because N counts hours with files
745        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
746        let config = RetentionConfig {
747            keep_hourly: 2,
748            ..zero_config()
749        };
750
751        let files = vec![
752            make_file_info_with_time("file0.txt", now),
753            make_file_info_with_time("file1.txt", now - chrono::Duration::hours(4)),
754        ];
755
756        let keep = select_files_to_keep(&files, &config);
757        assert_eq!(keep.len(), 2);
758        assert!(keep.contains(&0));
759        assert!(keep.contains(&1));
760    }
761
762    #[test]
763    fn test_weekly_skips_gaps() {
764        // keep_weekly=2 with files in week 0 and week 4 (gap of 3 weeks)
765        let config = RetentionConfig {
766            keep_weekly: 2,
767            ..zero_config()
768        };
769
770        let files = vec![
771            make_file_info("file0.txt", NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
772            make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 5, 18).unwrap()), // ~4 weeks back
773        ];
774
775        let keep = select_files_to_keep(&files, &config);
776        assert_eq!(keep.len(), 2);
777        assert!(keep.contains(&0));
778        assert!(keep.contains(&1));
779    }
780
781    #[test]
782    fn test_combined_retention_policies() {
783        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
784        let config = RetentionConfig {
785            keep_last: 2,
786            keep_hourly: 3,
787            keep_daily: 3,
788            keep_weekly: 2,
789            keep_monthly: 2,
790            keep_yearly: 1,
791        };
792
793        let files = vec![
794            make_file_info_with_time("file1.txt", now),
795            make_file_info_with_time("file2.txt", now - chrono::Duration::hours(1)),
796            make_file_info_with_time("file3.txt", now - chrono::Duration::days(1)),
797            make_file_info_with_time("file4.txt", now - chrono::Duration::days(10)),
798            make_file_info_with_time("file5.txt", now - chrono::Duration::days(40)),
799        ];
800
801        let keep = select_files_to_keep(&files, &config);
802        assert_eq!(keep.len(), 5); // All files kept by various policies
803    }
804
805    #[test]
806    fn test_keep_last_more_than_files() {
807        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
808        let config = RetentionConfig {
809            keep_last: 100,
810            ..zero_config()
811        };
812
813        let files = vec![
814            make_file_info("file1.txt", now.date_naive()),
815            make_file_info("file2.txt", now.date_naive() - chrono::Duration::days(1)),
816        ];
817
818        let keep = select_files_to_keep(&files, &config);
819        assert_eq!(keep.len(), 2);
820    }
821
822    #[test]
823    fn test_iso_week_year_boundary() {
824        // Test that ISO week handles year boundaries correctly
825        // Dec 31, 2024 is in ISO week 1 of 2025
826        let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
827        let (year, week) = get_week_key(date);
828        // The ISO week year for Dec 31, 2024 should be 2025
829        assert_eq!(year, 2025);
830        assert_eq!(week, 1);
831    }
832
833    // ==================== INDEPENDENT RETENTION TESTS ====================
834    // These tests verify that retention policies are applied independently,
835    // and a file can be kept by multiple policies.
836
837    #[test]
838    fn test_independent_policies_multiple_reasons() {
839        // A file can be kept by multiple policies simultaneously.
840        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
841        let config = RetentionConfig {
842            keep_last: 1,
843            keep_hourly: 2,
844            keep_daily: 2,
845            ..zero_config()
846        };
847
848        // file0 is keep-last and oldest in its hour (hour 12)
849        // file1 is oldest in its hour (hour 11) AND oldest file on day 15
850        let files = vec![
851            make_file_info_with_time("file0.txt", now), // hour 12, day 15
852            make_file_info_with_time("file1.txt", now - chrono::Duration::hours(1)), // hour 11, day 15
853        ];
854
855        let reasons = select_files_to_keep_with_reasons(&files, &config);
856
857        // file0 should have keep-last and hourly (oldest in hour 12)
858        let file0_reasons = reasons.get(&0).unwrap();
859        assert!(file0_reasons.contains(&RetentionReason::KeepLast));
860        assert!(file0_reasons.contains(&RetentionReason::Hourly));
861
862        // file1 should have hourly (hour 11) and daily (oldest on day 15)
863        let file1_reasons = reasons.get(&1).unwrap();
864        assert!(file1_reasons.contains(&RetentionReason::Hourly));
865        assert!(file1_reasons.contains(&RetentionReason::Daily));
866    }
867
868    #[test]
869    fn test_independent_policies_overlapping_periods() {
870        // Policies evaluate all files independently; overlapping periods are fine.
871        let now = Local.with_ymd_and_hms(2024, 6, 15, 14, 0, 0).unwrap();
872        let config = RetentionConfig {
873            keep_hourly: 3,
874            keep_daily: 2,
875            ..zero_config()
876        };
877
878        let files = vec![
879            make_file_info_with_time("file0.txt", now),
880            make_file_info_with_time("file1.txt", now - chrono::Duration::hours(1)),
881            make_file_info_with_time("file2.txt", now - chrono::Duration::days(1)),
882        ];
883
884        let keep = select_files_to_keep(&files, &config);
885        let reasons = select_files_to_keep_with_reasons(&files, &config);
886
887        assert_eq!(keep.len(), 3);
888
889        // file0: hourly only (hour 14)
890        let file0_reasons = reasons.get(&0).unwrap();
891        assert!(file0_reasons.contains(&RetentionReason::Hourly));
892        assert!(!file0_reasons.contains(&RetentionReason::Daily)); // file1 is older on same day
893
894        // file1: hourly (hour 13) + daily (oldest on day 15)
895        let file1_reasons = reasons.get(&1).unwrap();
896        assert!(file1_reasons.contains(&RetentionReason::Hourly));
897        assert!(file1_reasons.contains(&RetentionReason::Daily));
898
899        // file2: daily (day 14)
900        assert!(reasons.get(&2).unwrap().contains(&RetentionReason::Daily));
901    }
902
903    #[test]
904    fn test_independent_weekly_and_monthly() {
905        // Weekly and monthly policies can both keep the same file.
906        let config = RetentionConfig {
907            keep_weekly: 2,
908            keep_monthly: 3,
909            ..zero_config()
910        };
911
912        let files = vec![
913            make_file_info("file0.txt", NaiveDate::from_ymd_opt(2024, 6, 28).unwrap()),
914            make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 6, 21).unwrap()),
915            make_file_info("file2.txt", NaiveDate::from_ymd_opt(2024, 5, 15).unwrap()),
916        ];
917
918        let keep = select_files_to_keep(&files, &config);
919        let reasons = select_files_to_keep_with_reasons(&files, &config);
920
921        assert_eq!(keep.len(), 3);
922
923        // file0: weekly (week 26)
924        let file0_reasons = reasons.get(&0).unwrap();
925        assert!(file0_reasons.contains(&RetentionReason::Weekly));
926
927        // file1: weekly (week 25) + monthly (oldest in June)
928        let file1_reasons = reasons.get(&1).unwrap();
929        assert!(file1_reasons.contains(&RetentionReason::Weekly));
930        assert!(file1_reasons.contains(&RetentionReason::Monthly));
931
932        // file2: monthly (May)
933        assert!(reasons.get(&2).unwrap().contains(&RetentionReason::Monthly));
934    }
935
936    #[test]
937    fn test_independent_monthly_and_yearly() {
938        // Monthly and yearly policies can both keep the same file.
939        let config = RetentionConfig {
940            keep_monthly: 2,
941            keep_yearly: 3,
942            ..zero_config()
943        };
944
945        let files = vec![
946            make_file_info("file0.txt", NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
947            make_file_info("file1.txt", NaiveDate::from_ymd_opt(2024, 5, 15).unwrap()),
948            make_file_info("file2.txt", NaiveDate::from_ymd_opt(2023, 12, 1).unwrap()),
949        ];
950
951        let keep = select_files_to_keep(&files, &config);
952        let reasons = select_files_to_keep_with_reasons(&files, &config);
953
954        assert_eq!(keep.len(), 3);
955
956        // file0: monthly (June 2024)
957        let file0_reasons = reasons.get(&0).unwrap();
958        assert!(file0_reasons.contains(&RetentionReason::Monthly));
959
960        // file1: monthly (May 2024) + yearly (oldest in 2024)
961        let file1_reasons = reasons.get(&1).unwrap();
962        assert!(file1_reasons.contains(&RetentionReason::Monthly));
963        assert!(file1_reasons.contains(&RetentionReason::Yearly));
964
965        // file2: yearly (2023)
966        assert!(reasons.get(&2).unwrap().contains(&RetentionReason::Yearly));
967    }
968
969    #[test]
970    fn test_independent_full_chain() {
971        // Test that all policies are applied independently.
972        // Each policy needs enough slots to reach the files it should cover.
973        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
974        let config = RetentionConfig {
975            keep_last: 1,
976            keep_hourly: 2,
977            keep_daily: 2,
978            keep_weekly: 2,
979            keep_monthly: 2,
980            keep_yearly: 2,
981        };
982
983        let files = vec![
984            make_file_info_with_time("file0.txt", now),
985            make_file_info_with_time("file1.txt", now - chrono::Duration::minutes(30)),
986            make_file_info_with_time("file2.txt", now - chrono::Duration::hours(5)),
987            make_file_info_with_time("file3.txt", now - chrono::Duration::days(2)),
988            make_file_info_with_time("file4.txt", now - chrono::Duration::weeks(2)),
989            make_file_info("file5.txt", NaiveDate::from_ymd_opt(2023, 7, 15).unwrap()),
990        ];
991
992        let keep = select_files_to_keep(&files, &config);
993        let reasons = select_files_to_keep_with_reasons(&files, &config);
994
995        assert_eq!(keep.len(), 6);
996
997        // file0 should have multiple reasons (keep-last + hourly)
998        let file0_reasons = reasons.get(&0).unwrap();
999        assert!(file0_reasons.contains(&RetentionReason::KeepLast));
1000        assert!(file0_reasons.contains(&RetentionReason::Hourly));
1001
1002        // All files should be kept
1003        assert!(keep.contains(&0));
1004        assert!(keep.contains(&1));
1005        assert!(keep.contains(&2));
1006        assert!(keep.contains(&3));
1007        assert!(keep.contains(&4));
1008        assert!(keep.contains(&5));
1009    }
1010
1011    #[test]
1012    fn test_independent_same_period_keeps_oldest() {
1013        // Within a period, policies keep the oldest file in that period.
1014        let now = Local.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
1015        let config = RetentionConfig {
1016            keep_daily: 2,
1017            ..zero_config()
1018        };
1019
1020        // Multiple files on the same day - oldest should be kept
1021        let files = vec![
1022            make_file_info_with_time("file0.txt", now),
1023            make_file_info_with_time("file1.txt", now - chrono::Duration::hours(2)),
1024            make_file_info_with_time("file2.txt", now - chrono::Duration::hours(4)),
1025            make_file_info_with_time("file3.txt", now - chrono::Duration::days(1)),
1026        ];
1027
1028        let keep = select_files_to_keep(&files, &config);
1029
1030        assert_eq!(keep.len(), 2);
1031        assert!(keep.contains(&2)); // oldest on day 15
1032        assert!(!keep.contains(&0)); // not oldest on day 15
1033        assert!(!keep.contains(&1)); // not oldest on day 15
1034        assert!(keep.contains(&3)); // oldest on day 14
1035    }
1036
1037    // ==================== RETENTION FILE CONFIG TESTS ====================
1038
1039    #[test]
1040    fn test_resolve_config_all_defaults() {
1041        // No CLI args, no file config -> all defaults
1042        let config = resolve_config(None, None, None, None, None, None, None);
1043        assert_eq!(config, RetentionConfig::default());
1044    }
1045
1046    #[test]
1047    fn test_resolve_config_file_values() {
1048        // File config values should be used when no CLI args
1049        let file_config = RetentionFileConfig {
1050            keep_last: Some(10),
1051            keep_hourly: Some(48),
1052            keep_daily: None,
1053            keep_weekly: Some(8),
1054            keep_monthly: None,
1055            keep_yearly: Some(5),
1056        };
1057
1058        let config = resolve_config(None, None, None, None, None, None, Some(&file_config));
1059
1060        assert_eq!(config.keep_last, 10);
1061        assert_eq!(config.keep_hourly, 48);
1062        assert_eq!(config.keep_daily, 7); // default
1063        assert_eq!(config.keep_weekly, 8);
1064        assert_eq!(config.keep_monthly, 12); // default
1065        assert_eq!(config.keep_yearly, 5);
1066    }
1067
1068    #[test]
1069    fn test_resolve_config_cli_overrides_file() {
1070        // CLI args should override file config
1071        let file_config = RetentionFileConfig {
1072            keep_last: Some(10),
1073            keep_hourly: Some(48),
1074            keep_daily: Some(14),
1075            keep_weekly: Some(8),
1076            keep_monthly: Some(24),
1077            keep_yearly: Some(5),
1078        };
1079
1080        let config = resolve_config(
1081            Some(3),  // CLI override
1082            None,     // use file
1083            Some(30), // CLI override
1084            None,     // use file
1085            None,     // use file
1086            Some(2),  // CLI override
1087            Some(&file_config),
1088        );
1089
1090        assert_eq!(config.keep_last, 3); // CLI
1091        assert_eq!(config.keep_hourly, 48); // file
1092        assert_eq!(config.keep_daily, 30); // CLI
1093        assert_eq!(config.keep_weekly, 8); // file
1094        assert_eq!(config.keep_monthly, 24); // file
1095        assert_eq!(config.keep_yearly, 2); // CLI
1096    }
1097
1098    #[test]
1099    fn test_resolve_config_cli_only() {
1100        // CLI args with no file config
1101        let config = resolve_config(Some(1), Some(12), Some(3), Some(2), Some(6), Some(3), None);
1102
1103        assert_eq!(config.keep_last, 1);
1104        assert_eq!(config.keep_hourly, 12);
1105        assert_eq!(config.keep_daily, 3);
1106        assert_eq!(config.keep_weekly, 2);
1107        assert_eq!(config.keep_monthly, 6);
1108        assert_eq!(config.keep_yearly, 3);
1109    }
1110
1111    #[test]
1112    fn test_retention_file_config_parse_toml() {
1113        let toml_content = r#"
1114keep-last = 10
1115keep-hourly = 48
1116keep-daily = 14
1117"#;
1118        let config: RetentionFileConfig = toml::from_str(toml_content).unwrap();
1119
1120        assert_eq!(config.keep_last, Some(10));
1121        assert_eq!(config.keep_hourly, Some(48));
1122        assert_eq!(config.keep_daily, Some(14));
1123        assert_eq!(config.keep_weekly, None);
1124        assert_eq!(config.keep_monthly, None);
1125        assert_eq!(config.keep_yearly, None);
1126    }
1127
1128    #[test]
1129    fn test_retention_file_config_empty_toml() {
1130        let toml_content = "";
1131        let config: RetentionFileConfig = toml::from_str(toml_content).unwrap();
1132
1133        assert_eq!(config.keep_last, None);
1134        assert_eq!(config.keep_hourly, None);
1135    }
1136
1137    #[test]
1138    fn test_read_retention_file_not_exists() {
1139        let dir = std::env::temp_dir().join("prune_backup_test_no_file");
1140        let _ = std::fs::create_dir(&dir);
1141        // Ensure no .retention file exists
1142        let _ = std::fs::remove_file(dir.join(RETENTION_FILE_NAME));
1143
1144        let result = read_retention_file(&dir);
1145        assert!(result.is_ok());
1146        assert!(result.unwrap().is_none());
1147
1148        let _ = std::fs::remove_dir(&dir);
1149    }
1150
1151    #[test]
1152    fn test_read_retention_file_exists() {
1153        let dir = std::env::temp_dir().join("prune_backup_test_with_file");
1154        let _ = std::fs::create_dir_all(&dir);
1155
1156        let file_path = dir.join(RETENTION_FILE_NAME);
1157        std::fs::write(&file_path, "keep-last = 3\nkeep-daily = 10\n").unwrap();
1158
1159        let result = read_retention_file(&dir);
1160        assert!(result.is_ok());
1161        let config = result.unwrap().unwrap();
1162        assert_eq!(config.keep_last, Some(3));
1163        assert_eq!(config.keep_daily, Some(10));
1164        assert_eq!(config.keep_hourly, None);
1165
1166        let _ = std::fs::remove_file(&file_path);
1167        let _ = std::fs::remove_dir(&dir);
1168    }
1169
1170    #[test]
1171    fn test_read_retention_file_invalid_toml() {
1172        let dir = std::env::temp_dir().join("prune_backup_test_invalid");
1173        let _ = std::fs::create_dir_all(&dir);
1174
1175        let file_path = dir.join(RETENTION_FILE_NAME);
1176        std::fs::write(&file_path, "this is not valid toml {{{{").unwrap();
1177
1178        let result = read_retention_file(&dir);
1179        assert!(result.is_err());
1180
1181        let _ = std::fs::remove_file(&file_path);
1182        let _ = std::fs::remove_dir(&dir);
1183    }
1184}