1pub mod data_input;
13pub mod discovery;
14pub mod execution_plan;
15pub mod graph;
16pub mod normalize;
17pub mod semantics;
18pub mod spec_set;
19#[cfg(test)]
20mod transitive_normalization;
21use crate::engine::Context;
22use crate::parsing::ast::{DateTimeValue, LemmaRepository, LemmaSpec};
23use crate::Error;
24pub use data_input::DataValueInput;
25pub use execution_plan::ExecutionPlanSet;
26pub use execution_plan::{DataOverlay, ExecutionPlan, SpecSchema};
27use indexmap::IndexMap;
28pub use spec_set::LemmaSpecSet;
29use std::sync::Arc;
30
31#[derive(Debug, Clone)]
37pub struct SpecPlanningResult {
38 pub spec: std::sync::Arc<crate::parsing::ast::LemmaSpec>,
39 pub plans: Vec<ExecutionPlan>,
40 pub errors: Vec<Error>,
41}
42
43impl SpecPlanningResult {
44 fn clear_plans_on_error(&mut self) {
45 if !self.errors.is_empty() {
46 self.plans.clear();
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
53pub struct SpecSetPlanningResult {
54 pub repository: Arc<LemmaRepository>,
56 pub name: String,
58 pub lemma_spec_set: LemmaSpecSet,
59 pub slice_results: Vec<SpecPlanningResult>,
60}
61
62impl SpecSetPlanningResult {
63 pub fn errors(&self) -> impl Iterator<Item = &Error> {
64 self.slice_results.iter().flat_map(|s| s.errors.iter())
65 }
66
67 pub fn execution_plan_set(&self) -> ExecutionPlanSet {
68 ExecutionPlanSet {
69 spec_name: self.name.clone(),
70 plans: self
71 .slice_results
72 .iter()
73 .flat_map(|s| s.plans.clone())
74 .collect(),
75 }
76 }
77
78 pub fn schema_over(
86 &self,
87 from: &Option<DateTimeValue>,
88 to: &Option<DateTimeValue>,
89 ) -> Option<SpecSchema> {
90 let schemas: Vec<SpecSchema> = self
91 .slice_results
92 .iter()
93 .filter(|sr| {
94 let (slice_from, slice_to) = self.lemma_spec_set.effective_range(&sr.spec);
95 ranges_overlap(from, to, &slice_from, &slice_to)
96 })
97 .filter_map(|sr| {
98 sr.plans
99 .first()
100 .map(|p| p.interface_schema(&DataOverlay::default()))
101 })
102 .collect();
103
104 let first = schemas.first()?;
105
106 let mut data_types: std::collections::HashMap<
107 &str,
108 &crate::planning::semantics::LemmaType,
109 > = std::collections::HashMap::new();
110 let mut rule_types: std::collections::HashMap<
111 &str,
112 &crate::planning::semantics::LemmaType,
113 > = std::collections::HashMap::new();
114 for schema in &schemas {
115 for (name, entry) in &schema.data {
116 match data_types.get(name.as_str()) {
117 Some(existing) if **existing != entry.lemma_type => return None,
118 _ => {
119 data_types.insert(name.as_str(), &entry.lemma_type);
120 }
121 }
122 }
123 for (name, lemma_type) in &schema.rules {
124 match rule_types.get(name.as_str()) {
125 Some(existing) if *existing != lemma_type => return None,
126 _ => {
127 rule_types.insert(name.as_str(), lemma_type);
128 }
129 }
130 }
131 }
132
133 Some(first.clone())
134 }
135}
136
137pub(crate) fn ranges_overlap(
140 a_from: &Option<DateTimeValue>,
141 a_to: &Option<DateTimeValue>,
142 b_from: &Option<DateTimeValue>,
143 b_to: &Option<DateTimeValue>,
144) -> bool {
145 let a_before_b_end = match (a_from, b_to) {
146 (_, None) => true,
147 (None, Some(_)) => true,
148 (Some(a), Some(b)) => a < b,
149 };
150 let b_before_a_end = match (b_from, a_to) {
151 (_, None) => true,
152 (None, Some(_)) => true,
153 (Some(b), Some(a)) => b < a,
154 };
155 a_before_b_end && b_before_a_end
156}
157
158#[derive(Debug, Clone)]
159pub struct PlanningResult {
160 pub results: Vec<SpecSetPlanningResult>,
161}
162
163pub fn plan(context: &Context, limits: &crate::limits::ResourceLimits) -> PlanningResult {
168 let mut results: IndexMap<Arc<LemmaRepository>, IndexMap<String, SpecSetPlanningResult>> =
169 IndexMap::new();
170
171 for (repository, inner) in context.repositories().iter() {
172 for (_name, lemma_spec_set) in inner.iter() {
173 for spec in lemma_spec_set.iter_specs() {
174 plan_spec(
175 context,
176 repository,
177 lemma_spec_set,
178 &spec,
179 limits,
180 &mut results,
181 );
182 }
183 }
184 }
185
186 for (consumer_repository, spec_name, err) in
187 discovery::validate_dependency_interfaces(context, &results)
188 {
189 let set_result = results
190 .get_mut(&consumer_repository)
191 .and_then(|by_name| by_name.get_mut(&spec_name))
192 .expect("BUG: validate_dependency_interfaces returned error for absent spec set");
193 let first_spec = set_result
194 .slice_results
195 .first_mut()
196 .expect("planning result must contain at least one spec");
197 first_spec.errors.push(err);
198 for slice in &mut set_result.slice_results {
199 slice.clear_plans_on_error();
200 }
201 }
202
203 for by_name in results.values_mut() {
204 for set_result in by_name.values_mut() {
205 for spec_result in &mut set_result.slice_results {
206 dedup_errors(&mut spec_result.errors);
207 }
208 }
209 }
210
211 PlanningResult {
212 results: results
213 .into_values()
214 .flat_map(|by_name| by_name.into_values())
215 .collect(),
216 }
217}
218
219fn plan_spec(
220 context: &Context,
221 repository: &Arc<LemmaRepository>,
222 lemma_spec_set: &LemmaSpecSet,
223 spec: &Arc<LemmaSpec>,
224 limits: &crate::limits::ResourceLimits,
225 results: &mut IndexMap<Arc<LemmaRepository>, IndexMap<String, SpecSetPlanningResult>>,
226) {
227 let spec_name = &spec.name;
228
229 let mut spec_result = SpecPlanningResult {
230 spec: Arc::clone(spec),
231 plans: Vec::new(),
232 errors: Vec::new(),
233 };
234
235 for effective in lemma_spec_set.effective_dates(spec, context) {
236 let dag = match discovery::build_dag_for_spec(context, spec, &effective, limits) {
237 Ok(dag) => dag,
238 Err(discovery::DagError::Cycle(errors)) => {
239 spec_result.errors.extend(errors);
240 continue;
241 }
242 Err(discovery::DagError::Other(errors)) => {
243 spec_result.errors.extend(errors);
244 continue;
245 }
246 };
247
248 match graph::Graph::build(context, repository, spec, &dag, &effective, limits) {
249 Ok((graph, mut slice_types)) => {
250 match execution_plan::build_execution_plan(
251 &graph,
252 &mut slice_types,
253 &effective,
254 limits,
255 ) {
256 Ok(execution_plan) => {
257 let mut plan_errors =
258 execution_plan::validate_unit_index_references(&execution_plan)
259 .err()
260 .into_iter()
261 .collect::<Vec<_>>();
262 plan_errors.extend(execution_plan::validate_literal_data_against_types(
263 &execution_plan,
264 ));
265 if plan_errors.is_empty() {
266 spec_result.plans.push(execution_plan);
267 } else {
268 spec_result.errors.extend(plan_errors);
269 }
270 }
271 Err(plan_errors) => {
272 spec_result.errors.extend(plan_errors);
273 }
274 }
275 }
276 Err(build_errors) => {
277 spec_result.errors.extend(build_errors);
278 }
279 }
280 }
281
282 if !spec_result.plans.is_empty() || !spec_result.errors.is_empty() {
283 let entry = results
284 .entry(Arc::clone(repository))
285 .or_default()
286 .entry(spec_name.clone())
287 .or_insert_with(|| SpecSetPlanningResult {
288 repository: Arc::clone(repository),
289 name: spec_name.clone(),
290 lemma_spec_set: lemma_spec_set.clone(),
291 slice_results: Vec::new(),
292 });
293 entry.slice_results.push(spec_result);
294 }
295}
296
297fn dedup_errors(errors: &mut Vec<Error>) {
301 let mut seen = std::collections::HashSet::new();
302 errors.retain(|error| {
303 let key = (
304 error.kind(),
305 error.message().to_string(),
306 error.location().cloned(),
307 );
308 seen.insert(key)
309 });
310}
311
312#[cfg(test)]
317mod internal_tests {
318 use super::plan;
319 use crate::engine::Context;
320 use crate::limits::ResourceLimits;
321 use crate::literals::DateGranularity;
322 use crate::parsing::ast::{
323 DataValue, LemmaData, LemmaRepository, LemmaSpec, ParentType, Reference, Span,
324 };
325 use crate::parsing::source::Source;
326 use crate::planning::execution_plan::ExecutionPlan;
327 use crate::planning::semantics::{DataPath, PathSegment, TypeDefiningSpec, TypeExtends};
328 use crate::{parse, Error};
329 use std::collections::HashMap;
330 use std::sync::Arc;
331
332 fn plan_single(
334 main_spec: &LemmaSpec,
335 all_specs: &[LemmaSpec],
336 ) -> Result<ExecutionPlan, Vec<Error>> {
337 let mut ctx = Context::new();
338 let repository = ctx.workspace();
339 for spec in all_specs {
340 if let Err(e) = ctx.insert_spec(Arc::clone(&repository), Arc::new(spec.clone())) {
341 return Err(vec![e]);
342 }
343 }
344 let main_spec_arc = ctx
345 .spec_set(&repository, main_spec.name.as_str())
346 .and_then(|ss| ss.get_exact(main_spec.effective_from()).cloned())
347 .expect("main_spec must be in all_specs");
348 let result = plan(&ctx, &ResourceLimits::default());
349 let all_errors: Vec<Error> = result
350 .results
351 .iter()
352 .flat_map(|r| r.errors().cloned())
353 .collect();
354 if !all_errors.is_empty() {
355 return Err(all_errors);
356 }
357 match result
358 .results
359 .into_iter()
360 .find(|r| r.name == main_spec_arc.name)
361 {
362 Some(spec_result) => {
363 let plan_set = spec_result.execution_plan_set();
364 if plan_set.plans.is_empty() {
365 Err(vec![Error::validation(
366 format!("No execution plan produced for spec '{}'", main_spec.name),
367 Some(crate::planning::semantics::Source::new(
368 crate::parsing::source::SourceType::Volatile,
369 crate::planning::semantics::Span {
370 start: 0,
371 end: 0,
372 line: 1,
373 col: 0,
374 },
375 )),
376 None::<String>,
377 )])
378 } else {
379 let mut plans = plan_set.plans;
380 Ok(plans.remove(0))
381 }
382 }
383 None => Err(vec![Error::validation(
384 format!("No execution plan produced for spec '{}'", main_spec.name),
385 Some(crate::planning::semantics::Source::new(
386 crate::parsing::source::SourceType::Volatile,
387 crate::planning::semantics::Span {
388 start: 0,
389 end: 0,
390 line: 1,
391 col: 0,
392 },
393 )),
394 None::<String>,
395 )]),
396 }
397 }
398
399 #[test]
400 fn test_basic_validation() {
401 let input = r#"spec person
402data name: "John"
403data age: 25
404rule is_adult: age >= 18"#;
405
406 let specs: Vec<_> = parse(
407 input,
408 crate::parsing::source::SourceType::Volatile,
409 &ResourceLimits::default(),
410 )
411 .unwrap()
412 .into_flattened_specs();
413
414 let mut sources = HashMap::new();
415 sources.insert(
416 crate::parsing::source::SourceType::Volatile,
417 input.to_string(),
418 );
419
420 for spec in &specs {
421 let result = plan_single(spec, &specs);
422 assert!(
423 result.is_ok(),
424 "Basic validation should pass: {:?}",
425 result.err()
426 );
427 }
428 }
429
430 #[test]
431 fn test_duplicate_data() {
432 let input = r#"spec person
433data name: "John"
434data name: "Jane""#;
435
436 let specs: Vec<_> = parse(
437 input,
438 crate::parsing::source::SourceType::Volatile,
439 &ResourceLimits::default(),
440 )
441 .unwrap()
442 .into_flattened_specs();
443
444 let mut sources = HashMap::new();
445 sources.insert(
446 crate::parsing::source::SourceType::Volatile,
447 input.to_string(),
448 );
449
450 let result = plan_single(&specs[0], &specs);
451
452 assert!(
453 result.is_err(),
454 "Duplicate data should cause validation error"
455 );
456 let errors = result.unwrap_err();
457 let error_string = errors
458 .iter()
459 .map(|e| e.to_string())
460 .collect::<Vec<_>>()
461 .join(", ");
462 assert!(
463 error_string.contains("already used"),
464 "Error should mention duplicate data: {}",
465 error_string
466 );
467 assert!(error_string.contains("name"));
468 }
469
470 #[test]
471 fn mixed_type_range_literal_is_planning_error_not_panic() {
472 let input = r#"spec demo
473data x: 1 ... yes"#;
474
475 let specs: Vec<_> = parse(
476 input,
477 crate::parsing::source::SourceType::Volatile,
478 &ResourceLimits::default(),
479 )
480 .unwrap()
481 .into_flattened_specs();
482
483 let result = plan_single(&specs[0], &specs);
484
485 let errors = result.expect_err("mixed-type range literal must be a planning error");
486 assert_eq!(
487 errors.len(),
488 1,
489 "expected exactly one planning error, got: {:?}",
490 errors.iter().map(|e| e.to_string()).collect::<Vec<_>>()
491 );
492 let error_string = errors[0].to_string();
493 assert!(
494 error_string.contains(
495 "range endpoints must have the same supported base type, got number and boolean"
496 ),
497 "unexpected error message: {}",
498 error_string
499 );
500 }
501
502 #[test]
503 fn text_range_literal_is_planning_error_not_panic() {
504 let input = r#"spec demo
505data x: "a" ... "b""#;
506
507 let specs: Vec<_> = parse(
508 input,
509 crate::parsing::source::SourceType::Volatile,
510 &ResourceLimits::default(),
511 )
512 .unwrap()
513 .into_flattened_specs();
514
515 let result = plan_single(&specs[0], &specs);
516
517 let errors = result.expect_err("text range literal must be a planning error");
518 assert_eq!(
519 errors.len(),
520 1,
521 "expected exactly one planning error, got: {:?}",
522 errors.iter().map(|e| e.to_string()).collect::<Vec<_>>()
523 );
524 let error_string = errors[0].to_string();
525 assert!(
526 error_string.contains(
527 "range endpoints must have the same supported base type, got text and text"
528 ),
529 "unexpected error message: {}",
530 error_string
531 );
532 }
533
534 #[test]
535 fn qualified_type_from_spec_with_type_errors_is_planning_error_not_panic() {
536 let input = r#"spec b
537data money: number -> minimum 10 -> maximum 5
538
539spec a
540uses b
541data x: b.money"#;
542
543 let specs: Vec<_> = parse(
544 input,
545 crate::parsing::source::SourceType::Volatile,
546 &ResourceLimits::default(),
547 )
548 .unwrap()
549 .into_flattened_specs();
550
551 let result = plan_single(&specs[0], &specs);
552
553 let errors = result.expect_err("failing import target must be a planning error");
554 let error_string = errors
555 .iter()
556 .map(|e| e.to_string())
557 .collect::<Vec<_>>()
558 .join(", ");
559 assert!(
560 error_string.contains("minimum"),
561 "expected the import target's own type error to be reported: {}",
562 error_string
563 );
564 assert!(
565 error_string.contains(
566 "Cannot resolve type 'money' from spec 'b' (via import 'b'): spec 'b' failed type resolution"
567 ),
568 "expected the consumer's qualified type resolution error to be reported: {}",
569 error_string
570 );
571 }
572
573 #[test]
574 fn test_duplicate_rules() {
575 let input = r#"spec person
576data age: 25
577rule is_adult: age >= 18
578rule is_adult: age >= 21"#;
579
580 let specs: Vec<_> = parse(
581 input,
582 crate::parsing::source::SourceType::Volatile,
583 &ResourceLimits::default(),
584 )
585 .unwrap()
586 .into_flattened_specs();
587
588 let mut sources = HashMap::new();
589 sources.insert(
590 crate::parsing::source::SourceType::Volatile,
591 input.to_string(),
592 );
593
594 let result = plan_single(&specs[0], &specs);
595
596 assert!(
597 result.is_err(),
598 "Duplicate rules should cause validation error"
599 );
600 let errors = result.unwrap_err();
601 let error_string = errors
602 .iter()
603 .map(|e| e.to_string())
604 .collect::<Vec<_>>()
605 .join(", ");
606 assert!(
607 error_string.contains("Duplicate rule"),
608 "Error should mention duplicate rule: {}",
609 error_string
610 );
611 assert!(error_string.contains("is_adult"));
612 }
613
614 #[test]
615 fn test_circular_dependency() {
616 let input = r#"spec test
617rule a: b
618rule b: a"#;
619
620 let specs: Vec<_> = parse(
621 input,
622 crate::parsing::source::SourceType::Volatile,
623 &ResourceLimits::default(),
624 )
625 .unwrap()
626 .into_flattened_specs();
627
628 let mut sources = HashMap::new();
629 sources.insert(
630 crate::parsing::source::SourceType::Volatile,
631 input.to_string(),
632 );
633
634 let result = plan_single(&specs[0], &specs);
635
636 assert!(
637 result.is_err(),
638 "Circular dependency should cause validation error"
639 );
640 let errors = result.unwrap_err();
641 let error_string = errors
642 .iter()
643 .map(|e| e.to_string())
644 .collect::<Vec<_>>()
645 .join(", ");
646 assert!(error_string.contains("Circular dependency") || error_string.contains("circular"));
647 }
648
649 #[test]
650 fn test_multiple_specs() {
651 let input = r#"spec person
652data name: "John"
653data age: 25
654
655spec company
656data name: "Acme Corp"
657uses employee: person"#;
658
659 let specs: Vec<_> = parse(
660 input,
661 crate::parsing::source::SourceType::Volatile,
662 &ResourceLimits::default(),
663 )
664 .unwrap()
665 .into_flattened_specs();
666
667 let mut sources = HashMap::new();
668 sources.insert(
669 crate::parsing::source::SourceType::Volatile,
670 input.to_string(),
671 );
672
673 let result = plan_single(&specs[0], &specs);
674
675 assert!(
676 result.is_ok(),
677 "Multiple specs should validate successfully: {:?}",
678 result.err()
679 );
680 }
681
682 #[test]
683 fn test_invalid_spec_reference() {
684 let input = r#"spec person
685data name: "John"
686uses contract: nonexistent"#;
687
688 let specs: Vec<_> = parse(
689 input,
690 crate::parsing::source::SourceType::Volatile,
691 &ResourceLimits::default(),
692 )
693 .unwrap()
694 .into_flattened_specs();
695
696 let mut sources = HashMap::new();
697 sources.insert(
698 crate::parsing::source::SourceType::Volatile,
699 input.to_string(),
700 );
701
702 let result = plan_single(&specs[0], &specs);
703
704 assert!(
705 result.is_err(),
706 "Invalid spec reference should cause validation error"
707 );
708 let errors = result.unwrap_err();
709 let error_string = errors
710 .iter()
711 .map(|e| e.to_string())
712 .collect::<Vec<_>>()
713 .join(", ");
714 assert!(
715 error_string.contains("not found")
716 || error_string.contains("Spec")
717 || (error_string.contains("nonexistent") && error_string.contains("depends")),
718 "Error should mention spec reference issue: {}",
719 error_string
720 );
721 assert!(error_string.contains("nonexistent"));
722 }
723
724 #[test]
725 fn test_definition_empty_base_returns_lemma_error() {
726 let mut spec = LemmaSpec::new("test".to_string());
727 let source = Source::new(
728 crate::parsing::source::SourceType::Volatile,
729 Span {
730 start: 0,
731 end: 10,
732 line: 1,
733 col: 0,
734 },
735 );
736 spec.data.push(LemmaData::new(
737 Reference {
738 segments: vec![],
739 name: "x".to_string(),
740 },
741 DataValue::Definition {
742 base: Some(ParentType::Custom {
743 name: String::new(),
744 }),
745 constraints: None,
746 value: None,
747 },
748 source,
749 ));
750
751 let specs = vec![spec.clone()];
752 let mut sources = HashMap::new();
753 sources.insert(
754 crate::parsing::source::SourceType::Volatile,
755 "spec test\ndata x:".to_string(),
756 );
757
758 let result = plan_single(&spec, &specs);
759 assert!(
760 result.is_err(),
761 "Definition with empty base should fail planning"
762 );
763 let errors = result.unwrap_err();
764 let combined = errors
765 .iter()
766 .map(|e| e.to_string())
767 .collect::<Vec<_>>()
768 .join("\n");
769 assert!(
770 combined.contains("Unknown parent ''"),
771 "Error should mention empty/unknown type; got: {}",
772 combined
773 );
774 }
775
776 #[test]
777 fn test_data_binding_with_custom_type_resolves_in_correct_spec_context() {
778 let code = r#"
789spec one
790data money: number
791data x: money
792
793spec two
794uses one
795with one.x: 7
796rule getx: one.x
797"#;
798
799 let specs = parse(
800 code,
801 crate::parsing::source::SourceType::Volatile,
802 &ResourceLimits::default(),
803 )
804 .unwrap()
805 .into_flattened_specs();
806 let spec_two = specs.iter().find(|d| d.name == "two").unwrap();
807
808 let mut sources = HashMap::new();
809 sources.insert(
810 crate::parsing::source::SourceType::Volatile,
811 code.to_string(),
812 );
813 let execution_plan = plan_single(spec_two, &specs).expect("planning should succeed");
814
815 let one_x_path = DataPath {
817 segments: vec![PathSegment {
818 data: "one".to_string(),
819 spec: "one".to_string(),
820 }],
821 data: "x".to_string(),
822 };
823
824 let one_x_type = execution_plan
825 .data
826 .get(&one_x_path)
827 .and_then(|d| d.schema_type())
828 .expect("one.x should have a resolved type");
829
830 assert_eq!(
831 one_x_type.name(),
832 "x",
833 "one.x should have declared type 'x', got: {}",
834 one_x_type.name()
835 );
836 assert!(one_x_type.is_number(), "money should be number-based");
837 }
838
839 #[test]
840 fn test_data_definition_from_spec_has_import_defining_spec() {
841 let code = r#"
842spec examples
843data money: measure
844 -> unit eur 1.00
845
846spec checkout
847uses examples
848data money: measure
849 -> unit eur 1.00
850data local_price: money
851data imported_price: examples.money
852"#;
853
854 let specs = parse(
855 code,
856 crate::parsing::source::SourceType::Volatile,
857 &ResourceLimits::default(),
858 )
859 .unwrap()
860 .into_flattened_specs();
861
862 let mut ctx = Context::new();
863 let repository = ctx.workspace();
864 for spec in &specs {
865 ctx.insert_spec(Arc::clone(&repository), Arc::new(spec.clone()))
866 .expect("insert spec");
867 }
868
869 let examples_arc = ctx
870 .spec_set(&repository, "examples")
871 .and_then(|ss| ss.get_exact(None).cloned())
872 .expect("examples spec should be present");
873 let checkout_arc = ctx
874 .spec_set(&repository, "checkout")
875 .and_then(|ss| ss.get_exact(None).cloned())
876 .expect("checkout spec should be present");
877
878 let mut sources = HashMap::new();
879 sources.insert(
880 crate::parsing::source::SourceType::Volatile,
881 code.to_string(),
882 );
883
884 let result = plan(&ctx, &ResourceLimits::default());
885
886 let checkout_result = result
887 .results
888 .iter()
889 .find(|r| r.name == checkout_arc.name)
890 .expect("checkout result should exist");
891 let checkout_errors: Vec<_> = checkout_result.errors().collect();
892 assert!(
893 checkout_errors.is_empty(),
894 "No checkout planning errors expected, got: {:?}",
895 checkout_errors
896 );
897 let checkout_plans = checkout_result.execution_plan_set();
898 assert!(
899 !checkout_plans.plans.is_empty(),
900 "checkout should produce at least one plan"
901 );
902 let execution_plan = &checkout_plans.plans[0];
903
904 let local_type = execution_plan
905 .data
906 .get(&DataPath::new(vec![], "local_price".to_string()))
907 .and_then(|d| d.schema_type())
908 .expect("local_price should have schema type");
909 let imported_type = execution_plan
910 .data
911 .get(&DataPath::new(vec![], "imported_price".to_string()))
912 .and_then(|d| d.schema_type())
913 .expect("imported_price should have schema type");
914
915 match &local_type.extends {
916 TypeExtends::Custom {
917 defining_spec: TypeDefiningSpec::Local,
918 ..
919 } => {}
920 other => panic!(
921 "local_price should resolve as local defining_spec, got {:?}",
922 other
923 ),
924 }
925
926 match &imported_type.extends {
927 TypeExtends::Custom {
928 defining_spec: TypeDefiningSpec::Import { spec, .. },
929 ..
930 } => {
931 assert!(
932 Arc::ptr_eq(spec, &examples_arc),
933 "imported_price should point to resolved 'examples' spec arc"
934 );
935 }
936 other => panic!(
937 "imported_price should resolve as import defining_spec, got {:?}",
938 other
939 ),
940 }
941 }
942
943 #[test]
944 fn test_plan_with_registry_grouped_specs() {
945 let source = r#"spec somespec
946data quantity: 10
947
948spec example
949uses inventory: somespec
950rule total_measure: inventory.quantity"#;
951
952 let parsed = parse(
953 source,
954 crate::parsing::source::SourceType::Volatile,
955 &ResourceLimits::default(),
956 )
957 .unwrap();
958 assert_eq!(parsed.flatten_specs().len(), 2);
959
960 let mut ctx = Context::new();
961 let repository = Arc::new(
962 LemmaRepository::new(Some("@user/workspace".to_string()))
963 .with_dependency("@user/workspace")
964 .with_start_line(1)
965 .with_source_type(crate::parsing::source::SourceType::Volatile),
966 );
967 for spec in parsed.flatten_specs() {
968 ctx.insert_spec(Arc::clone(&repository), Arc::new(spec.clone()))
969 .expect("insert spec");
970 }
971
972 let result = plan(&ctx, &ResourceLimits::default());
973 let example_result = result
974 .results
975 .iter()
976 .find(|r| r.name == "example")
977 .expect("example result must exist");
978 let errors: Vec<_> = example_result.errors().collect();
979 assert!(
980 errors.is_empty(),
981 "Planning under registry-scoped specs should succeed: {:?}",
982 errors
983 );
984 assert!(
985 !example_result.execution_plan_set().plans.is_empty(),
986 "expected at least one plan for registry-grouped example"
987 );
988 }
989
990 #[test]
991 fn test_multiple_independent_errors_are_all_reported() {
992 let source = r#"spec demo
995uses type_src: nonexistent_type_source
996with type_src.amount: 10
997uses helper: nonexistent_spec
998data price: 10
999rule total: helper.value + price"#;
1000
1001 let specs = parse(
1002 source,
1003 crate::parsing::source::SourceType::Volatile,
1004 &ResourceLimits::default(),
1005 )
1006 .unwrap()
1007 .into_flattened_specs();
1008
1009 let mut sources = HashMap::new();
1010 sources.insert(
1011 crate::parsing::source::SourceType::Volatile,
1012 source.to_string(),
1013 );
1014
1015 let result = plan_single(&specs[0], &specs);
1016 assert!(result.is_err(), "Planning should fail with multiple errors");
1017
1018 let errors = result.unwrap_err();
1019 let all_messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
1020 let combined = all_messages.join("\n");
1021
1022 assert!(
1023 combined.contains("nonexistent_type_source"),
1024 "Should report import error for 'nonexistent_type_source'. Got:\n{}",
1025 combined
1026 );
1027
1028 assert!(
1030 combined.contains("nonexistent_spec"),
1031 "Should report spec reference error for 'nonexistent_spec'. Got:\n{}",
1032 combined
1033 );
1034
1035 assert!(
1037 errors.len() >= 2,
1038 "Expected at least 2 errors, got {}: {}",
1039 errors.len(),
1040 combined
1041 );
1042
1043 let data_import_err = errors
1044 .iter()
1045 .find(|e| e.to_string().contains("nonexistent_type_source"))
1046 .expect("import error");
1047 let loc = data_import_err
1048 .location()
1049 .expect("import error should carry source location");
1050 assert_eq!(
1051 loc.source_type,
1052 crate::parsing::source::SourceType::Volatile
1053 );
1054 assert_ne!(
1055 (loc.span.start, loc.span.end),
1056 (0, 0),
1057 "import error span should not be empty"
1058 );
1059 }
1060
1061 #[test]
1062 fn test_type_error_does_not_suppress_cross_spec_data_error() {
1063 let source = r#"spec demo
1067uses cur: missing_spec
1068with cur.currency: 10
1069uses ext: also_missing
1070rule val: ext.some_data"#;
1071
1072 let specs = parse(
1073 source,
1074 crate::parsing::source::SourceType::Volatile,
1075 &ResourceLimits::default(),
1076 )
1077 .unwrap()
1078 .into_flattened_specs();
1079
1080 let mut sources = HashMap::new();
1081 sources.insert(
1082 crate::parsing::source::SourceType::Volatile,
1083 source.to_string(),
1084 );
1085
1086 let result = plan_single(&specs[0], &specs);
1087 assert!(result.is_err());
1088
1089 let errors = result.unwrap_err();
1090 let combined: String = errors
1091 .iter()
1092 .map(|e| e.to_string())
1093 .collect::<Vec<_>>()
1094 .join("\n");
1095
1096 assert!(
1097 combined.contains("missing_spec"),
1098 "Should report import error about 'missing_spec'. Got:\n{}",
1099 combined
1100 );
1101
1102 assert!(
1104 combined.contains("also_missing"),
1105 "Should report error about 'also_missing'. Got:\n{}",
1106 combined
1107 );
1108 }
1109
1110 #[test]
1111 fn test_spec_dag_orders_dep_before_consumer() {
1112 let source = r#"spec dep 2025-01-01
1113data money: number
1114data x: money
1115
1116spec consumer 2025-01-01
1117uses dep
1118data imported_amount: dep.money
1119rule passthrough: imported_amount"#;
1120 let specs = parse(
1121 source,
1122 crate::parsing::source::SourceType::Volatile,
1123 &ResourceLimits::default(),
1124 )
1125 .unwrap()
1126 .into_flattened_specs();
1127
1128 let mut ctx = Context::new();
1129 let repository = ctx.workspace();
1130 for spec in &specs {
1131 ctx.insert_spec(Arc::clone(&repository), Arc::new(spec.clone()))
1132 .expect("insert spec");
1133 }
1134
1135 let dt = crate::DateTimeValue {
1136 year: 2025,
1137 month: 1,
1138 day: 1,
1139 hour: 0,
1140 minute: 0,
1141 second: 0,
1142 microsecond: 0,
1143 timezone: None,
1144 granularity: DateGranularity::Full,
1145 };
1146 let effective = crate::parsing::ast::EffectiveDate::DateTimeValue(dt);
1147 let consumer_arc = ctx
1148 .spec_set(&repository, "consumer")
1149 .and_then(|ss| ss.spec_at(&effective))
1150 .expect("consumer spec");
1151 let dag = super::discovery::build_dag_for_spec(
1152 &ctx,
1153 &consumer_arc,
1154 &effective,
1155 &crate::ResourceLimits::default(),
1156 )
1157 .expect("DAG should succeed");
1158 let ordered_names: Vec<String> = dag.iter().map(|s| s.1.name.clone()).collect();
1159 let dep_idx = ordered_names
1160 .iter()
1161 .position(|n| n == "dep")
1162 .expect("dep must exist");
1163 let consumer_idx = ordered_names
1164 .iter()
1165 .position(|n| n == "consumer")
1166 .expect("consumer must exist");
1167 assert!(
1168 dep_idx < consumer_idx,
1169 "dependency must be planned before dependent. order={:?}",
1170 ordered_names
1171 );
1172 }
1173
1174 #[test]
1175 fn test_spec_dependency_cycle_surfaces_as_spec_error_and_populates_results() {
1176 let source = r#"spec a 2025-01-01
1177uses dep_b: b
1178data amount: number
1179
1180spec b 2025-01-01
1181uses src_a: a
1182data imported_value: src_a.amount
1183"#;
1184 let specs = parse(
1185 source,
1186 crate::parsing::source::SourceType::Volatile,
1187 &ResourceLimits::default(),
1188 )
1189 .unwrap()
1190 .into_flattened_specs();
1191
1192 let mut ctx = Context::new();
1193 let repository = ctx.workspace();
1194 for spec in &specs {
1195 ctx.insert_spec(Arc::clone(&repository), Arc::new(spec.clone()))
1196 .expect("insert spec");
1197 }
1198
1199 let result = plan(&ctx, &ResourceLimits::default());
1200
1201 let spec_errors: Vec<String> = result
1202 .results
1203 .iter()
1204 .flat_map(|r| r.errors())
1205 .map(|e| e.to_string())
1206 .collect();
1207 assert!(
1208 spec_errors
1209 .iter()
1210 .any(|e| e.contains("Spec dependency cycle")),
1211 "expected cycle error on spec, got: {spec_errors:?}",
1212 );
1213
1214 assert!(
1215 result.results.iter().any(|r| r.name == "b"),
1216 "cyclic spec 'b' must still have an entry in results so downstream invariants hold"
1217 );
1218 }
1219
1220 fn has_source_for(plan: &super::execution_plan::ExecutionPlan, name: &str) -> bool {
1225 plan.sources.iter().any(|e| e.name == name)
1226 }
1227
1228 #[test]
1229 fn sources_contain_main_and_dep_for_cross_spec_rule_reference() {
1230 let code = r#"
1231spec dep
1232data x: 10
1233rule val: x
1234
1235spec consumer
1236uses d: dep
1237with d.x: 5
1238rule result: d.val
1239"#;
1240 let specs = parse(
1241 code,
1242 crate::parsing::source::SourceType::Volatile,
1243 &ResourceLimits::default(),
1244 )
1245 .unwrap()
1246 .into_flattened_specs();
1247 let consumer = specs.iter().find(|s| s.name == "consumer").unwrap();
1248
1249 let mut sources = HashMap::new();
1250 sources.insert(
1251 crate::parsing::source::SourceType::Volatile,
1252 code.to_string(),
1253 );
1254
1255 let plan = plan_single(consumer, &specs).expect("planning should succeed");
1256
1257 assert_eq!(plan.sources.len(), 2, "main + dep, got: {:?}", plan.sources);
1258 assert!(
1259 has_source_for(&plan, "consumer"),
1260 "sources must include main spec"
1261 );
1262 assert!(
1263 has_source_for(&plan, "dep"),
1264 "sources must include dep spec"
1265 );
1266 }
1267
1268 #[test]
1269 fn sources_contain_only_main_for_standalone_spec() {
1270 let code = r#"
1271spec standalone
1272data age: 25
1273rule is_adult: age >= 18
1274"#;
1275 let specs = parse(
1276 code,
1277 crate::parsing::source::SourceType::Volatile,
1278 &ResourceLimits::default(),
1279 )
1280 .unwrap()
1281 .into_flattened_specs();
1282
1283 let mut sources = HashMap::new();
1284 sources.insert(
1285 crate::parsing::source::SourceType::Volatile,
1286 code.to_string(),
1287 );
1288
1289 let plan = plan_single(&specs[0], &specs).expect("planning should succeed");
1290
1291 assert_eq!(
1292 plan.sources.len(),
1293 1,
1294 "standalone should have only main spec"
1295 );
1296 assert!(has_source_for(&plan, "standalone"));
1297 }
1298
1299 #[test]
1300 fn sources_contain_all_cross_spec_refs() {
1301 let code = r#"
1302spec rates
1303data base_rate: 0.05
1304rule rate: base_rate
1305
1306spec config
1307data threshold: 100
1308rule limit: threshold
1309
1310spec calculator
1311uses r: rates
1312with r.base_rate: 0.03
1313uses c: config
1314with c.threshold: 200
1315rule combined: r.rate + c.limit
1316"#;
1317 let specs = parse(
1318 code,
1319 crate::parsing::source::SourceType::Volatile,
1320 &ResourceLimits::default(),
1321 )
1322 .unwrap()
1323 .into_flattened_specs();
1324 let calc = specs.iter().find(|s| s.name == "calculator").unwrap();
1325
1326 let mut sources = HashMap::new();
1327 sources.insert(
1328 crate::parsing::source::SourceType::Volatile,
1329 code.to_string(),
1330 );
1331
1332 let plan = plan_single(calc, &specs).expect("planning should succeed");
1333
1334 assert_eq!(
1335 plan.sources.len(),
1336 3,
1337 "calculator + rates + config, got: {:?}",
1338 plan.sources
1339 );
1340 assert!(has_source_for(&plan, "calculator"));
1341 assert!(has_source_for(&plan, "rates"));
1342 assert!(has_source_for(&plan, "config"));
1343 }
1344
1345 #[test]
1346 fn sources_include_spec_ref_even_without_rules() {
1347 let code = r#"
1348spec dep
1349data x: 10
1350
1351spec consumer
1352uses d: dep
1353data local: 99
1354rule result: local
1355"#;
1356 let specs = parse(
1357 code,
1358 crate::parsing::source::SourceType::Volatile,
1359 &ResourceLimits::default(),
1360 )
1361 .unwrap()
1362 .into_flattened_specs();
1363 let consumer = specs.iter().find(|s| s.name == "consumer").unwrap();
1364
1365 let mut sources = HashMap::new();
1366 sources.insert(
1367 crate::parsing::source::SourceType::Volatile,
1368 code.to_string(),
1369 );
1370
1371 let plan = plan_single(consumer, &specs).expect("planning should succeed");
1372
1373 assert_eq!(
1374 plan.sources.len(),
1375 2,
1376 "consumer + dep, got: {:?}",
1377 plan.sources
1378 );
1379 assert!(
1380 has_source_for(&plan, "dep"),
1381 "spec ref dep must be in sources even without rules"
1382 );
1383 }
1384
1385 #[test]
1386 fn sources_round_trip_to_valid_specs() {
1387 let code = r#"
1388spec dep
1389data x: 42
1390rule val: x
1391
1392spec consumer
1393uses d: dep
1394rule result: d.val
1395"#;
1396 let specs = parse(
1397 code,
1398 crate::parsing::source::SourceType::Volatile,
1399 &ResourceLimits::default(),
1400 )
1401 .unwrap()
1402 .into_flattened_specs();
1403 let consumer = specs.iter().find(|s| s.name == "consumer").unwrap();
1404
1405 let mut sources = HashMap::new();
1406 sources.insert(
1407 crate::parsing::source::SourceType::Volatile,
1408 code.to_string(),
1409 );
1410
1411 let plan = plan_single(consumer, &specs).expect("planning should succeed");
1412
1413 for super::execution_plan::SpecSource {
1414 name,
1415 source: source_text,
1416 ..
1417 } in &plan.sources
1418 {
1419 let parsed = parse(
1420 source_text,
1421 crate::parsing::source::SourceType::Volatile,
1422 &ResourceLimits::default(),
1423 );
1424 assert!(
1425 parsed.is_ok(),
1426 "source for '{}' must re-parse: {:?}\nsource:\n{}",
1427 name,
1428 parsed.err(),
1429 source_text
1430 );
1431 }
1432 }
1433}