Skip to main content

simulator_api/
slot_range.rs

1//! An inclusive slot range.
2//!
3//! Slot bounds travel in pairs through most of the pipeline. As two adjacent
4//! `u64` parameters nothing stops a caller transposing them, and the ordering
5//! invariant gets re-checked ad hoc (or not at all) at each layer. This carries
6//! both together and checks once, at construction.
7
8use std::fmt;
9
10use serde::{Deserialize, Serialize};
11
12/// An inclusive `[start, end]` slot range. Construct via [`SlotRange::new`],
13/// which rejects an inverted range, or [`SlotRange::new_unchecked`] where the
14/// ordering is already guaranteed by the caller.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
16pub struct SlotRange {
17    start: u64,
18    end: u64,
19}
20
21/// Why a pair of bounds isn't a valid range.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
23#[error("invalid range: start_slot {start} > end_slot {end}")]
24pub struct InvertedSlotRange {
25    pub start: u64,
26    pub end: u64,
27}
28
29impl SlotRange {
30    /// An inclusive range, rejecting `start > end`.
31    pub fn new(start: u64, end: u64) -> Result<Self, InvertedSlotRange> {
32        if start > end {
33            return Err(InvertedSlotRange { start, end });
34        }
35        Ok(Self { start, end })
36    }
37
38    /// A range whose ordering the caller already guarantees — e.g. one derived
39    /// from a `start` plus a non-zero count. Prefer [`SlotRange::new`].
40    pub const fn new_unchecked(start: u64, end: u64) -> Self {
41        Self { start, end }
42    }
43
44    /// The range covering `count` slots from `start`. `count` of 0 yields a
45    /// single-slot range, matching how the callers' slot counts behave.
46    pub const fn from_count(start: u64, count: u64) -> Self {
47        Self {
48            start,
49            end: start.saturating_add(count.saturating_sub(1)),
50        }
51    }
52
53    pub const fn start(&self) -> u64 {
54        self.start
55    }
56
57    pub const fn end(&self) -> u64 {
58        self.end
59    }
60
61    /// Number of slots covered, inclusive of both bounds.
62    pub const fn len(&self) -> u64 {
63        self.end - self.start + 1
64    }
65
66    /// Never true — an inclusive range always covers at least one slot. Present
67    /// so `len()` doesn't trip `clippy::len_without_is_empty`.
68    pub const fn is_empty(&self) -> bool {
69        false
70    }
71
72    pub const fn contains(&self, slot: u64) -> bool {
73        slot >= self.start && slot <= self.end
74    }
75
76    pub const fn overlaps(&self, other: SlotRange) -> bool {
77        self.start <= other.end && other.start <= self.end
78    }
79}
80
81impl fmt::Display for SlotRange {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        write!(f, "{}..={}", self.start, self.end)
84    }
85}
86
87impl From<SlotRange> for std::ops::RangeInclusive<u64> {
88    fn from(range: SlotRange) -> Self {
89        range.start..=range.end
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use rstest::rstest;
96
97    use super::*;
98
99    #[test]
100    fn an_inverted_range_is_rejected() {
101        assert_eq!(
102            SlotRange::new(10, 9),
103            Err(InvertedSlotRange { start: 10, end: 9 })
104        );
105        assert!(SlotRange::new(10, 10).is_ok());
106    }
107
108    #[test]
109    fn length_is_inclusive_of_both_bounds() {
110        assert_eq!(SlotRange::new(10, 10).expect("range").len(), 1);
111        assert_eq!(SlotRange::new(10, 19).expect("range").len(), 10);
112    }
113
114    #[test]
115    fn from_count_matches_the_start_plus_count_convention() {
116        assert_eq!(
117            SlotRange::from_count(100, 1),
118            SlotRange::new_unchecked(100, 100)
119        );
120        assert_eq!(
121            SlotRange::from_count(100, 10),
122            SlotRange::new_unchecked(100, 109)
123        );
124        // A zero count still covers the anchor slot rather than inverting.
125        assert_eq!(
126            SlotRange::from_count(100, 0),
127            SlotRange::new_unchecked(100, 100)
128        );
129    }
130
131    #[test]
132    fn contains_is_inclusive() {
133        let range = SlotRange::new(10, 12).expect("range");
134        assert!(range.contains(10) && range.contains(12));
135        assert!(!range.contains(9) && !range.contains(13));
136    }
137
138    #[rstest]
139    #[case::shares_the_lower_bound(5, 10, true)]
140    #[case::shares_the_upper_bound(12, 20, true)]
141    #[case::lies_inside(11, 11, true)]
142    #[case::encloses(0, 20, true)]
143    #[case::ends_just_below(5, 9, false)]
144    #[case::starts_just_above(13, 20, false)]
145    fn overlaps_is_inclusive(#[case] start: u64, #[case] end: u64, #[case] expected: bool) {
146        let range = SlotRange::new_unchecked(10, 12);
147        let other = SlotRange::new_unchecked(start, end);
148        assert_eq!(range.overlaps(other), expected);
149        assert_eq!(other.overlaps(range), expected);
150    }
151}