Skip to main content

kopiur_kopia/
selection.rs

1//! Pure snapshot-selection helpers shared by the controller and the mover.
2//!
3//! Restore resolution picks a snapshot from a `kopia snapshot list` by an
4//! optional point-in-time cutoff (`asOf`) and a zero-based `offset` (0 = newest).
5//! These two operations are pure functions over an already-fetched, newest-first
6//! list, so they live here (next to [`crate::SnapshotListEntry`]) rather than in
7//! either binary — the mover resolves object-store restores in-Job and the
8//! controller's tests exercise the same logic. The RFC3339 parse of the `asOf`
9//! string is the caller's concern (the admission webhook validates it; callers
10//! re-parse defensively) so these stay infallible.
11
12use chrono::{DateTime, Utc};
13
14use crate::SnapshotListEntry;
15
16/// Keep only snapshots taken at or before `cutoff` (point-in-time selection),
17/// preserving order so it composes with [`pick_offset`]: filter first, then
18/// offset ("the previous one as of last Tuesday"). `None` keeps the full list.
19pub fn filter_as_of(
20    mut snapshots: Vec<SnapshotListEntry>,
21    cutoff: Option<DateTime<Utc>>,
22) -> Vec<SnapshotListEntry> {
23    if let Some(cutoff) = cutoff {
24        snapshots.retain(|e| e.end_time <= cutoff);
25    }
26    snapshots
27}
28
29/// Pick the snapshot at `offset` (0 = newest) from a newest-first list. A
30/// negative offset clamps to the newest rather than panicking; an out-of-range
31/// offset returns `None` (the caller applies `onMissingSnapshot`).
32pub fn pick_offset(snapshots: Vec<SnapshotListEntry>, offset: i64) -> Option<SnapshotListEntry> {
33    let idx = offset.max(0) as usize;
34    snapshots.into_iter().nth(idx)
35}
36
37#[cfg(test)]
38mod tests {
39    use super::*;
40
41    fn list_entry(id: &str, end_time: &str) -> SnapshotListEntry {
42        serde_json::from_value(serde_json::json!({
43            "id": id,
44            "source": { "host": "h", "userName": "u", "path": "/data" },
45            "startTime": end_time,
46            "endTime": end_time,
47        }))
48        .expect("valid SnapshotListEntry")
49    }
50
51    /// Three snapshots, newest-first (the order the list is sorted into).
52    fn three_snapshots() -> Vec<SnapshotListEntry> {
53        vec![
54            list_entry("k3", "2026-06-03T00:00:00Z"),
55            list_entry("k2", "2026-06-02T00:00:00Z"),
56            list_entry("k1", "2026-06-01T00:00:00Z"),
57        ]
58    }
59
60    fn cutoff(s: &str) -> DateTime<Utc> {
61        DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
62    }
63
64    #[test]
65    fn filter_as_of_keeps_snapshots_at_or_before_the_instant() {
66        // A cutoff between k2 and k3 drops k3 (newer than the instant); k2/k1 remain.
67        let kept = filter_as_of(three_snapshots(), Some(cutoff("2026-06-02T12:00:00Z")));
68        assert_eq!(
69            kept.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(),
70            ["k2", "k1"]
71        );
72        // Exactly AT a snapshot's endTime keeps it ("at or before").
73        let kept = filter_as_of(three_snapshots(), Some(cutoff("2026-06-02T00:00:00Z")));
74        assert_eq!(kept.first().map(|e| e.id.as_str()), Some("k2"));
75        // Before everything → empty (caller applies onMissingSnapshot).
76        let kept = filter_as_of(three_snapshots(), Some(cutoff("2026-05-01T00:00:00Z")));
77        assert!(kept.is_empty());
78        // No cutoff → untouched.
79        let kept = filter_as_of(three_snapshots(), None);
80        assert_eq!(kept.len(), 3);
81    }
82
83    #[test]
84    fn as_of_composes_with_offset() {
85        // "the previous one as of just after k2": asOf drops k3, offset 1 then
86        // steps past k2 to k1.
87        let kept = filter_as_of(three_snapshots(), Some(cutoff("2026-06-02T12:00:00Z")));
88        assert_eq!(pick_offset(kept, 1).map(|e| e.id), Some("k1".to_string()));
89    }
90
91    #[test]
92    fn pick_offset_zero_is_newest_and_out_of_range_is_none() {
93        assert_eq!(
94            pick_offset(three_snapshots(), 0).map(|e| e.id),
95            Some("k3".to_string())
96        );
97        assert_eq!(
98            pick_offset(three_snapshots(), 2).map(|e| e.id),
99            Some("k1".to_string())
100        );
101        assert_eq!(pick_offset(three_snapshots(), 3).map(|e| e.id), None);
102        // A negative offset clamps to newest rather than panicking.
103        assert_eq!(
104            pick_offset(three_snapshots(), -1).map(|e| e.id),
105            Some("k3".to_string())
106        );
107    }
108}