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