1pub mod execution_plan;
9pub mod fingerprint;
10pub mod graph;
11pub mod semantics;
12pub mod slice_interface;
13pub mod temporal;
14pub mod types;
15pub mod validation;
16use crate::engine::Context;
17use crate::parsing::ast::{DateTimeValue, FactValue as ParsedFactValue, LemmaSpec, TypeDef};
18use crate::Error;
19pub use execution_plan::{Branch, ExecutableRule, ExecutionPlan, SpecSchema};
20pub use semantics::{
21 is_same_spec, negated_comparison, ArithmeticComputation, ComparisonComputation, Expression,
22 ExpressionKind, Fact, FactData, FactPath, FactValue, LemmaType, LiteralValue,
23 LogicalComputation, MathematicalComputation, NegationType, PathSegment, RulePath, Source, Span,
24 TypeDefiningSpec, TypeExtends, ValueKind, VetoExpression,
25};
26use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
27use std::sync::Arc;
28
29#[derive(Debug, Default, Clone)]
32pub struct PlanHashRegistry {
33 by_slice: BTreeMap<(String, Option<DateTimeValue>), String>,
34 by_pin: BTreeMap<(String, String), Arc<LemmaSpec>>,
35}
36
37impl PlanHashRegistry {
38 pub(crate) fn insert(
39 &mut self,
40 spec: &Arc<LemmaSpec>,
41 slice_from: Option<DateTimeValue>,
42 hash: String,
43 ) {
44 let hash_lower = hash.trim().to_ascii_lowercase();
45 self.by_slice
46 .insert((spec.name.clone(), slice_from), hash_lower.clone());
47 self.by_pin
48 .insert((spec.name.clone(), hash_lower), Arc::clone(spec));
49 }
50
51 pub(crate) fn get_by_slice(
53 &self,
54 spec_name: &str,
55 slice_from: &Option<DateTimeValue>,
56 ) -> Option<&str> {
57 self.by_slice
58 .get(&(spec_name.to_string(), slice_from.clone()))
59 .map(|s| s.as_str())
60 }
61
62 pub(crate) fn get_by_pin(&self, spec_name: &str, hash: &str) -> Option<&Arc<LemmaSpec>> {
64 let key = (spec_name.to_string(), hash.trim().to_ascii_lowercase());
65 self.by_pin.get(&key)
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
70struct SpecName(String);
71
72impl SpecName {
73 fn new(name: impl Into<String>) -> Self {
74 Self(name.into())
75 }
76}
77
78impl std::fmt::Display for SpecName {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 write!(f, "{}", self.0)
81 }
82}
83
84fn collect_spec_dependencies(
85 spec: &LemmaSpec,
86 known_names: &BTreeSet<SpecName>,
87) -> BTreeSet<SpecName> {
88 let mut deps = BTreeSet::new();
89 let current = SpecName::new(spec.name.clone());
90
91 for fact in &spec.facts {
92 match &fact.value {
93 ParsedFactValue::SpecReference(r) => {
94 let dep = SpecName::new(r.name.clone());
95 if dep != current && known_names.contains(&dep) {
96 deps.insert(dep);
97 }
98 }
99 ParsedFactValue::TypeDeclaration {
100 from: Some(from_ref),
101 ..
102 } => {
103 let dep = SpecName::new(from_ref.name.clone());
104 if dep != current && known_names.contains(&dep) {
105 deps.insert(dep);
106 }
107 }
108 _ => {}
109 }
110 }
111
112 for type_def in &spec.types {
113 if let TypeDef::Import { from, .. } = type_def {
114 let dep = SpecName::new(from.name.clone());
115 if dep != current && known_names.contains(&dep) {
116 deps.insert(dep);
117 }
118 }
119 }
120
121 deps
122}
123
124pub(crate) fn order_specs_for_planning_graph(
127 specs: Vec<Arc<LemmaSpec>>,
128) -> Result<Vec<Arc<LemmaSpec>>, Vec<Error>> {
129 let all_names: BTreeSet<SpecName> = specs
130 .iter()
131 .map(|s| SpecName::new(s.name.clone()))
132 .collect();
133
134 let mut deps_by_name: BTreeMap<SpecName, BTreeSet<SpecName>> = BTreeMap::new();
135 for name in &all_names {
136 deps_by_name.insert(name.clone(), BTreeSet::new());
137 }
138 for spec in &specs {
139 let deps = collect_spec_dependencies(spec, &all_names);
140 deps_by_name.insert(SpecName::new(spec.name.clone()), deps);
141 }
142
143 let mut in_degree: BTreeMap<SpecName, usize> = BTreeMap::new();
145 for (name, deps) in &deps_by_name {
146 in_degree.insert(name.clone(), deps.len());
147 }
148
149 let mut dependents: BTreeMap<SpecName, BTreeSet<SpecName>> = BTreeMap::new();
151 for name in &all_names {
152 dependents.insert(name.clone(), BTreeSet::new());
153 }
154 for (name, deps) in &deps_by_name {
155 for dep in deps {
156 if let Some(children) = dependents.get_mut(dep) {
157 children.insert(name.clone());
158 }
159 }
160 }
161
162 let mut queue: VecDeque<SpecName> = VecDeque::new();
163 for (name, degree) in &in_degree {
164 if *degree == 0 {
165 queue.push_back(name.clone());
166 }
167 }
168
169 let mut ordered_names: Vec<SpecName> = Vec::new();
170 while let Some(name) = queue.pop_front() {
171 ordered_names.push(name.clone());
172 if let Some(children) = dependents.get(&name) {
173 for child in children {
174 if let Some(degree) = in_degree.get_mut(child) {
175 *degree -= 1;
176 if *degree == 0 {
177 queue.push_back(child.clone());
178 }
179 }
180 }
181 }
182 }
183
184 if ordered_names.len() != all_names.len() {
185 let mut cycle_nodes: Vec<SpecName> = in_degree
186 .iter()
187 .filter_map(|(name, degree)| {
188 if *degree > 0 {
189 Some(name.clone())
190 } else {
191 None
192 }
193 })
194 .collect();
195 cycle_nodes.sort();
196 let cycle_path = if cycle_nodes.len() > 1 {
197 let mut path = cycle_nodes.clone();
198 path.push(cycle_nodes[0].clone());
199 path.iter()
200 .map(std::string::ToString::to_string)
201 .collect::<Vec<_>>()
202 .join(" -> ")
203 } else {
204 cycle_nodes
205 .iter()
206 .map(std::string::ToString::to_string)
207 .collect::<Vec<_>>()
208 .join(" -> ")
209 };
210 return Err(vec![Error::validation(
211 format!("Spec dependency cycle: {}", cycle_path),
212 None,
213 None::<String>,
214 )]);
215 }
216
217 let mut by_name: HashMap<SpecName, Vec<Arc<LemmaSpec>>> = HashMap::new();
218 for s in specs {
219 by_name
220 .entry(SpecName::new(s.name.clone()))
221 .or_default()
222 .push(s);
223 }
224 for v in by_name.values_mut() {
225 v.sort_by(|a, b| a.effective_from().cmp(&b.effective_from()));
226 }
227
228 let mut out = Vec::new();
229 for name in ordered_names {
230 if let Some(mut vec) = by_name.remove(&name) {
231 out.append(&mut vec);
232 }
233 }
234 Ok(out)
235}
236
237#[derive(Debug, Clone)]
239pub struct SpecPlanningResult {
240 pub spec: Arc<LemmaSpec>,
242 pub plans: Vec<ExecutionPlan>,
244 pub errors: Vec<Error>,
246}
247
248#[derive(Debug, Clone)]
250pub struct PlanningResult {
251 pub per_spec: Vec<SpecPlanningResult>,
253 pub global_errors: Vec<Error>,
255 pub plan_hash_registry: PlanHashRegistry,
257}
258
259pub fn plan(context: &Context, sources: HashMap<String, String>) -> PlanningResult {
269 let mut global_errors: Vec<Error> = Vec::new();
270 global_errors.extend(temporal::validate_temporal_coverage(context));
271
272 let all_specs: Vec<_> = match order_specs_for_planning_graph(context.iter().collect()) {
273 Ok(specs) => specs,
274 Err(mut cycle_errors) => {
275 global_errors.append(&mut cycle_errors);
276 return PlanningResult {
277 per_spec: Vec::new(),
278 global_errors,
279 plan_hash_registry: PlanHashRegistry::default(),
280 };
281 }
282 };
283
284 let mut per_spec: Vec<SpecPlanningResult> = Vec::new();
285 let mut plan_hashes = PlanHashRegistry::default();
286
287 for spec_arc in &all_specs {
288 let slices = temporal::compute_temporal_slices(spec_arc, context);
289 let mut spec_plans: Vec<ExecutionPlan> = Vec::new();
290 let mut spec_errors: Vec<Error> = Vec::new();
291 let mut slice_resolved_types: Vec<HashMap<Arc<LemmaSpec>, types::ResolvedSpecTypes>> =
292 Vec::new();
293
294 for slice in &slices {
295 match graph::Graph::build(
296 spec_arc,
297 context,
298 sources.clone(),
299 slice.from.clone(),
300 &plan_hashes,
301 ) {
302 Ok((graph, slice_types)) => {
303 let execution_plan = execution_plan::build_execution_plan(
304 &graph,
305 &slice_types,
306 slice.from.clone(),
307 slice.to.clone(),
308 );
309 let value_errors =
310 execution_plan::validate_literal_facts_against_types(&execution_plan);
311 if value_errors.is_empty() {
312 let hash = execution_plan.plan_hash();
313 plan_hashes.insert(spec_arc, slice.from.clone(), hash);
314 spec_plans.push(execution_plan);
315 } else {
316 spec_errors.extend(value_errors);
317 }
318 slice_resolved_types.push(slice_types);
319 }
320 Err(build_errors) => {
321 spec_errors.extend(build_errors);
322 }
323 }
324 }
325
326 if spec_errors.is_empty() && spec_plans.len() > 1 {
327 spec_errors.extend(slice_interface::validate_slice_interfaces(
328 spec_arc,
329 &spec_plans,
330 &slice_resolved_types,
331 ));
332 }
333
334 per_spec.push(SpecPlanningResult {
335 spec: Arc::clone(spec_arc),
336 plans: spec_plans,
337 errors: spec_errors,
338 });
339 }
340
341 PlanningResult {
342 per_spec,
343 global_errors,
344 plan_hash_registry: plan_hashes,
345 }
346}
347
348#[cfg(test)]
353mod internal_tests {
354 use super::{order_specs_for_planning_graph, plan};
355 use crate::engine::Context;
356 use crate::parsing::ast::{FactValue, LemmaFact, LemmaSpec, ParentType, Reference, Span};
357 use crate::parsing::source::Source;
358 use crate::planning::execution_plan::ExecutionPlan;
359 use crate::planning::semantics::{FactPath, PathSegment, TypeDefiningSpec, TypeExtends};
360 use crate::{parse, Error, ResourceLimits};
361 use std::collections::HashMap;
362 use std::sync::Arc;
363
364 fn plan_single(
366 main_spec: &LemmaSpec,
367 all_specs: &[LemmaSpec],
368 sources: HashMap<String, String>,
369 ) -> Result<ExecutionPlan, Vec<Error>> {
370 let mut ctx = Context::new();
371 for spec in all_specs {
372 if let Err(e) = ctx.insert_spec(Arc::new(spec.clone()), spec.from_registry) {
373 return Err(vec![e]);
374 }
375 }
376 let main_spec_arc = ctx
377 .get_spec_effective_from(main_spec.name.as_str(), main_spec.effective_from())
378 .expect("main_spec must be in all_specs");
379 let result = plan(&ctx, sources);
380 let all_errors: Vec<Error> = result
381 .global_errors
382 .into_iter()
383 .chain(
384 result
385 .per_spec
386 .iter()
387 .flat_map(|r| r.errors.clone())
388 .collect::<Vec<_>>(),
389 )
390 .collect();
391 if !all_errors.is_empty() {
392 return Err(all_errors);
393 }
394 match result
395 .per_spec
396 .into_iter()
397 .find(|r| Arc::ptr_eq(&r.spec, &main_spec_arc))
398 {
399 Some(spec_result) if !spec_result.plans.is_empty() => {
400 let mut plans = spec_result.plans;
401 Ok(plans.remove(0))
402 }
403 _ => Err(vec![Error::validation(
404 format!("No execution plan produced for spec '{}'", main_spec.name),
405 Some(crate::planning::semantics::Source::new(
406 "<test>",
407 crate::planning::semantics::Span {
408 start: 0,
409 end: 0,
410 line: 1,
411 col: 0,
412 },
413 )),
414 None::<String>,
415 )]),
416 }
417 }
418
419 #[test]
420 fn test_basic_validation() {
421 let input = r#"spec person
422fact name: "John"
423fact age: 25
424rule is_adult: age >= 18"#;
425
426 let specs = parse(input, "test.lemma", &ResourceLimits::default())
427 .unwrap()
428 .specs;
429
430 let mut sources = HashMap::new();
431 sources.insert("test.lemma".to_string(), input.to_string());
432
433 for spec in &specs {
434 let result = plan_single(spec, &specs, sources.clone());
435 assert!(
436 result.is_ok(),
437 "Basic validation should pass: {:?}",
438 result.err()
439 );
440 }
441 }
442
443 #[test]
444 fn test_duplicate_facts() {
445 let input = r#"spec person
446fact name: "John"
447fact name: "Jane""#;
448
449 let specs = parse(input, "test.lemma", &ResourceLimits::default())
450 .unwrap()
451 .specs;
452
453 let mut sources = HashMap::new();
454 sources.insert("test.lemma".to_string(), input.to_string());
455
456 let result = plan_single(&specs[0], &specs, sources);
457
458 assert!(
459 result.is_err(),
460 "Duplicate facts should cause validation error"
461 );
462 let errors = result.unwrap_err();
463 let error_string = errors
464 .iter()
465 .map(|e| e.to_string())
466 .collect::<Vec<_>>()
467 .join(", ");
468 assert!(
469 error_string.contains("Duplicate fact"),
470 "Error should mention duplicate fact: {}",
471 error_string
472 );
473 assert!(error_string.contains("name"));
474 }
475
476 #[test]
477 fn test_duplicate_rules() {
478 let input = r#"spec person
479fact age: 25
480rule is_adult: age >= 18
481rule is_adult: age >= 21"#;
482
483 let specs = parse(input, "test.lemma", &ResourceLimits::default())
484 .unwrap()
485 .specs;
486
487 let mut sources = HashMap::new();
488 sources.insert("test.lemma".to_string(), input.to_string());
489
490 let result = plan_single(&specs[0], &specs, sources);
491
492 assert!(
493 result.is_err(),
494 "Duplicate rules should cause validation error"
495 );
496 let errors = result.unwrap_err();
497 let error_string = errors
498 .iter()
499 .map(|e| e.to_string())
500 .collect::<Vec<_>>()
501 .join(", ");
502 assert!(
503 error_string.contains("Duplicate rule"),
504 "Error should mention duplicate rule: {}",
505 error_string
506 );
507 assert!(error_string.contains("is_adult"));
508 }
509
510 #[test]
511 fn test_circular_dependency() {
512 let input = r#"spec test
513rule a: b
514rule b: a"#;
515
516 let specs = parse(input, "test.lemma", &ResourceLimits::default())
517 .unwrap()
518 .specs;
519
520 let mut sources = HashMap::new();
521 sources.insert("test.lemma".to_string(), input.to_string());
522
523 let result = plan_single(&specs[0], &specs, sources);
524
525 assert!(
526 result.is_err(),
527 "Circular dependency should cause validation error"
528 );
529 let errors = result.unwrap_err();
530 let error_string = errors
531 .iter()
532 .map(|e| e.to_string())
533 .collect::<Vec<_>>()
534 .join(", ");
535 assert!(error_string.contains("Circular dependency") || error_string.contains("circular"));
536 }
537
538 #[test]
539 fn test_unified_references_work() {
540 let input = r#"spec test
541fact age: 25
542rule is_adult: age >= 18
543rule test1: age
544rule test2: is_adult"#;
545
546 let specs = parse(input, "test.lemma", &ResourceLimits::default())
547 .unwrap()
548 .specs;
549
550 let mut sources = HashMap::new();
551 sources.insert("test.lemma".to_string(), input.to_string());
552
553 let result = plan_single(&specs[0], &specs, sources);
554
555 assert!(
556 result.is_ok(),
557 "Unified references should work: {:?}",
558 result.err()
559 );
560 }
561
562 #[test]
563 fn test_multiple_specs() {
564 let input = r#"spec person
565fact name: "John"
566fact age: 25
567
568spec company
569fact name: "Acme Corp"
570fact employee: spec person"#;
571
572 let specs = parse(input, "test.lemma", &ResourceLimits::default())
573 .unwrap()
574 .specs;
575
576 let mut sources = HashMap::new();
577 sources.insert("test.lemma".to_string(), input.to_string());
578
579 let result = plan_single(&specs[0], &specs, sources);
580
581 assert!(
582 result.is_ok(),
583 "Multiple specs should validate successfully: {:?}",
584 result.err()
585 );
586 }
587
588 #[test]
589 fn test_invalid_spec_reference() {
590 let input = r#"spec person
591fact name: "John"
592fact contract: spec nonexistent"#;
593
594 let specs = parse(input, "test.lemma", &ResourceLimits::default())
595 .unwrap()
596 .specs;
597
598 let mut sources = HashMap::new();
599 sources.insert("test.lemma".to_string(), input.to_string());
600
601 let result = plan_single(&specs[0], &specs, sources);
602
603 assert!(
604 result.is_err(),
605 "Invalid spec reference should cause validation error"
606 );
607 let errors = result.unwrap_err();
608 let error_string = errors
609 .iter()
610 .map(|e| e.to_string())
611 .collect::<Vec<_>>()
612 .join(", ");
613 assert!(
614 error_string.contains("not found")
615 || error_string.contains("Spec")
616 || (error_string.contains("nonexistent") && error_string.contains("depends")),
617 "Error should mention spec reference issue: {}",
618 error_string
619 );
620 assert!(error_string.contains("nonexistent"));
621 }
622
623 #[test]
624 fn test_type_declaration_empty_base_returns_lemma_error() {
625 let mut spec = LemmaSpec::new("test".to_string());
626 let source = Source::new(
627 "test.lemma",
628 Span {
629 start: 0,
630 end: 10,
631 line: 1,
632 col: 0,
633 },
634 );
635 spec.facts.push(LemmaFact::new(
636 Reference {
637 segments: vec![],
638 name: "x".to_string(),
639 },
640 FactValue::TypeDeclaration {
641 base: ParentType::Custom(String::new()),
642 constraints: None,
643 from: None,
644 },
645 source,
646 ));
647
648 let specs = vec![spec.clone()];
649 let mut sources = HashMap::new();
650 sources.insert(
651 "test.lemma".to_string(),
652 "spec test\nfact x: []".to_string(),
653 );
654
655 let result = plan_single(&spec, &specs, sources);
656 assert!(
657 result.is_err(),
658 "TypeDeclaration with empty base should fail planning"
659 );
660 let errors = result.unwrap_err();
661 let combined = errors
662 .iter()
663 .map(|e| e.to_string())
664 .collect::<Vec<_>>()
665 .join("\n");
666 assert!(
667 combined.contains("TypeDeclaration base cannot be empty"),
668 "Error should mention empty base; got: {}",
669 combined
670 );
671 }
672
673 #[test]
674 fn test_fact_binding_with_custom_type_resolves_in_correct_spec_context() {
675 let code = r#"
686spec one
687type money: number
688fact x: [money]
689
690spec two
691fact one: spec one
692fact one.x: 7
693rule getx: one.x
694"#;
695
696 let specs = parse(code, "test.lemma", &ResourceLimits::default())
697 .unwrap()
698 .specs;
699 let spec_two = specs.iter().find(|d| d.name == "two").unwrap();
700
701 let mut sources = HashMap::new();
702 sources.insert("test.lemma".to_string(), code.to_string());
703 let execution_plan =
704 plan_single(spec_two, &specs, sources).expect("planning should succeed");
705
706 let one_x_path = FactPath {
708 segments: vec![PathSegment {
709 fact: "one".to_string(),
710 spec: "one".to_string(),
711 }],
712 fact: "x".to_string(),
713 };
714
715 let one_x_type = execution_plan
716 .facts
717 .get(&one_x_path)
718 .and_then(|d| d.schema_type())
719 .expect("one.x should have a resolved type");
720
721 assert_eq!(
722 one_x_type.name(),
723 "money",
724 "one.x should have type 'money', got: {}",
725 one_x_type.name()
726 );
727 assert!(one_x_type.is_number(), "money should be number-based");
728 }
729
730 #[test]
731 fn test_fact_type_declaration_from_spec_has_import_defining_spec() {
732 let code = r#"
733spec examples
734type money: scale
735 -> unit eur 1.00
736
737spec checkout
738type money: scale
739 -> unit eur 1.00
740fact local_price: [money]
741fact imported_price: [money from examples]
742"#;
743
744 let specs = parse(code, "test.lemma", &ResourceLimits::default())
745 .unwrap()
746 .specs;
747
748 let mut ctx = Context::new();
749 for spec in &specs {
750 ctx.insert_spec(Arc::new(spec.clone()), spec.from_registry)
751 .expect("insert spec");
752 }
753
754 let examples_arc = ctx
755 .get_spec_effective_from("examples", None)
756 .expect("examples spec should be present");
757 let checkout_arc = ctx
758 .get_spec_effective_from("checkout", None)
759 .expect("checkout spec should be present");
760
761 let mut sources = HashMap::new();
762 sources.insert("test.lemma".to_string(), code.to_string());
763
764 let result = plan(&ctx, sources);
765 assert!(
766 result.global_errors.is_empty(),
767 "No global errors expected, got: {:?}",
768 result.global_errors
769 );
770
771 let checkout_result = result
772 .per_spec
773 .iter()
774 .find(|r| Arc::ptr_eq(&r.spec, &checkout_arc))
775 .expect("checkout result should exist");
776 assert!(
777 checkout_result.errors.is_empty(),
778 "No checkout planning errors expected, got: {:?}",
779 checkout_result.errors
780 );
781 assert!(
782 !checkout_result.plans.is_empty(),
783 "checkout should produce at least one plan"
784 );
785 let execution_plan = &checkout_result.plans[0];
786
787 let local_type = execution_plan
788 .facts
789 .get(&FactPath::new(vec![], "local_price".to_string()))
790 .and_then(|d| d.schema_type())
791 .expect("local_price should have schema type");
792 let imported_type = execution_plan
793 .facts
794 .get(&FactPath::new(vec![], "imported_price".to_string()))
795 .and_then(|d| d.schema_type())
796 .expect("imported_price should have schema type");
797
798 match &local_type.extends {
799 TypeExtends::Custom {
800 defining_spec: TypeDefiningSpec::Local,
801 ..
802 } => {}
803 other => panic!(
804 "local_price should resolve as local defining_spec, got {:?}",
805 other
806 ),
807 }
808
809 match &imported_type.extends {
810 TypeExtends::Custom {
811 defining_spec: TypeDefiningSpec::Import { spec, .. },
812 ..
813 } => {
814 assert!(
815 Arc::ptr_eq(spec, &examples_arc),
816 "imported_price should point to resolved 'examples' spec arc"
817 );
818 }
819 other => panic!(
820 "imported_price should resolve as import defining_spec, got {:?}",
821 other
822 ),
823 }
824 }
825
826 #[test]
827 fn test_plan_with_registry_style_spec_names() {
828 let source = r#"spec @user/workspace/somespec
829fact quantity: 10
830
831spec user/workspace/example
832fact inventory: spec @user/workspace/somespec
833rule total_quantity: inventory.quantity"#;
834
835 let specs = parse(source, "registry_bundle.lemma", &ResourceLimits::default())
836 .unwrap()
837 .specs;
838 assert_eq!(specs.len(), 2);
839
840 let example_spec = specs
841 .iter()
842 .find(|d| d.name == "user/workspace/example")
843 .expect("should find user/workspace/example");
844
845 let mut sources = HashMap::new();
846 sources.insert("registry_bundle.lemma".to_string(), source.to_string());
847
848 let result = plan_single(example_spec, &specs, sources);
849 assert!(
850 result.is_ok(),
851 "Planning with @... spec names should succeed: {:?}",
852 result.err()
853 );
854 }
855
856 #[test]
857 fn test_multiple_independent_errors_are_all_reported() {
858 let source = r#"spec demo
861type money from nonexistent_type_source
862fact helper: spec nonexistent_spec
863fact price: 10
864rule total: helper.value + price"#;
865
866 let specs = parse(source, "test.lemma", &ResourceLimits::default())
867 .unwrap()
868 .specs;
869
870 let mut sources = HashMap::new();
871 sources.insert("test.lemma".to_string(), source.to_string());
872
873 let result = plan_single(&specs[0], &specs, sources);
874 assert!(result.is_err(), "Planning should fail with multiple errors");
875
876 let errors = result.unwrap_err();
877 let all_messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
878 let combined = all_messages.join("\n");
879
880 assert!(
881 combined.contains("nonexistent_type_source"),
882 "Should report type import error for 'nonexistent_type_source'. Got:\n{}",
883 combined
884 );
885
886 assert!(
888 combined.contains("nonexistent_spec"),
889 "Should report spec reference error for 'nonexistent_spec'. Got:\n{}",
890 combined
891 );
892
893 assert!(
895 errors.len() >= 2,
896 "Expected at least 2 errors, got {}: {}",
897 errors.len(),
898 combined
899 );
900 }
901
902 #[test]
903 fn test_type_error_does_not_suppress_cross_spec_fact_error() {
904 let source = r#"spec demo
908type currency from missing_spec
909fact ext: spec also_missing
910rule val: ext.some_fact"#;
911
912 let specs = parse(source, "test.lemma", &ResourceLimits::default())
913 .unwrap()
914 .specs;
915
916 let mut sources = HashMap::new();
917 sources.insert("test.lemma".to_string(), source.to_string());
918
919 let result = plan_single(&specs[0], &specs, sources);
920 assert!(result.is_err());
921
922 let errors = result.unwrap_err();
923 let combined: String = errors
924 .iter()
925 .map(|e| e.to_string())
926 .collect::<Vec<_>>()
927 .join("\n");
928
929 assert!(
930 combined.contains("missing_spec"),
931 "Should report type import error about 'missing_spec'. Got:\n{}",
932 combined
933 );
934
935 assert!(
937 combined.contains("also_missing"),
938 "Should report error about 'also_missing'. Got:\n{}",
939 combined
940 );
941 }
942
943 #[test]
944 fn test_spec_order_includes_fact_type_declaration_from_edges() {
945 let source = r#"spec dep
946type money: number
947fact x: [money]
948
949spec consumer
950fact imported_amount: [money from dep]
951rule passthrough: imported_amount"#;
952 let specs = parse(source, "test.lemma", &ResourceLimits::default())
953 .unwrap()
954 .specs;
955
956 let mut ctx = Context::new();
957 for spec in &specs {
958 ctx.insert_spec(Arc::new(spec.clone()), spec.from_registry)
959 .expect("insert spec");
960 }
961
962 let ordered = order_specs_for_planning_graph(ctx.iter().collect())
963 .expect("spec order should succeed");
964 let ordered_names: Vec<String> = ordered.iter().map(|s| s.name.clone()).collect();
965 let dep_idx = ordered_names
966 .iter()
967 .position(|n| n == "dep")
968 .expect("dep must exist");
969 let consumer_idx = ordered_names
970 .iter()
971 .position(|n| n == "consumer")
972 .expect("consumer must exist");
973 assert!(
974 dep_idx < consumer_idx,
975 "dependency must be planned before dependent. order={:?}",
976 ordered_names
977 );
978 }
979
980 #[test]
981 fn test_spec_dependency_cycle_returns_global_error_and_aborts() {
982 let source = r#"spec a
983fact dep_b: spec b
984
985spec b
986fact imported_value: [amount from a]
987"#;
988 let specs = parse(source, "test.lemma", &ResourceLimits::default())
989 .unwrap()
990 .specs;
991
992 let mut ctx = Context::new();
993 for spec in &specs {
994 ctx.insert_spec(Arc::new(spec.clone()), spec.from_registry)
995 .expect("insert spec");
996 }
997
998 let result = plan(&ctx, HashMap::new());
999 assert!(
1000 result
1001 .global_errors
1002 .iter()
1003 .any(|e| e.to_string().contains("Spec dependency cycle")),
1004 "expected global cycle error, got {:?}",
1005 result
1006 .global_errors
1007 .iter()
1008 .map(|e| e.to_string())
1009 .collect::<Vec<_>>()
1010 );
1011 assert!(
1012 result.per_spec.is_empty(),
1013 "planning should abort before per-spec planning when cycle exists"
1014 );
1015 }
1016}