1use crate::common::Retention;
33use chrono::{DateTime, Datelike, Utc};
34use std::collections::BTreeSet;
35
36pub trait BackupLike {
39 fn end_time(&self) -> DateTime<Utc>;
41 fn id(&self) -> &str;
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Default)]
48pub struct KeptSet {
49 pub keep: Vec<String>,
51 pub delete: Vec<String>,
53}
54
55fn hour_key(t: DateTime<Utc>) -> (i32, u32, u32) {
59 (t.year(), t.ordinal(), t.hour())
60}
61fn day_key(t: DateTime<Utc>) -> (i32, u32) {
62 (t.year(), t.ordinal())
63}
64fn week_key(t: DateTime<Utc>) -> (i32, u32) {
65 let iso = t.iso_week();
66 (iso.year(), iso.week())
67}
68fn month_key(t: DateTime<Utc>) -> (i32, u32) {
69 (t.year(), t.month())
70}
71fn year_key(t: DateTime<Utc>) -> i32 {
72 t.year()
73}
74
75use chrono::Timelike;
76
77fn keep_per_period<K, F>(
80 sorted: &[usize],
81 times: &[DateTime<Utc>],
82 count: usize,
83 key: F,
84) -> Vec<usize>
85where
86 K: Ord,
87 F: Fn(DateTime<Utc>) -> K,
88{
89 let mut kept = Vec::new();
90 let mut seen: BTreeSet<K> = BTreeSet::new();
91 for &idx in sorted {
92 if kept.len() >= count {
93 break;
94 }
95 let k = key(times[idx]);
96 if seen.insert(k) {
97 kept.push(idx);
99 }
100 }
101 kept
102}
103
104pub fn select_kept<T: BackupLike>(backups: &[T], policy: &Retention) -> KeptSet {
136 if backups.is_empty() {
137 return KeptSet::default();
138 }
139
140 let times: Vec<DateTime<Utc>> = backups.iter().map(|b| b.end_time()).collect();
141
142 let mut order: Vec<usize> = (0..backups.len()).collect();
144 order.sort_by(|&a, &b| {
145 times[b]
146 .cmp(×[a])
147 .then_with(|| backups[a].id().cmp(backups[b].id()))
148 });
149
150 let mut keep_idx: BTreeSet<usize> = BTreeSet::new();
151
152 if let Some(n) = policy.keep_latest {
154 for &idx in order.iter().take(n as usize) {
155 keep_idx.insert(idx);
156 }
157 }
158 if let Some(n) = policy.keep_hourly {
159 keep_idx.extend(keep_per_period(&order, ×, n as usize, hour_key));
160 }
161 if let Some(n) = policy.keep_daily {
162 keep_idx.extend(keep_per_period(&order, ×, n as usize, day_key));
163 }
164 if let Some(n) = policy.keep_weekly {
165 keep_idx.extend(keep_per_period(&order, ×, n as usize, week_key));
166 }
167 if let Some(n) = policy.keep_monthly {
168 keep_idx.extend(keep_per_period(&order, ×, n as usize, month_key));
169 }
170 if let Some(n) = policy.keep_annual {
171 keep_idx.extend(keep_per_period(&order, ×, n as usize, year_key));
172 }
173
174 let mut keep = Vec::new();
175 let mut delete = Vec::new();
176 for &idx in &order {
177 if keep_idx.contains(&idx) {
178 keep.push(backups[idx].id().to_string());
179 } else {
180 delete.push(backups[idx].id().to_string());
181 }
182 }
183 KeptSet { keep, delete }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use chrono::TimeZone;
190
191 struct Fake {
193 id: String,
194 end: DateTime<Utc>,
195 }
196 impl BackupLike for Fake {
197 fn end_time(&self) -> DateTime<Utc> {
198 self.end
199 }
200 fn id(&self) -> &str {
201 &self.id
202 }
203 }
204
205 fn at(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime<Utc> {
206 Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).single().unwrap()
207 }
208 fn fake(id: &str, t: DateTime<Utc>) -> Fake {
209 Fake {
210 id: id.into(),
211 end: t,
212 }
213 }
214
215 fn policy(
216 latest: Option<u32>,
217 hourly: Option<u32>,
218 daily: Option<u32>,
219 weekly: Option<u32>,
220 monthly: Option<u32>,
221 annual: Option<u32>,
222 ) -> Retention {
223 Retention {
224 keep_latest: latest,
225 keep_hourly: hourly,
226 keep_daily: daily,
227 keep_weekly: weekly,
228 keep_monthly: monthly,
229 keep_annual: annual,
230 }
231 }
232
233 fn as_set(v: &[String]) -> BTreeSet<&str> {
234 v.iter().map(String::as_str).collect()
235 }
236
237 #[test]
238 fn empty_input_yields_empty_sets() {
239 let got = select_kept::<Fake>(&[], &policy(Some(5), None, None, None, None, None));
240 assert!(got.keep.is_empty());
241 assert!(got.delete.is_empty());
242 }
243
244 #[test]
245 fn empty_policy_keeps_nothing() {
246 let backups = vec![
248 fake("a", at(2026, 5, 24, 2, 0)),
249 fake("b", at(2026, 5, 23, 2, 0)),
250 ];
251 let got = select_kept(&backups, &Retention::default());
252 assert!(got.keep.is_empty(), "empty policy keeps nothing");
253 assert_eq!(as_set(&got.delete), ["a", "b"].into_iter().collect());
254 }
255
256 #[test]
257 fn keep_latest_keeps_n_newest() {
258 let backups = vec![
259 fake("d1", at(2026, 5, 24, 2, 0)),
260 fake("d2", at(2026, 5, 23, 2, 0)),
261 fake("d3", at(2026, 5, 22, 2, 0)),
262 fake("d4", at(2026, 5, 21, 2, 0)),
263 ];
264 let got = select_kept(&backups, &policy(Some(2), None, None, None, None, None));
265 assert_eq!(as_set(&got.keep), ["d1", "d2"].into_iter().collect());
266 assert_eq!(as_set(&got.delete), ["d3", "d4"].into_iter().collect());
267 }
268
269 #[test]
270 fn keep_daily_keeps_one_newest_per_day() {
271 let backups = vec![
273 fake("a", at(2026, 5, 24, 0, 5)),
274 fake("b", at(2026, 5, 24, 1, 30)),
275 fake("c", at(2026, 5, 24, 2, 0)), fake("d", at(2026, 5, 23, 2, 0)),
277 fake("e", at(2026, 5, 22, 2, 0)),
278 ];
279 let got = select_kept(&backups, &policy(None, None, Some(14), None, None, None));
280 assert_eq!(as_set(&got.keep), ["c", "d", "e"].into_iter().collect());
282 assert_eq!(as_set(&got.delete), ["a", "b"].into_iter().collect());
283 }
284
285 #[test]
286 fn keep_daily_count_caps_number_of_days() {
287 let backups = vec![
288 fake("d24", at(2026, 5, 24, 2, 0)),
289 fake("d23", at(2026, 5, 23, 2, 0)),
290 fake("d22", at(2026, 5, 22, 2, 0)),
291 fake("d21", at(2026, 5, 21, 2, 0)),
292 ];
293 let got = select_kept(&backups, &policy(None, None, Some(2), None, None, None));
294 assert_eq!(as_set(&got.keep), ["d24", "d23"].into_iter().collect());
296 assert_eq!(as_set(&got.delete), ["d22", "d21"].into_iter().collect());
297 }
298
299 #[test]
300 fn keep_latest_unions_with_keep_daily() {
301 let backups = vec![
304 fake("c", at(2026, 5, 24, 6, 0)),
305 fake("b", at(2026, 5, 24, 5, 0)),
306 fake("a", at(2026, 5, 23, 5, 0)),
307 ];
308 let got = select_kept(&backups, &policy(Some(2), None, Some(7), None, None, None));
309 assert_eq!(as_set(&got.keep), ["a", "b", "c"].into_iter().collect());
311 assert!(got.delete.is_empty());
312 }
313
314 #[test]
315 fn annual_snapshot_survives_flood_of_newer_dailies() {
316 let mut backups = vec![fake("y2024", at(2024, 12, 31, 23, 0))];
320 for d in 1..=10u32 {
321 backups.push(fake(&format!("y2026-{d:02}"), at(2026, 5, d, 2, 0)));
322 }
323 let got = select_kept(&backups, &policy(None, None, Some(3), None, None, Some(2)));
326 let keep = as_set(&got.keep);
327 assert!(
328 keep.contains("y2024"),
329 "annual snapshot must not be dropped by daily flood; kept={keep:?}"
330 );
331 assert!(keep.contains("y2026-10"));
333 assert!(keep.contains("y2026-09"));
334 assert!(keep.contains("y2026-08"));
335 assert!(got.delete.contains(&"y2026-01".to_string()));
337 }
338
339 #[test]
340 fn monthly_and_weekly_pick_newest_in_period() {
341 let backups = vec![
342 fake("may-late", at(2026, 5, 28, 2, 0)),
343 fake("may-early", at(2026, 5, 2, 2, 0)),
344 fake("apr", at(2026, 4, 15, 2, 0)),
345 fake("mar", at(2026, 3, 15, 2, 0)),
346 ];
347 let got = select_kept(&backups, &policy(None, None, None, None, Some(2), None));
348 assert_eq!(as_set(&got.keep), ["may-late", "apr"].into_iter().collect());
350 }
351
352 #[test]
353 fn every_backup_kept_by_any_bucket_survives() {
354 let backups = vec![
357 fake("now", at(2026, 5, 24, 12, 0)),
358 fake("earlier-today", at(2026, 5, 24, 1, 0)),
359 fake("yesterday", at(2026, 5, 23, 1, 0)),
360 fake("last-week", at(2026, 5, 16, 1, 0)),
361 ];
362 let got = select_kept(
363 &backups,
364 &policy(Some(1), None, Some(2), Some(2), None, None),
365 );
366 let keep = as_set(&got.keep);
367 let del = as_set(&got.delete);
368 for id in keep.iter() {
369 assert!(!del.contains(id), "id {id} in both keep and delete");
370 }
371 assert_eq!(keep.len() + del.len(), 4);
373 }
374}