Skip to main content

lemma/planning/
spec_set.rs

1//! Source-level grouping: specs sharing a name, keyed by effective_from.
2
3use crate::parsing::ast::{DateTimeValue, EffectiveDate, LemmaRepository, LemmaSpec};
4use std::collections::BTreeMap;
5use std::sync::Arc;
6
7/// All spec versions sharing a (repository, name) identity, keyed by effective_from.
8///
9/// The owning [`LemmaRepository`] is held by `Arc` so the set carries repository identity as
10/// a real memory reference instead of relying on string parsing.
11#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
12pub struct LemmaSpecSet {
13    pub repository: Arc<LemmaRepository>,
14    pub name: String,
15    specs: BTreeMap<EffectiveDate, LemmaSpec>,
16}
17
18impl LemmaSpecSet {
19    #[must_use]
20    pub fn new(repository: Arc<LemmaRepository>, name: String) -> Self {
21        Self {
22            repository,
23            name,
24            specs: BTreeMap::new(),
25        }
26    }
27
28    #[must_use]
29    pub fn is_empty(&self) -> bool {
30        self.specs.is_empty()
31    }
32
33    /// Exact identity by `effective_from` key.
34    #[must_use]
35    pub fn get_exact(&self, effective_from: Option<&DateTimeValue>) -> Option<&LemmaSpec> {
36        let key = EffectiveDate::from_option(effective_from.cloned());
37        self.specs.get(&key)
38    }
39
40    /// Insert a spec. Returns `false` if the same `effective_from` already exists.
41    pub fn insert(&mut self, spec: LemmaSpec) -> bool {
42        assert_eq!(
43            spec.name, self.name,
44            "BUG: spec name mismatch in LemmaSpecSet::insert"
45        );
46        let key = spec.effective_from.clone();
47        if self.specs.contains_key(&key) {
48            return false;
49        }
50        self.specs.insert(key, spec);
51        true
52    }
53
54    /// Remove by `effective_from` key. Returns whether a row was removed.
55    pub fn remove(&mut self, effective_from: Option<&DateTimeValue>) -> bool {
56        let key = EffectiveDate::from_option(effective_from.cloned());
57        self.specs.remove(&key).is_some()
58    }
59
60    pub fn iter_specs(&self) -> impl Iterator<Item = &LemmaSpec> + '_ {
61        self.specs.values()
62    }
63
64    /// Every spec paired with its half-open `[effective_from, effective_to)` range.
65    ///
66    /// - `effective_from = None` on the first row means no earlier version exists.
67    /// - `effective_to = None` on the last row means no successor (this is the
68    ///   latest loaded version; its validity is unbounded forward).
69    /// - Otherwise `effective_to` equals the next row's `effective_from`
70    ///   (exclusive end of this row's validity).
71    ///
72    /// Iteration order matches [`Self::iter_specs`] (ascending by `effective_from`).
73    pub fn iter_with_ranges(
74        &self,
75    ) -> impl Iterator<Item = (&LemmaSpec, Option<DateTimeValue>, Option<DateTimeValue>)> + '_ {
76        self.iter_specs().map(move |spec| {
77            let (effective_from, effective_to) = self.effective_range(spec);
78            (spec, effective_from, effective_to)
79        })
80    }
81
82    /// Spec active at `effective`. Each spec covers `[effective_from, next.effective_from)`.
83    /// The last spec covers `[effective_from, +∞)`.
84    #[must_use]
85    pub fn spec_at(&self, effective: &EffectiveDate) -> Option<&LemmaSpec> {
86        self.specs
87            .range(..=effective.clone())
88            .next_back()
89            .map(|(_, spec)| spec)
90    }
91
92    /// Returns the effective range `[from, to)` for a spec in this set.
93    ///
94    /// - `from`: `spec.effective_from()` (None = -∞)
95    /// - `to`: next temporal version's `effective_from`, or None (+∞) if no successor.
96    pub fn effective_range(
97        &self,
98        spec: &LemmaSpec,
99    ) -> (Option<DateTimeValue>, Option<DateTimeValue>) {
100        let from = spec.effective_from().cloned();
101        let key = spec.effective_from.clone();
102        let exact = self.specs.get_key_value(&key).unwrap_or_else(|| {
103            unreachable!(
104                "BUG: effective_range called with spec '{}' not in spec set",
105                spec.name
106            )
107        });
108        let to = self
109            .specs
110            .range((
111                std::ops::Bound::Excluded(exact.0),
112                std::ops::Bound::Unbounded,
113            ))
114            .next()
115            .and_then(|(_, next)| next.effective_from().cloned());
116        (from, to)
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::parsing::ast::LemmaSpec;
124
125    fn main_repository() -> Arc<LemmaRepository> {
126        Arc::new(LemmaRepository::new(None))
127    }
128
129    use crate::literals::DateGranularity;
130
131    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
132        DateTimeValue {
133            year,
134            month,
135            day,
136            hour: 0,
137            minute: 0,
138            second: 0,
139            microsecond: 0,
140            timezone: None,
141            granularity: DateGranularity::Full,
142        }
143    }
144
145    fn make_spec(name: &str) -> LemmaSpec {
146        LemmaSpec::new(name.to_string())
147    }
148
149    fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
150        let mut spec = LemmaSpec::new(name.to_string());
151        spec.effective_from = EffectiveDate::from_option(effective_from);
152        spec
153    }
154
155    /// A `LemmaSpecSet` carries `(repository, name)` identity; inserting a spec
156    /// whose name differs corrupts that identity silently (`spec_at`/`get_exact`
157    /// would return a spec that does not belong to this set). This is an
158    /// invariant violation and must crash, not be accepted.
159    #[test]
160    #[should_panic(expected = "BUG")]
161    fn insert_panics_on_spec_name_mismatch() {
162        let mut ss = LemmaSpecSet::new(main_repository(), "a".to_string());
163        ss.insert(make_spec("b"));
164    }
165
166    #[test]
167    fn effective_range_unbounded_single_spec() {
168        let mut ss = LemmaSpecSet::new(main_repository(), "a".to_string());
169        let spec = make_spec("a");
170        assert!(ss.insert(spec));
171        let stored = ss.get_exact(None).expect("inserted");
172
173        let (from, to) = ss.effective_range(stored);
174        assert_eq!(from, None);
175        assert_eq!(to, None);
176    }
177
178    #[test]
179    fn effective_range_soft_end_from_next_spec() {
180        let mut ss = LemmaSpecSet::new(main_repository(), "a".to_string());
181        assert!(ss.insert(make_spec_with_range("a", Some(date(2025, 1, 1)))));
182        assert!(ss.insert(make_spec_with_range("a", Some(date(2025, 6, 1)))));
183
184        let v1 = ss.get_exact(Some(&date(2025, 1, 1))).expect("v1");
185        let (from, to) = ss.effective_range(v1);
186        assert_eq!(from, Some(date(2025, 1, 1)));
187        assert_eq!(to, Some(date(2025, 6, 1)));
188
189        let v2 = ss.get_exact(Some(&date(2025, 6, 1))).expect("v2");
190        let (from, to) = ss.effective_range(v2);
191        assert_eq!(from, Some(date(2025, 6, 1)));
192        assert_eq!(to, None);
193    }
194
195    /// `iter_with_ranges` yields each spec paired with its half-open
196    /// `[effective_from, effective_to)` range. Earlier rows end where the
197    /// next row begins; the latest row's `effective_to` is `None`.
198    #[test]
199    fn iter_with_ranges_yields_specs_paired_with_half_open_range() {
200        let mut ss = LemmaSpecSet::new(main_repository(), "a".to_string());
201        assert!(ss.insert(make_spec_with_range("a", Some(date(2025, 1, 1)))));
202        assert!(ss.insert(make_spec_with_range("a", Some(date(2025, 6, 1)))));
203
204        let entries: Vec<_> = ss.iter_with_ranges().collect();
205        assert_eq!(entries.len(), 2);
206
207        let (spec_0, from_0, to_0) = &entries[0];
208        assert_eq!(spec_0.effective_from(), Some(&date(2025, 1, 1)));
209        assert_eq!(from_0, &Some(date(2025, 1, 1)));
210        assert_eq!(
211            to_0,
212            &Some(date(2025, 6, 1)),
213            "earlier row ends at the next row's effective_from"
214        );
215
216        let (spec_1, from_1, to_1) = &entries[1];
217        assert_eq!(spec_1.effective_from(), Some(&date(2025, 6, 1)));
218        assert_eq!(from_1, &Some(date(2025, 6, 1)));
219        assert_eq!(
220            to_1, &None,
221            "latest row has no successor; effective_to is None"
222        );
223    }
224
225    #[test]
226    fn effective_range_unbounded_start_with_successor() {
227        let mut ss = LemmaSpecSet::new(main_repository(), "a".to_string());
228        assert!(ss.insert(make_spec("a")));
229        assert!(ss.insert(make_spec_with_range("a", Some(date(2025, 3, 1)))));
230
231        let v1 = ss.get_exact(None).expect("v1");
232        let (from, to) = ss.effective_range(v1);
233        assert_eq!(from, None);
234        assert_eq!(to, Some(date(2025, 3, 1)));
235    }
236}