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