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 spec_name: "test".to_string(),
246 source_text: "".into(),
247 }
248 }
249
250 fn make_spec(name: &str) -> LemmaSpec {
251 LemmaSpec::new(name.to_string())
252 }
253
254 fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
255 let mut spec = make_spec(name);
256 spec.effective_from = effective_from;
257 spec
258 }
259
260 fn add_spec_ref_fact(spec: &mut LemmaSpec, fact_name: &str, dep_name: &str) {
261 spec.facts.push(LemmaFact {
262 reference: Reference::local(fact_name.to_string()),
263 value: FactValue::SpecReference(SpecRef {
264 name: dep_name.to_string(),
265 is_registry: false,
266 hash_pin: None,
267 effective: None,
268 }),
269 source_location: dummy_source(),
270 });
271 }
272
273 #[test]
274 fn no_deps_produces_single_slice() {
275 let mut ctx = Context::new();
276 let spec = Arc::new(make_spec_with_range("a", Some(date(2025, 1, 1))));
277 ctx.insert_spec(Arc::clone(&spec)).unwrap();
278
279 let slices = compute_temporal_slices(&spec, &ctx);
280 assert_eq!(slices.len(), 1);
281 assert_eq!(slices[0].from, Some(date(2025, 1, 1)));
282 assert_eq!(slices[0].to, None);
283 }
284
285 #[test]
286 fn single_dep_no_boundary_in_range() {
287 let mut ctx = Context::new();
288 let mut main_spec = make_spec_with_range("main", Some(date(2025, 1, 1)));
289 add_spec_ref_fact(&mut main_spec, "dep", "config");
290 let main_arc = Arc::new(main_spec);
291 ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
292
293 let config = Arc::new(make_spec("config"));
294 ctx.insert_spec(config).unwrap();
295
296 let slices = compute_temporal_slices(&main_arc, &ctx);
297 assert_eq!(slices.len(), 1);
298 }
299
300 #[test]
301 fn single_dep_one_boundary_produces_two_slices() {
302 let mut ctx = Context::new();
303
304 let config_v1 = Arc::new(make_spec("config"));
305 ctx.insert_spec(config_v1).unwrap();
306 let config_v2 = Arc::new(make_spec_with_range("config", Some(date(2025, 2, 1))));
307 ctx.insert_spec(config_v2).unwrap();
308
309 let mut main_spec = make_spec_with_range("main", Some(date(2025, 1, 1)));
311 add_spec_ref_fact(&mut main_spec, "cfg", "config");
312 let main_arc = Arc::new(main_spec);
313 ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
314
315 let slices = compute_temporal_slices(&main_arc, &ctx);
316 assert_eq!(slices.len(), 2);
317 assert_eq!(slices[0].from, Some(date(2025, 1, 1)));
318 assert_eq!(slices[0].to, Some(date(2025, 2, 1)));
319 assert_eq!(slices[1].from, Some(date(2025, 2, 1)));
320 assert_eq!(slices[1].to, None);
321 }
322
323 #[test]
324 fn boundary_outside_range_ignored() {
325 let mut ctx = Context::new();
326
327 let config_v1 = Arc::new(make_spec("config"));
328 ctx.insert_spec(config_v1).unwrap();
329 let config_v2 = Arc::new(make_spec_with_range("config", Some(date(2025, 6, 1))));
330 ctx.insert_spec(config_v2).unwrap();
331
332 let main_v1 = make_spec_with_range("main", Some(date(2025, 1, 1)));
334 let main_v2 = make_spec_with_range("main", Some(date(2025, 3, 1)));
335 let mut main_v1 = main_v1;
336 add_spec_ref_fact(&mut main_v1, "cfg", "config");
337 let main_arc = Arc::new(main_v1);
338 ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
339 ctx.insert_spec(Arc::new(main_v2)).unwrap();
340
341 let slices = compute_temporal_slices(&main_arc, &ctx);
342 assert_eq!(slices.len(), 1);
343 }
344
345 #[test]
346 fn transitive_dep_boundary_included() {
347 let mut ctx = Context::new();
348
349 let mut config = make_spec("config");
350 add_spec_ref_fact(&mut config, "rates_ref", "rates");
351 ctx.insert_spec(Arc::new(config)).unwrap();
352
353 let rates_v1 = Arc::new(make_spec("rates"));
354 ctx.insert_spec(rates_v1).unwrap();
355 let rates_v2 = Arc::new(make_spec_with_range("rates", Some(date(2025, 2, 1))));
356 ctx.insert_spec(rates_v2).unwrap();
357
358 let mut main_spec = make_spec_with_range("main", Some(date(2025, 1, 1)));
360 add_spec_ref_fact(&mut main_spec, "cfg", "config");
361 let main_arc = Arc::new(main_spec);
362 ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
363
364 let slices = compute_temporal_slices(&main_arc, &ctx);
365 assert_eq!(slices.len(), 2);
366 assert_eq!(slices[0].to, Some(date(2025, 2, 1)));
367 assert_eq!(slices[1].from, Some(date(2025, 2, 1)));
368 }
369
370 #[test]
371 fn unbounded_spec_with_versioned_dep() {
372 let mut ctx = Context::new();
373
374 let dep_v1 = Arc::new(make_spec("dep"));
375 ctx.insert_spec(dep_v1).unwrap();
376 let dep_v2 = Arc::new(make_spec_with_range("dep", Some(date(2025, 6, 1))));
377 ctx.insert_spec(dep_v2).unwrap();
378
379 let mut main_spec = make_spec("main");
380 add_spec_ref_fact(&mut main_spec, "d", "dep");
381 let main_arc = Arc::new(main_spec);
382 ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
383
384 let slices = compute_temporal_slices(&main_arc, &ctx);
385 assert_eq!(slices.len(), 2);
386 assert_eq!(slices[0].from, None);
387 assert_eq!(slices[0].to, Some(date(2025, 6, 1)));
388 assert_eq!(slices[1].from, Some(date(2025, 6, 1)));
389 assert_eq!(slices[1].to, None);
390 }
391
392 #[test]
393 fn pinned_ref_does_not_create_boundary() {
394 let mut ctx = Context::new();
395
396 let dep_v1 = Arc::new(make_spec("dep"));
397 ctx.insert_spec(dep_v1).unwrap();
398 let dep_v2 = Arc::new(make_spec_with_range("dep", Some(date(2025, 6, 1))));
399 ctx.insert_spec(dep_v2).unwrap();
400
401 let mut main_spec = make_spec("main");
402 main_spec.facts.push(LemmaFact {
403 reference: Reference::local("d".to_string()),
404 value: FactValue::SpecReference(SpecRef {
405 name: "dep".to_string(),
406 is_registry: false,
407 hash_pin: Some("abcd1234".to_string()),
408 effective: None,
409 }),
410 source_location: dummy_source(),
411 });
412 let main_arc = Arc::new(main_spec);
413 ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
414
415 let slices = compute_temporal_slices(&main_arc, &ctx);
416 assert_eq!(slices.len(), 1);
417 }
418}