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