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