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