1use crate::engine::{Context, TemporalBound};
2use crate::parsing::ast::{DateTimeValue, FactValue, LemmaSpec};
3use crate::parsing::source::Source;
4use crate::Error;
5use std::collections::BTreeSet;
6use std::sync::Arc;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct TemporalSlice {
12 pub from: Option<DateTimeValue>,
14 pub to: Option<DateTimeValue>,
16}
17
18fn implicit_spec_refs(spec: &LemmaSpec) -> Vec<(String, Source)> {
20 spec.facts
21 .iter()
22 .filter_map(|fact| {
23 if let FactValue::SpecReference(spec_ref) = &fact.value {
24 if spec_ref.hash_pin.is_none() {
25 return Some((spec_ref.name.clone(), fact.source_location.clone()));
26 }
27 }
28 None
29 })
30 .collect()
31}
32
33fn implicit_spec_ref_names(spec: &LemmaSpec) -> Vec<String> {
35 implicit_spec_refs(spec)
36 .into_iter()
37 .map(|(n, _)| n)
38 .collect()
39}
40
41pub fn compute_temporal_slices(spec_arc: &Arc<LemmaSpec>, context: &Context) -> Vec<TemporalSlice> {
53 let (eff_from, eff_to) = context.effective_range(spec_arc);
54 let range_start = TemporalBound::from_start(eff_from.as_ref());
55 let range_end = TemporalBound::from_end(eff_to.as_ref());
56
57 let direct_implicit_names = implicit_spec_ref_names(spec_arc);
58 if direct_implicit_names.is_empty() {
59 return vec![TemporalSlice {
60 from: eff_from,
61 to: eff_to,
62 }];
63 }
64
65 let mut visited_names: BTreeSet<String> = BTreeSet::new();
68 let mut pending_names: Vec<String> = direct_implicit_names;
69 let mut all_boundaries: BTreeSet<DateTimeValue> = BTreeSet::new();
70
71 while let Some(dep_name) = pending_names.pop() {
72 if !visited_names.insert(dep_name.clone()) {
73 continue;
74 }
75
76 let dep_versions: Vec<Arc<LemmaSpec>> =
77 context.iter().filter(|d| d.name == dep_name).collect();
78 assert!(
79 !dep_versions.is_empty(),
80 "BUG: compute_temporal_slices found implicit dep '{}' with no versions in context — \
81 validate_temporal_coverage should have rejected this",
82 dep_name
83 );
84
85 let boundaries = context.version_boundaries(&dep_name);
86 for boundary in boundaries {
87 let bound = TemporalBound::At(boundary.clone());
88 if bound > range_start && bound < range_end {
89 all_boundaries.insert(boundary);
90 }
91 }
92 for dep_spec in &dep_versions {
93 for transitive_name in implicit_spec_ref_names(dep_spec) {
94 if !visited_names.contains(&transitive_name) {
95 pending_names.push(transitive_name);
96 }
97 }
98 }
99 }
100
101 if all_boundaries.is_empty() {
102 return vec![TemporalSlice {
103 from: eff_from,
104 to: eff_to,
105 }];
106 }
107
108 let mut slices = Vec::new();
110 let mut cursor = eff_from.clone();
111
112 for boundary in &all_boundaries {
113 slices.push(TemporalSlice {
114 from: cursor,
115 to: Some(boundary.clone()),
116 });
117 cursor = Some(boundary.clone());
118 }
119
120 slices.push(TemporalSlice {
121 from: cursor,
122 to: eff_to,
123 });
124
125 slices
126}
127
128pub fn validate_temporal_coverage(context: &Context) -> Vec<Error> {
139 let mut errors = Vec::new();
140
141 for spec_arc in context.iter() {
142 let (eff_from, eff_to) = context.effective_range(&spec_arc);
143 let dep_refs = implicit_spec_refs(&spec_arc);
144
145 for (dep_name, ref_source) in &dep_refs {
146 let gaps = context.dep_coverage_gaps(dep_name, eff_from.as_ref(), eff_to.as_ref());
147
148 for (gap_start, gap_end) in &gaps {
149 let (message, suggestion) =
150 format_coverage_gap(&spec_arc.name, dep_name, gap_start, gap_end, &eff_from);
151 errors.push(Error::validation(
152 message,
153 Some(ref_source.clone()),
154 Some(suggestion),
155 ));
156 }
157 }
158 }
159
160 errors
161}
162
163fn format_coverage_gap(
164 spec_name: &str,
165 dep_name: &str,
166 gap_start: &Option<DateTimeValue>,
167 gap_end: &Option<DateTimeValue>,
168 spec_from: &Option<DateTimeValue>,
169) -> (String, String) {
170 let message = match (gap_start, gap_end) {
171 (None, Some(end)) => format!(
172 "'{}' depends on '{}', but no version of '{}' is active before {}",
173 spec_name, dep_name, dep_name, end
174 ),
175 (Some(start), None) => format!(
176 "'{}' depends on '{}', but no version of '{}' is active after {}",
177 spec_name, dep_name, dep_name, start
178 ),
179 (Some(start), Some(end)) => format!(
180 "'{}' depends on '{}', but no version of '{}' is active between {} and {}",
181 spec_name, dep_name, dep_name, start, end
182 ),
183 (None, None) => format!(
184 "'{}' depends on '{}', but no version of '{}' exists",
185 spec_name, dep_name, dep_name
186 ),
187 };
188
189 let suggestion = if gap_start.is_none() && gap_end.is_none() && dep_name.starts_with('@') {
190 format!(
191 "Run `lemma get` or `lemma get {}` to fetch this dependency.",
192 dep_name
193 )
194 } else if gap_start.is_none() && spec_from.is_none() {
195 format!(
196 "Add an effective_from date to '{}' so it starts when '{}' is available, \
197 or add an earlier version of '{}'.",
198 spec_name, dep_name, dep_name
199 )
200 } else if gap_end.is_none() {
201 format!(
202 "Add a newer version of '{}' that covers the remaining range.",
203 dep_name
204 )
205 } else {
206 format!(
207 "Add a version of '{}' that covers the gap, \
208 or adjust the effective_from date on '{}'.",
209 dep_name, spec_name
210 )
211 };
212
213 (message, suggestion)
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::parsing::ast::{FactValue, LemmaFact, LemmaSpec, Reference, SpecRef};
220 use crate::parsing::source::Source;
221 use crate::Span;
222
223 fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
224 DateTimeValue {
225 year,
226 month,
227 day,
228 hour: 0,
229 minute: 0,
230 second: 0,
231 microsecond: 0,
232 timezone: None,
233 }
234 }
235
236 fn dummy_source() -> Source {
237 Source {
238 attribute: "test".to_string(),
239 span: Span {
240 start: 0,
241 end: 0,
242 line: 0,
243 col: 0,
244 },
245 source_text: "".into(),
246 }
247 }
248
249 fn make_spec(name: &str) -> LemmaSpec {
250 LemmaSpec::new(name.to_string())
251 }
252
253 fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
254 let mut spec = make_spec(name);
255 spec.effective_from = effective_from;
256 spec
257 }
258
259 fn add_spec_ref_fact(spec: &mut LemmaSpec, fact_name: &str, dep_name: &str) {
260 spec.facts.push(LemmaFact {
261 reference: Reference::local(fact_name.to_string()),
262 value: FactValue::SpecReference(SpecRef {
263 name: dep_name.to_string(),
264 is_registry: false,
265 hash_pin: None,
266 effective: None,
267 }),
268 source_location: dummy_source(),
269 });
270 }
271
272 #[test]
273 fn no_deps_produces_single_slice() {
274 let mut ctx = Context::new();
275 let spec = Arc::new(make_spec_with_range("a", Some(date(2025, 1, 1))));
276 ctx.insert_spec(Arc::clone(&spec)).unwrap();
277
278 let slices = compute_temporal_slices(&spec, &ctx);
279 assert_eq!(slices.len(), 1);
280 assert_eq!(slices[0].from, Some(date(2025, 1, 1)));
281 assert_eq!(slices[0].to, None);
282 }
283
284 #[test]
285 fn single_dep_no_boundary_in_range() {
286 let mut ctx = Context::new();
287 let mut main_spec = make_spec_with_range("main", Some(date(2025, 1, 1)));
288 add_spec_ref_fact(&mut main_spec, "dep", "config");
289 let main_arc = Arc::new(main_spec);
290 ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
291
292 let config = Arc::new(make_spec("config"));
293 ctx.insert_spec(config).unwrap();
294
295 let slices = compute_temporal_slices(&main_arc, &ctx);
296 assert_eq!(slices.len(), 1);
297 }
298
299 #[test]
300 fn single_dep_one_boundary_produces_two_slices() {
301 let mut ctx = Context::new();
302
303 let config_v1 = Arc::new(make_spec("config"));
304 ctx.insert_spec(config_v1).unwrap();
305 let config_v2 = Arc::new(make_spec_with_range("config", Some(date(2025, 2, 1))));
306 ctx.insert_spec(config_v2).unwrap();
307
308 let mut main_spec = make_spec_with_range("main", Some(date(2025, 1, 1)));
310 add_spec_ref_fact(&mut main_spec, "cfg", "config");
311 let main_arc = Arc::new(main_spec);
312 ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
313
314 let slices = compute_temporal_slices(&main_arc, &ctx);
315 assert_eq!(slices.len(), 2);
316 assert_eq!(slices[0].from, Some(date(2025, 1, 1)));
317 assert_eq!(slices[0].to, Some(date(2025, 2, 1)));
318 assert_eq!(slices[1].from, Some(date(2025, 2, 1)));
319 assert_eq!(slices[1].to, None);
320 }
321
322 #[test]
323 fn boundary_outside_range_ignored() {
324 let mut ctx = Context::new();
325
326 let config_v1 = Arc::new(make_spec("config"));
327 ctx.insert_spec(config_v1).unwrap();
328 let config_v2 = Arc::new(make_spec_with_range("config", Some(date(2025, 6, 1))));
329 ctx.insert_spec(config_v2).unwrap();
330
331 let main_v1 = make_spec_with_range("main", Some(date(2025, 1, 1)));
333 let main_v2 = make_spec_with_range("main", Some(date(2025, 3, 1)));
334 let mut main_v1 = main_v1;
335 add_spec_ref_fact(&mut main_v1, "cfg", "config");
336 let main_arc = Arc::new(main_v1);
337 ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
338 ctx.insert_spec(Arc::new(main_v2)).unwrap();
339
340 let slices = compute_temporal_slices(&main_arc, &ctx);
341 assert_eq!(slices.len(), 1);
342 }
343
344 #[test]
345 fn transitive_dep_boundary_included() {
346 let mut ctx = Context::new();
347
348 let mut config = make_spec("config");
349 add_spec_ref_fact(&mut config, "rates_ref", "rates");
350 ctx.insert_spec(Arc::new(config)).unwrap();
351
352 let rates_v1 = Arc::new(make_spec("rates"));
353 ctx.insert_spec(rates_v1).unwrap();
354 let rates_v2 = Arc::new(make_spec_with_range("rates", Some(date(2025, 2, 1))));
355 ctx.insert_spec(rates_v2).unwrap();
356
357 let mut main_spec = make_spec_with_range("main", Some(date(2025, 1, 1)));
359 add_spec_ref_fact(&mut main_spec, "cfg", "config");
360 let main_arc = Arc::new(main_spec);
361 ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
362
363 let slices = compute_temporal_slices(&main_arc, &ctx);
364 assert_eq!(slices.len(), 2);
365 assert_eq!(slices[0].to, Some(date(2025, 2, 1)));
366 assert_eq!(slices[1].from, Some(date(2025, 2, 1)));
367 }
368
369 #[test]
370 fn unbounded_spec_with_versioned_dep() {
371 let mut ctx = Context::new();
372
373 let dep_v1 = Arc::new(make_spec("dep"));
374 ctx.insert_spec(dep_v1).unwrap();
375 let dep_v2 = Arc::new(make_spec_with_range("dep", Some(date(2025, 6, 1))));
376 ctx.insert_spec(dep_v2).unwrap();
377
378 let mut main_spec = make_spec("main");
379 add_spec_ref_fact(&mut main_spec, "d", "dep");
380 let main_arc = Arc::new(main_spec);
381 ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
382
383 let slices = compute_temporal_slices(&main_arc, &ctx);
384 assert_eq!(slices.len(), 2);
385 assert_eq!(slices[0].from, None);
386 assert_eq!(slices[0].to, Some(date(2025, 6, 1)));
387 assert_eq!(slices[1].from, Some(date(2025, 6, 1)));
388 assert_eq!(slices[1].to, None);
389 }
390
391 #[test]
392 fn pinned_ref_does_not_create_boundary() {
393 let mut ctx = Context::new();
394
395 let dep_v1 = Arc::new(make_spec("dep"));
396 ctx.insert_spec(dep_v1).unwrap();
397 let dep_v2 = Arc::new(make_spec_with_range("dep", Some(date(2025, 6, 1))));
398 ctx.insert_spec(dep_v2).unwrap();
399
400 let mut main_spec = make_spec("main");
401 main_spec.facts.push(LemmaFact {
402 reference: Reference::local("d".to_string()),
403 value: FactValue::SpecReference(SpecRef {
404 name: "dep".to_string(),
405 is_registry: false,
406 hash_pin: Some("abcd1234".to_string()),
407 effective: None,
408 }),
409 source_location: dummy_source(),
410 });
411 let main_arc = Arc::new(main_spec);
412 ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
413
414 let slices = compute_temporal_slices(&main_arc, &ctx);
415 assert_eq!(slices.len(), 1);
416 }
417}