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