tmux_backup/management/
backup.rs

1//! High-level representation of a backup for catalog operations and reporting.
2
3use std::fmt;
4use std::path::PathBuf;
5
6use chrono::{Duration, NaiveDateTime};
7use clap::ValueEnum;
8
9/// Quick access, high-level representation of a backup.
10///
11/// `Backup` provides only information which can be derived from the file name, avoiding to open
12/// the file, deal with the format, parse the metadata, etc.
13///
14/// This is sufficient for the [`Catalog`](crate::management::catalog::Catalog) to list backups
15/// and decide whether or not a backup should be deleted or kept.
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub struct Backup {
18    /// Path to the backup file.
19    pub filepath: PathBuf,
20
21    /// Backup date.
22    pub creation_date: NaiveDateTime,
23}
24
25impl Backup {
26    /// Return a string representing the duration since the backup file was created.
27    ///
28    // This function can only receive properly formatted files
29    pub fn age(&self, now: NaiveDateTime) -> String {
30        let duration = now.signed_duration_since(self.creation_date);
31        let duration_secs = duration.num_seconds();
32
33        // Month scale -> "n months ago"
34        let month = Duration::weeks(4).num_seconds();
35        if duration_secs >= 2 * month {
36            return format!("{} months", duration_secs / month);
37        }
38        if duration_secs >= month {
39            return "1 month".into();
40        }
41
42        // Week scale -> "n weeks ago"
43        let week = Duration::weeks(1).num_seconds();
44        if duration_secs >= 2 * week {
45            return format!("{} weeks", duration_secs / week);
46        }
47        if duration_secs >= week {
48            return "1 week".into();
49        }
50
51        // Day scale -> "n days ago"
52        let day = Duration::days(1).num_seconds();
53        if duration_secs >= 2 * day {
54            return format!("{} days", duration_secs / day);
55        }
56        if duration_secs >= day {
57            return "1 day".into();
58        }
59
60        // Hour scale -> "n hours ago"
61        let hour = Duration::hours(1).num_seconds();
62        if duration_secs >= 2 * hour {
63            return format!("{} hours", duration_secs / hour);
64        }
65        if duration_secs >= hour {
66            return "1 hour".into();
67        }
68
69        // Minute scale -> "n minutes ago"
70        let minute = Duration::minutes(1).num_seconds();
71        if duration_secs >= 2 * minute {
72            return format!("{} minutes", duration_secs / minute);
73        }
74        if duration_secs >= minute {
75            return "1 minute".into();
76        }
77
78        format!("{duration_secs} seconds")
79    }
80}
81
82/// Which subset of backups to print.
83#[derive(Debug, Clone, ValueEnum)]
84pub enum BackupStatus {
85    /// Retainable backups only.
86    Retainable,
87
88    /// Purgeable backups only.
89    Purgeable,
90}
91
92impl fmt::Display for BackupStatus {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        match self {
95            BackupStatus::Retainable => write!(f, "{:12}", "retainable"),
96            BackupStatus::Purgeable => write!(f, "{:12}", "purgeable"),
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use chrono::NaiveDate;
105
106    /// Helper to create a backup with a given datetime for testing.
107    fn backup_at(year: i32, month: u32, day: u32, hour: u32, min: u32, sec: u32) -> Backup {
108        Backup {
109            filepath: PathBuf::from("/tmp/backup.tar.zst"),
110            creation_date: NaiveDate::from_ymd_opt(year, month, day)
111                .unwrap()
112                .and_hms_opt(hour, min, sec)
113                .unwrap(),
114        }
115    }
116
117    fn datetime(year: i32, month: u32, day: u32, hour: u32, min: u32, sec: u32) -> NaiveDateTime {
118        NaiveDate::from_ymd_opt(year, month, day)
119            .unwrap()
120            .and_hms_opt(hour, min, sec)
121            .unwrap()
122    }
123
124    mod age_formatting {
125        use super::*;
126
127        #[test]
128        fn fresh_backup_shows_seconds() {
129            let backup = backup_at(2024, 6, 15, 10, 30, 0);
130            let now = datetime(2024, 6, 15, 10, 30, 45);
131
132            assert_eq!(backup.age(now), "45 seconds");
133        }
134
135        #[test]
136        fn zero_seconds_is_still_seconds() {
137            let backup = backup_at(2024, 6, 15, 10, 30, 0);
138            let now = datetime(2024, 6, 15, 10, 30, 0);
139
140            assert_eq!(backup.age(now), "0 seconds");
141        }
142
143        #[test]
144        fn exactly_one_minute() {
145            let backup = backup_at(2024, 6, 15, 10, 30, 0);
146            let now = datetime(2024, 6, 15, 10, 31, 0);
147
148            assert_eq!(backup.age(now), "1 minute");
149        }
150
151        #[test]
152        fn just_under_two_minutes_is_still_one_minute() {
153            let backup = backup_at(2024, 6, 15, 10, 30, 0);
154            let now = datetime(2024, 6, 15, 10, 31, 59);
155
156            assert_eq!(backup.age(now), "1 minute");
157        }
158
159        #[test]
160        fn two_minutes_uses_plural() {
161            let backup = backup_at(2024, 6, 15, 10, 30, 0);
162            let now = datetime(2024, 6, 15, 10, 32, 0);
163
164            assert_eq!(backup.age(now), "2 minutes");
165        }
166
167        #[test]
168        fn fifty_nine_minutes_before_hour_threshold() {
169            let backup = backup_at(2024, 6, 15, 10, 0, 0);
170            let now = datetime(2024, 6, 15, 10, 59, 59);
171
172            assert_eq!(backup.age(now), "59 minutes");
173        }
174
175        #[test]
176        fn exactly_one_hour() {
177            let backup = backup_at(2024, 6, 15, 10, 0, 0);
178            let now = datetime(2024, 6, 15, 11, 0, 0);
179
180            assert_eq!(backup.age(now), "1 hour");
181        }
182
183        #[test]
184        fn just_under_two_hours_is_still_one_hour() {
185            let backup = backup_at(2024, 6, 15, 10, 0, 0);
186            let now = datetime(2024, 6, 15, 11, 59, 59);
187
188            assert_eq!(backup.age(now), "1 hour");
189        }
190
191        #[test]
192        fn two_hours_uses_plural() {
193            let backup = backup_at(2024, 6, 15, 10, 0, 0);
194            let now = datetime(2024, 6, 15, 12, 0, 0);
195
196            assert_eq!(backup.age(now), "2 hours");
197        }
198
199        #[test]
200        fn twenty_three_hours_before_day_threshold() {
201            let backup = backup_at(2024, 6, 15, 0, 0, 0);
202            let now = datetime(2024, 6, 15, 23, 59, 59);
203
204            assert_eq!(backup.age(now), "23 hours");
205        }
206
207        #[test]
208        fn exactly_one_day() {
209            let backup = backup_at(2024, 6, 15, 10, 0, 0);
210            let now = datetime(2024, 6, 16, 10, 0, 0);
211
212            assert_eq!(backup.age(now), "1 day");
213        }
214
215        #[test]
216        fn six_days_before_week_threshold() {
217            let backup = backup_at(2024, 6, 15, 10, 0, 0);
218            let now = datetime(2024, 6, 21, 9, 59, 59);
219
220            assert_eq!(backup.age(now), "5 days");
221        }
222
223        #[test]
224        fn exactly_one_week() {
225            let backup = backup_at(2024, 6, 15, 10, 0, 0);
226            let now = datetime(2024, 6, 22, 10, 0, 0);
227
228            assert_eq!(backup.age(now), "1 week");
229        }
230
231        #[test]
232        fn two_weeks() {
233            let backup = backup_at(2024, 6, 1, 10, 0, 0);
234            let now = datetime(2024, 6, 15, 10, 0, 0);
235
236            assert_eq!(backup.age(now), "2 weeks");
237        }
238
239        #[test]
240        fn three_weeks_exactly() {
241            let backup = backup_at(2024, 6, 1, 10, 0, 0);
242            let now = datetime(2024, 6, 22, 10, 0, 0); // exactly 21 days = 3 weeks
243
244            assert_eq!(backup.age(now), "3 weeks");
245        }
246
247        #[test]
248        fn just_under_four_weeks_still_shows_weeks() {
249            let backup = backup_at(2024, 6, 1, 10, 0, 0);
250            let now = datetime(2024, 6, 29, 9, 59, 59); // just under 28 days
251
252            assert_eq!(backup.age(now), "3 weeks");
253        }
254
255        #[test]
256        fn exactly_one_month_four_weeks() {
257            let backup = backup_at(2024, 6, 1, 10, 0, 0);
258            let now = datetime(2024, 6, 29, 10, 0, 0);
259
260            // 4 weeks = 28 days, which is the "month" threshold in this implementation
261            assert_eq!(backup.age(now), "1 month");
262        }
263
264        #[test]
265        fn two_months() {
266            let backup = backup_at(2024, 1, 1, 10, 0, 0);
267            let now = datetime(2024, 3, 1, 10, 0, 0);
268
269            // ~60 days = 2 "months" (8 weeks)
270            assert_eq!(backup.age(now), "2 months");
271        }
272
273        #[test]
274        fn many_months_ago() {
275            let backup = backup_at(2024, 1, 1, 0, 0, 0);
276            let now = datetime(2024, 12, 1, 0, 0, 0);
277
278            // ~11 months = roughly 48 weeks
279            let age = backup.age(now);
280            assert!(age.ends_with("months"), "Expected months, got: {age}");
281        }
282    }
283
284    mod backup_status_display {
285        use super::*;
286
287        #[test]
288        fn retainable_is_padded_to_12_chars() {
289            let status = BackupStatus::Retainable;
290            assert_eq!(format!("{status}"), "retainable  ");
291        }
292
293        #[test]
294        fn purgeable_is_padded_to_12_chars() {
295            let status = BackupStatus::Purgeable;
296            assert_eq!(format!("{status}"), "purgeable   ");
297        }
298    }
299
300    mod backup_equality {
301        use super::*;
302
303        #[test]
304        fn same_path_and_date_are_equal() {
305            let a = backup_at(2024, 6, 15, 10, 30, 0);
306            let b = backup_at(2024, 6, 15, 10, 30, 0);
307
308            assert_eq!(a, b);
309        }
310
311        #[test]
312        fn different_dates_are_not_equal() {
313            let a = backup_at(2024, 6, 15, 10, 30, 0);
314            let b = backup_at(2024, 6, 15, 10, 30, 1);
315
316            assert_ne!(a, b);
317        }
318
319        #[test]
320        fn different_paths_are_not_equal() {
321            let a = Backup {
322                filepath: PathBuf::from("/tmp/a.tar.zst"),
323                creation_date: datetime(2024, 6, 15, 10, 30, 0),
324            };
325            let b = Backup {
326                filepath: PathBuf::from("/tmp/b.tar.zst"),
327                creation_date: datetime(2024, 6, 15, 10, 30, 0),
328            };
329
330            assert_ne!(a, b);
331        }
332    }
333}