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