Skip to main content

krabka_restore/bound/
compile.rs

1//! Turning the operator's bound flags into a compiled `Predicates` set, and
2//! the one flag combination that is rejected outright.
3//!
4//! `Predicates::from_args` lives here because compiling the flags is a
5//! separate concern from evaluating them: it groups `--to-offset` and
6//! `--exclude-offset` per partition, and it proves, from the flags alone,
7//! whether the exclude ranges swallow the whole keep window of a partition.
8//! `fully_covers` is the interval arithmetic behind that proof.
9
10use {
11    super::{ExcludedOffsets, Predicates},
12    crate::{
13        args::{PartitionRef, RestoreArgs},
14        error::RestoreError,
15    },
16    krabka_ids::Offset,
17    std::collections::HashMap,
18};
19
20impl Predicates {
21    /// Compile the bound flags into a predicate set.
22    ///
23    /// # Errors
24    ///
25    /// Returns [`RestoreError::InvalidArgument`] when a partition's
26    /// `--exclude-offset` ranges, merged, cover every offset from `0` through
27    /// that partition's `--to-offset` bound: nothing between `0` and the
28    /// bound can survive, so the bound keeps zero records for that partition.
29    /// That is the one flag combination this function can prove empties a
30    /// partition without reading the archive, because both windows are named
31    /// entirely by the flags. Two related cases are deliberately not
32    /// flagged here. A negative `--to-offset`, which would exclude a whole
33    /// partition outright, is already rejected by [`RestoreArgs`]'s own
34    /// parser and never reaches this function. A `--exclude-key` or
35    /// `--exclude-header` pattern that matches every possible byte string,
36    /// such as an empty pattern or `.*`, is not detected either: proving a
37    /// regex is universal is not a check this function attempts, a partial
38    /// heuristic would catch some universal patterns and not others, and
39    /// unlike an offset window, a key predicate never empties a partition on
40    /// its own anyway, because a keyless record still survives it.
41    pub fn from_args(args: &RestoreArgs) -> Result<Self, RestoreError> {
42        let mut to_offset = HashMap::with_capacity(args.to_offset.len());
43        for bound in &args.to_offset {
44            to_offset.insert(bound.partition.clone(), bound.last_offset);
45        }
46
47        let mut exclude_offset: HashMap<PartitionRef, Vec<ExcludedOffsets>> = HashMap::new();
48        for range in &args.exclude_offset {
49            exclude_offset
50                .entry(range.partition.clone())
51                .or_default()
52                .push(ExcludedOffsets {
53                    start: range.start,
54                    end_exclusive: range.end_exclusive,
55                });
56        }
57
58        for (partition, &last_offset) in &to_offset {
59            let fully_excluded = exclude_offset
60                .get(partition)
61                .is_some_and(|ranges| fully_covers(ranges, last_offset));
62            if fully_excluded {
63                return Err(RestoreError::InvalidArgument(format!(
64                    "--exclude-offset excludes every offset that --to-offset keeps in {partition}"
65                )));
66            }
67        }
68
69        Ok(Self {
70            to_offset,
71            to_timestamp: args.to_timestamp,
72            exclude_key: args.exclude_key.clone(),
73            exclude_header: args.exclude_header.clone(),
74            exclude_producer_id: args.exclude_producer_id.iter().copied().collect(),
75            exclude_offset,
76        })
77    }
78}
79
80/// Whether `ranges`, merged, cover every offset in `0..=last_offset`.
81///
82/// This is the arithmetic behind the "can never keep a record" check in
83/// [`Predicates::from_args`]: a partition's whole possible keep window, from
84/// offset zero through its `--to-offset` bound, counts as covered only when
85/// the exclude ranges leave no gap in it.
86fn fully_covers(ranges: &[ExcludedOffsets], last_offset: Offset) -> bool {
87    let mut sorted = ranges.to_vec();
88    sorted.sort_unstable_by_key(|range| range.start);
89
90    let mut covered_through = Offset::ZERO;
91    for ExcludedOffsets {
92        start,
93        end_exclusive,
94    } in sorted
95    {
96        if start > covered_through {
97            return false;
98        }
99        if end_exclusive > covered_through {
100            covered_through = end_exclusive;
101        }
102        if covered_through > last_offset {
103            return true;
104        }
105    }
106    covered_through > last_offset
107}
108
109#[cfg(test)]
110mod tests {
111    use {super::*, crate::bound::test_support::args_from, assert2::check};
112
113    #[test]
114    fn exclude_offset_fully_covering_the_to_offset_window_is_rejected() {
115        let result = Predicates::from_args(&args_from(&[
116            "--to-offset",
117            "orders:0=5",
118            "--exclude-offset",
119            "orders:0=0..6",
120        ]));
121
122        check!(matches!(result, Err(RestoreError::InvalidArgument(_))));
123    }
124
125    #[test]
126    fn exclude_offset_covering_the_window_through_several_ranges_is_rejected() {
127        let result = Predicates::from_args(&args_from(&[
128            "--to-offset",
129            "orders:0=5",
130            "--exclude-offset",
131            "orders:0=0..3",
132            "--exclude-offset",
133            "orders:0=3..6",
134        ]));
135
136        check!(matches!(result, Err(RestoreError::InvalidArgument(_))));
137    }
138
139    #[test]
140    fn exclude_offset_leaving_any_gap_in_the_window_is_accepted() {
141        let result = Predicates::from_args(&args_from(&[
142            "--to-offset",
143            "orders:0=5",
144            "--exclude-offset",
145            "orders:0=0..3",
146        ]));
147
148        check!(result.is_ok());
149    }
150
151    #[test]
152    fn exclude_offset_covering_an_unrelated_partition_does_not_reject() {
153        let result = Predicates::from_args(&args_from(&[
154            "--to-offset",
155            "orders:0=5",
156            "--exclude-offset",
157            "orders:1=0..100",
158        ]));
159
160        check!(result.is_ok());
161    }
162
163    #[test]
164    fn no_to_offset_bound_means_no_coverage_check_at_all() {
165        let result =
166            Predicates::from_args(&args_from(&["--exclude-offset", "orders:0=0..1000000"]));
167
168        check!(result.is_ok());
169    }
170}