1use crate::common::Retention;
33use chrono::{DateTime, Datelike, Utc};
34use std::collections::BTreeSet;
35
36pub trait SnapshotLike {
39 fn end_time(&self) -> DateTime<Utc>;
41 fn id(&self) -> &str;
43 fn pinned(&self) -> bool {
48 false
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Default)]
55pub struct KeptSet {
56 pub keep: Vec<String>,
58 pub delete: Vec<String>,
60}
61
62fn hour_key(t: DateTime<Utc>) -> (i32, u32, u32) {
66 (t.year(), t.ordinal(), t.hour())
67}
68fn day_key(t: DateTime<Utc>) -> (i32, u32) {
69 (t.year(), t.ordinal())
70}
71fn week_key(t: DateTime<Utc>) -> (i32, u32) {
72 let iso = t.iso_week();
73 (iso.year(), iso.week())
74}
75fn month_key(t: DateTime<Utc>) -> (i32, u32) {
76 (t.year(), t.month())
77}
78fn year_key(t: DateTime<Utc>) -> i32 {
79 t.year()
80}
81
82use chrono::Timelike;
83
84fn keep_per_period<K, F>(
87 sorted: &[usize],
88 times: &[DateTime<Utc>],
89 count: usize,
90 key: F,
91) -> Vec<usize>
92where
93 K: Ord,
94 F: Fn(DateTime<Utc>) -> K,
95{
96 let mut kept = Vec::new();
97 let mut seen: BTreeSet<K> = BTreeSet::new();
98 for &idx in sorted {
99 if kept.len() >= count {
100 break;
101 }
102 let k = key(times[idx]);
103 if seen.insert(k) {
104 kept.push(idx);
106 }
107 }
108 kept
109}
110
111pub fn select_kept<T: SnapshotLike>(backups: &[T], policy: &Retention) -> KeptSet {
143 if backups.is_empty() {
144 return KeptSet::default();
145 }
146
147 let times: Vec<DateTime<Utc>> = backups.iter().map(|b| b.end_time()).collect();
148
149 let mut order: Vec<usize> = (0..backups.len()).collect();
151 order.sort_by(|&a, &b| {
152 times[b]
153 .cmp(×[a])
154 .then_with(|| backups[a].id().cmp(backups[b].id()))
155 });
156
157 let mut keep_idx: BTreeSet<usize> = BTreeSet::new();
158
159 if let Some(n) = policy.keep_latest {
161 for &idx in order.iter().take(n as usize) {
162 keep_idx.insert(idx);
163 }
164 }
165 if let Some(n) = policy.keep_hourly {
166 keep_idx.extend(keep_per_period(&order, ×, n as usize, hour_key));
167 }
168 if let Some(n) = policy.keep_daily {
169 keep_idx.extend(keep_per_period(&order, ×, n as usize, day_key));
170 }
171 if let Some(n) = policy.keep_weekly {
172 keep_idx.extend(keep_per_period(&order, ×, n as usize, week_key));
173 }
174 if let Some(n) = policy.keep_monthly {
175 keep_idx.extend(keep_per_period(&order, ×, n as usize, month_key));
176 }
177 if let Some(n) = policy.keep_annual {
178 keep_idx.extend(keep_per_period(&order, ×, n as usize, year_key));
179 }
180
181 let mut keep = Vec::new();
182 let mut delete = Vec::new();
183 for &idx in &order {
184 if keep_idx.contains(&idx) || backups[idx].pinned() {
188 keep.push(backups[idx].id().to_string());
189 } else {
190 delete.push(backups[idx].id().to_string());
191 }
192 }
193 KeptSet { keep, delete }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use chrono::TimeZone;
200
201 struct Fake {
203 id: String,
204 end: DateTime<Utc>,
205 pinned: bool,
206 }
207 impl SnapshotLike for Fake {
208 fn end_time(&self) -> DateTime<Utc> {
209 self.end
210 }
211 fn id(&self) -> &str {
212 &self.id
213 }
214 fn pinned(&self) -> bool {
215 self.pinned
216 }
217 }
218
219 fn at(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime<Utc> {
220 Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).single().unwrap()
221 }
222 fn fake(id: &str, t: DateTime<Utc>) -> Fake {
223 Fake {
224 id: id.into(),
225 end: t,
226 pinned: false,
227 }
228 }
229 fn pinned(id: &str, t: DateTime<Utc>) -> Fake {
230 Fake {
231 id: id.into(),
232 end: t,
233 pinned: true,
234 }
235 }
236
237 fn policy(
238 latest: Option<u32>,
239 hourly: Option<u32>,
240 daily: Option<u32>,
241 weekly: Option<u32>,
242 monthly: Option<u32>,
243 annual: Option<u32>,
244 ) -> Retention {
245 Retention {
246 keep_latest: latest,
247 keep_hourly: hourly,
248 keep_daily: daily,
249 keep_weekly: weekly,
250 keep_monthly: monthly,
251 keep_annual: annual,
252 }
253 }
254
255 fn as_set(v: &[String]) -> BTreeSet<&str> {
256 v.iter().map(String::as_str).collect()
257 }
258
259 #[test]
260 fn empty_input_yields_empty_sets() {
261 let got = select_kept::<Fake>(&[], &policy(Some(5), None, None, None, None, None));
262 assert!(got.keep.is_empty());
263 assert!(got.delete.is_empty());
264 }
265
266 #[test]
267 fn empty_policy_keeps_nothing() {
268 let backups = vec![
270 fake("a", at(2026, 5, 24, 2, 0)),
271 fake("b", at(2026, 5, 23, 2, 0)),
272 ];
273 let got = select_kept(&backups, &Retention::default());
274 assert!(got.keep.is_empty(), "empty policy keeps nothing");
275 assert_eq!(as_set(&got.delete), ["a", "b"].into_iter().collect());
276 }
277
278 #[test]
279 fn keep_latest_keeps_n_newest() {
280 let backups = vec![
281 fake("d1", at(2026, 5, 24, 2, 0)),
282 fake("d2", at(2026, 5, 23, 2, 0)),
283 fake("d3", at(2026, 5, 22, 2, 0)),
284 fake("d4", at(2026, 5, 21, 2, 0)),
285 ];
286 let got = select_kept(&backups, &policy(Some(2), None, None, None, None, None));
287 assert_eq!(as_set(&got.keep), ["d1", "d2"].into_iter().collect());
288 assert_eq!(as_set(&got.delete), ["d3", "d4"].into_iter().collect());
289 }
290
291 #[test]
292 fn keep_daily_keeps_one_newest_per_day() {
293 let backups = vec![
295 fake("a", at(2026, 5, 24, 0, 5)),
296 fake("b", at(2026, 5, 24, 1, 30)),
297 fake("c", at(2026, 5, 24, 2, 0)), fake("d", at(2026, 5, 23, 2, 0)),
299 fake("e", at(2026, 5, 22, 2, 0)),
300 ];
301 let got = select_kept(&backups, &policy(None, None, Some(14), None, None, None));
302 assert_eq!(as_set(&got.keep), ["c", "d", "e"].into_iter().collect());
304 assert_eq!(as_set(&got.delete), ["a", "b"].into_iter().collect());
305 }
306
307 #[test]
308 fn keep_daily_count_caps_number_of_days() {
309 let backups = vec![
310 fake("d24", at(2026, 5, 24, 2, 0)),
311 fake("d23", at(2026, 5, 23, 2, 0)),
312 fake("d22", at(2026, 5, 22, 2, 0)),
313 fake("d21", at(2026, 5, 21, 2, 0)),
314 ];
315 let got = select_kept(&backups, &policy(None, None, Some(2), None, None, None));
316 assert_eq!(as_set(&got.keep), ["d24", "d23"].into_iter().collect());
318 assert_eq!(as_set(&got.delete), ["d22", "d21"].into_iter().collect());
319 }
320
321 #[test]
322 fn keep_latest_unions_with_keep_daily() {
323 let backups = vec![
326 fake("c", at(2026, 5, 24, 6, 0)),
327 fake("b", at(2026, 5, 24, 5, 0)),
328 fake("a", at(2026, 5, 23, 5, 0)),
329 ];
330 let got = select_kept(&backups, &policy(Some(2), None, Some(7), None, None, None));
331 assert_eq!(as_set(&got.keep), ["a", "b", "c"].into_iter().collect());
333 assert!(got.delete.is_empty());
334 }
335
336 #[test]
337 fn annual_snapshot_survives_flood_of_newer_dailies() {
338 let mut backups = vec![fake("y2024", at(2024, 12, 31, 23, 0))];
342 for d in 1..=10u32 {
343 backups.push(fake(&format!("y2026-{d:02}"), at(2026, 5, d, 2, 0)));
344 }
345 let got = select_kept(&backups, &policy(None, None, Some(3), None, None, Some(2)));
348 let keep = as_set(&got.keep);
349 assert!(
350 keep.contains("y2024"),
351 "annual snapshot must not be dropped by daily flood; kept={keep:?}"
352 );
353 assert!(keep.contains("y2026-10"));
355 assert!(keep.contains("y2026-09"));
356 assert!(keep.contains("y2026-08"));
357 assert!(got.delete.contains(&"y2026-01".to_string()));
359 }
360
361 #[test]
362 fn monthly_and_weekly_pick_newest_in_period() {
363 let backups = vec![
364 fake("may-late", at(2026, 5, 28, 2, 0)),
365 fake("may-early", at(2026, 5, 2, 2, 0)),
366 fake("apr", at(2026, 4, 15, 2, 0)),
367 fake("mar", at(2026, 3, 15, 2, 0)),
368 ];
369 let got = select_kept(&backups, &policy(None, None, None, None, Some(2), None));
370 assert_eq!(as_set(&got.keep), ["may-late", "apr"].into_iter().collect());
372 }
373
374 #[test]
375 fn pinned_snapshot_survives_a_prune_that_would_delete_it() {
376 let backups = vec![
380 fake("newest", at(2026, 5, 24, 2, 0)),
381 pinned("pinned-old", at(2026, 5, 20, 2, 0)),
382 fake("unpinned-old", at(2026, 5, 19, 2, 0)),
383 ];
384 let got = select_kept(&backups, &policy(Some(1), None, None, None, None, None));
385 let keep = as_set(&got.keep);
386 let del = as_set(&got.delete);
387 assert!(keep.contains("newest"), "keepLatest:1 keeps the newest");
388 assert!(
389 keep.contains("pinned-old"),
390 "a pinned snapshot must survive a prune that would otherwise delete it"
391 );
392 assert!(
393 del.contains("unpinned-old"),
394 "the unpinned older snapshot is pruned"
395 );
396 assert!(!del.contains("pinned-old"), "pinned is never in delete");
397 }
398
399 #[test]
400 fn every_backup_kept_by_any_bucket_survives() {
401 let backups = vec![
404 fake("now", at(2026, 5, 24, 12, 0)),
405 fake("earlier-today", at(2026, 5, 24, 1, 0)),
406 fake("yesterday", at(2026, 5, 23, 1, 0)),
407 fake("last-week", at(2026, 5, 16, 1, 0)),
408 ];
409 let got = select_kept(
410 &backups,
411 &policy(Some(1), None, Some(2), Some(2), None, None),
412 );
413 let keep = as_set(&got.keep);
414 let del = as_set(&got.delete);
415 for id in keep.iter() {
416 assert!(!del.contains(id), "id {id} in both keep and delete");
417 }
418 assert_eq!(keep.len() + del.len(), 4);
420 }
421
422 #[test]
423 fn e2e_gfs_history_partitions_exactly_as_the_retention_e2e_expects() {
424 let backups = vec![
431 fake("e2e-gfs-1", at(2025, 4, 10, 10, 0)),
432 fake("e2e-gfs-2", at(2026, 3, 2, 10, 0)),
433 fake("e2e-gfs-3", at(2026, 4, 6, 10, 0)),
434 fake("e2e-gfs-4", at(2026, 5, 25, 10, 0)),
435 fake("e2e-gfs-5", at(2026, 6, 1, 10, 0)),
436 fake("e2e-gfs-6", at(2026, 6, 8, 10, 0)),
437 fake("e2e-gfs-7", at(2026, 6, 8, 11, 0)),
438 ];
439 let policy: Retention = serde_json::from_value(serde_json::json!({
440 "keepLatest": 1, "keepDaily": 2, "keepWeekly": 2,
441 "keepMonthly": 2, "keepAnnual": 2
442 }))
443 .unwrap();
444 let got = select_kept(&backups, &policy);
445 assert_eq!(
446 as_set(&got.keep),
447 ["e2e-gfs-1", "e2e-gfs-4", "e2e-gfs-5", "e2e-gfs-7"]
448 .into_iter()
449 .collect(),
450 "keep: latest+daily+weekly (gfs-7/5), monthly #2 (gfs-4), annual #2 (gfs-1)"
451 );
452 assert_eq!(
453 as_set(&got.delete),
454 ["e2e-gfs-2", "e2e-gfs-3", "e2e-gfs-6"]
455 .into_iter()
456 .collect(),
457 "delete: months outside keepMonthly:2 and the same-day older duplicate"
458 );
459 }
460
461 #[test]
470 fn select_kept_is_stable_on_its_own_kept_set() {
471 let populations: Vec<Vec<Fake>> = vec![
472 vec![
475 fake("tie-a", at(2026, 5, 24, 2, 0)),
476 fake("tie-b", at(2026, 5, 24, 2, 0)),
477 fake("d23", at(2026, 5, 23, 2, 0)),
478 fake("d22-am", at(2026, 5, 22, 2, 0)),
479 fake("d22-pm", at(2026, 5, 22, 14, 0)),
480 fake("w-old", at(2026, 5, 1, 2, 0)),
481 pinned("pin-ancient", at(2020, 1, 1, 0, 0)),
482 ],
483 vec![fake("only", at(2026, 5, 24, 2, 0))],
485 ];
486 let policies = [
487 policy(Some(2), None, None, None, None, None),
488 policy(None, None, Some(2), None, None, None),
489 policy(Some(1), None, Some(2), Some(1), Some(1), Some(1)),
490 policy(None, None, None, None, None, None), ];
492 for snaps in &populations {
493 for pol in &policies {
494 let first = select_kept(snaps, pol);
495 let survivors: Vec<Fake> = snaps
496 .iter()
497 .filter(|s| first.keep.iter().any(|k| k == &s.id))
498 .map(|s| Fake {
499 id: s.id.clone(),
500 end: s.end,
501 pinned: s.pinned,
502 })
503 .collect();
504 let second = select_kept(&survivors, pol);
505 assert!(
506 second.delete.is_empty(),
507 "keep(S) must be a fixed point; policy {pol:?} re-deleted {:?}",
508 second.delete
509 );
510 assert_eq!(
511 as_set(&second.keep),
512 as_set(&first.keep),
513 "keep(keep(S)) == keep(S) for policy {pol:?}"
514 );
515 }
516 }
517 }
518}