Skip to main content

fast_pull/base/
invert.rs

1//! Iterator and helper for computing the *gaps* (not-yet-downloaded ranges)
2//! from a set of [`ProgressEntry`](crate::ProgressEntry)s.
3
4use crate::ProgressEntry;
5
6/// Iterator that yields the *gaps* (non-downloaded ranges) from a list of [`ProgressEntry`]s.
7///
8/// Entries shorter than `window` are merged into adjacent gaps to reduce fragmentation.
9///
10/// # Preconditions
11///
12/// The input entries must be **sorted by `start` and non-overlapping**, as produced by
13/// [`Merge::merge_progress`](crate::Merge::merge_progress). Unsorted or overlapping input
14/// makes the iterator emit nonsensical reversed gaps; a `debug_assert` catches it in
15/// debug builds. Reversed entries (`start > end`) are tolerated and ignored.
16#[derive(Debug)]
17pub struct InvertIter<I: Iterator<Item = ProgressEntry>> {
18    /// Iterator over the already-downloaded (sorted) ranges.
19    iter: I,
20    /// End offset of the last range consumed from `iter`.
21    prev_end: u64,
22    /// Total size of the source.
23    total_size: u64,
24    /// Merge entries shorter than this into the surrounding gap.
25    window: u64,
26}
27
28impl<I> Iterator for InvertIter<I>
29where
30    I: Iterator<Item = ProgressEntry>,
31{
32    type Item = ProgressEntry;
33    fn next(&mut self) -> Option<Self::Item> {
34        let mut gap_start = self.prev_end;
35        let mut last_end = gap_start;
36        for range in self.iter.by_ref() {
37            // Only the ordering precondition is checked here. A reversed range
38            // (`start > end`) is a tolerated no-op -- the `saturating_sub` below
39            // gives it length 0 so it is absorbed into the surrounding gap -- and
40            // must therefore not trip this assertion.
41            debug_assert!(
42                range.start >= last_end,
43                "InvertIter requires sorted, non-overlapping ranges, but got {range:?} \
44                 after a range ending at {last_end}; merge the input first"
45            );
46            last_end = range.end;
47            if range.start == gap_start {
48                gap_start = range.end;
49                continue;
50            }
51            let len = range.end.saturating_sub(range.start);
52            if len >= self.window {
53                self.prev_end = range.end;
54                return Some(gap_start..range.start);
55            }
56        }
57        if gap_start < self.total_size {
58            self.prev_end = self.total_size;
59            Some(gap_start..self.total_size)
60        } else {
61            None
62        }
63    }
64}
65
66/// `window`: when a [`ProgressEntry`] length is less than `window`, it is merged into the gap to reduce progress fragmentation.
67pub const fn invert<I>(progress: I, total_size: u64, window: u64) -> InvertIter<I>
68where
69    I: Iterator<Item = ProgressEntry>,
70{
71    InvertIter {
72        iter: progress,
73        prev_end: 0,
74        total_size,
75        window,
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    #![allow(clippy::single_range_in_vec_init)]
82    use super::*;
83
84    fn invert_vec(progress: &[ProgressEntry], total_size: u64, window: u64) -> Vec<ProgressEntry> {
85        invert(progress.iter().cloned(), total_size, window).collect()
86    }
87
88    #[test]
89    fn test_windowed_invert() {
90        assert_eq!(invert_vec(&[10..20], 30, 1), [0..10, 20..30]);
91        assert_eq!(invert_vec(&[10..12], 30, 5), [0..30]);
92        assert_eq!(invert_vec(&[10..20, 25..27], 30, 5), [0..10, 20..30]);
93        assert_eq!(invert_vec(&[10..14, 25..27, 30..32], 50, 5), [0..50]);
94        assert_eq!(invert_vec(&[10..14, 25..49], 50, 5), [0..25, 49..50]);
95        assert_eq!(invert_vec(&[2..4, 6..8, 10..12], 15, 5), [0..15]);
96        assert_eq!(invert_vec(&[0..2, 10..20], 30, 5), [2..10, 20..30]);
97    }
98
99    #[test]
100    fn test_invert_empty_progress() {
101        // Nothing downloaded of a 50-byte file -> one gap spanning everything.
102        assert_eq!(invert_vec(&[], 50, 1), [0..50]);
103    }
104
105    #[test]
106    fn test_invert_zero_total_size() {
107        // total_size 0 -> no gaps, even if progress is present.
108        assert_eq!(invert_vec(&[0..5], 0, 1), []);
109    }
110
111    #[test]
112    fn test_invert_full_cover_no_gaps() {
113        assert_eq!(invert_vec(&[0..30], 30, 1), []);
114    }
115
116    #[test]
117    fn test_invert_window_zero_keeps_small_entries() {
118        #![allow(clippy::single_range_in_vec_init)]
119        // window=0 means every entry (even tiny) is kept, so small entries are
120        // not merged into the surrounding gap.
121        assert_eq!(invert_vec(&[10..12], 30, 0), [0..10, 12..30]);
122    }
123
124    #[test]
125    fn test_invert_trailing_gap_only() {
126        assert_eq!(invert_vec(&[0..20], 30, 1), [20..30]);
127    }
128
129    #[test]
130    fn test_invert_leading_gap_only() {
131        assert_eq!(invert_vec(&[10..30], 30, 1), [0..10]);
132    }
133
134    #[test]
135    fn test_invert_contiguous_then_gap() {
136        #![allow(clippy::single_range_in_vec_init)]
137        assert_eq!(invert_vec(&[0..10, 10..20], 30, 1), [20..30]);
138    }
139
140    #[test]
141    fn test_invert_reversed_range_is_safe_and_ignored() {
142        // A reversed entry (start > end) must not underflow the length
143        // computation. It gets length 0 and is absorbed into the surrounding
144        // gap, so it is harmlessly ignored instead of corrupting the output.
145        let reversed = core::ops::Range { start: 30, end: 5 };
146        assert_eq!(invert_vec(&[reversed], 50, 1), [0..50]);
147    }
148
149    #[test]
150    fn invert_window_boundary_len_equals_window_is_emitted() {
151        // The window test is `len >= window`, so a length exactly equal to the
152        // window keeps the entry and the surrounding gaps stay separate.
153        assert_eq!(invert_vec(&[0..5, 10..15], 20, 5), [5..10, 15..20]);
154    }
155
156    #[test]
157    fn invert_window_boundary_len_below_window_is_absorbed() {
158        // One byte below the window is merged into the surrounding gap.
159        assert_eq!(invert_vec(&[0..5, 10..14], 20, 5), [5..20]);
160    }
161
162    #[test]
163    fn invert_window_does_not_merge_small_gaps_between_large_ranges() {
164        // `window` only suppresses short *downloaded* entries; it never swallows
165        // a gap. Two large entries separated by a 2-byte gap still yield that
166        // gap, because those 2 bytes genuinely are missing.
167        assert_eq!(invert_vec(&[0..10, 12..22], 22, 5), [10..12]);
168    }
169
170    #[test]
171    fn invert_trailing_gap_emitted_regardless_of_window() {
172        // A trailing gap is real missing data, so it is emitted even when it is
173        // far shorter than the window.
174        assert_eq!(invert_vec(&[0..28], 30, 5), [28..30]);
175        assert_eq!(invert_vec(&[0..28], 30, 100), [28..30]);
176    }
177
178    #[test]
179    fn invert_multiple_gaps_across_next_calls() {
180        // Each `next()` yields at most one gap, so `prev_end` must carry over
181        // correctly between calls.
182        let mut it = invert([0..5, 10..20, 25..30].iter().cloned(), 30, 1);
183        assert_eq!(it.next(), Some(5..10));
184        assert_eq!(it.next(), Some(20..25));
185        assert_eq!(it.next(), None);
186    }
187
188    #[test]
189    fn invert_small_entry_contiguous_to_prior_is_not_a_gap() {
190        // A short entry touching the previous one counts as downloaded rather
191        // than being turned into a gap.
192        assert_eq!(invert_vec(&[0..10, 10..12], 30, 5), [12..30]);
193    }
194
195    #[test]
196    fn invert_reversed_entry_in_middle_is_ignored() {
197        // Reversed entries are tolerated anywhere in the stream, not just first.
198        let reversed = core::ops::Range { start: 30, end: 5 };
199        assert_eq!(invert_vec(&[0..10, reversed], 50, 1), [10..50]);
200    }
201
202    #[test]
203    #[cfg_attr(debug_assertions, should_panic)]
204    #[allow(clippy::should_panic_without_expect)]
205    fn invert_overlapping_input_fails_fast() {
206        // Overlapping input violates the precondition and would otherwise emit a
207        // reversed gap silently; the debug assertion turns it into a panic.
208        let _ = invert_vec(&[0..10, 5..15], 20, 1);
209    }
210
211    #[test]
212    #[cfg_attr(debug_assertions, should_panic)]
213    #[allow(clippy::should_panic_without_expect)]
214    fn invert_unsorted_input_fails_fast() {
215        // Same for unsorted input.
216        let _ = invert_vec(&[10..20, 0..5], 20, 1);
217    }
218}