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