1pub mod execution_plan;
14pub mod fingerprint;
15pub mod graph;
16pub mod semantics;
17pub mod slice_interface;
18pub mod temporal;
19pub mod types;
20pub mod validation;
21use crate::engine::Context;
22use crate::parsing::ast::{DateTimeValue, FactValue as ParsedFactValue, LemmaSpec, TypeDef};
23use crate::Error;
24pub use execution_plan::{Branch, ExecutableRule, ExecutionPlan, SpecId, SpecSchema};
25pub use semantics::{
26 is_same_spec, negated_comparison, ArithmeticComputation, ComparisonComputation, Expression,
27 ExpressionKind, Fact, FactData, FactPath, FactValue, LemmaType, LiteralValue,
28 LogicalComputation, MathematicalComputation, NegationType, PathSegment, RulePath, Source, Span,
29 TypeDefiningSpec, TypeExtends, ValueKind, VetoExpression,
30};
31use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
32use std::sync::Arc;
33
34#[derive(Debug, Default, Clone)]
37pub struct PlanHashRegistry {
38 by_slice: BTreeMap<(String, Option<DateTimeValue>), String>,
39 by_pin: BTreeMap<(String, String), Arc<LemmaSpec>>,
40}
41
42impl PlanHashRegistry {
43 pub(crate) fn insert(
44 &mut self,
45 spec: &Arc<LemmaSpec>,
46 slice_from: Option<DateTimeValue>,
47 hash: String,
48 ) {
49 let hash_lower = hash.trim().to_ascii_lowercase();
50 self.by_slice
51 .insert((spec.name.clone(), slice_from), hash_lower.clone());
52 self.by_pin
53 .insert((spec.name.clone(), hash_lower), Arc::clone(spec));
54 }
55
56 pub(crate) fn get_by_slice(
58 &self,
59 spec_name: &str,
60 slice_from: &Option<DateTimeValue>,
61 ) -> Option<&str> {
62 self.by_slice
63 .get(&(spec_name.to_string(), slice_from.clone()))
64 .map(|s| s.as_str())
65 }
66
67 pub(crate) fn get_by_pin(&self, spec_name: &str, hash: &str) -> Option<&Arc<LemmaSpec>> {
69 let key = (spec_name.to_string(), hash.trim().to_ascii_lowercase());
70 self.by_pin.get(&key)
71 }
72}
73
74pub(crate) fn validate_effective_for_pin(
78 spec_ref: &crate::parsing::ast::SpecRef,
79 resolved_spec: &Arc<LemmaSpec>,
80 context: &Context,
81) -> Option<Error> {
82 let effective = spec_ref.effective.as_ref()?;
83 let (_, eff_to) = context.effective_range(resolved_spec);
84 let from_ok = resolved_spec
85 .effective_from()
86 .map(|f| *effective >= *f)
87 .unwrap_or(true);
88 let to_ok = eff_to.as_ref().map(|t| *effective < *t).unwrap_or(true);
89 if from_ok && to_ok {
90 return None;
91 }
92 Some(Error::validation(
93 format!(
94 "Effective {} is outside the temporal range of pinned spec '{}~{}' ([{}, {}))",
95 effective,
96 spec_ref.name,
97 spec_ref.hash_pin.as_deref().unwrap_or("?"),
98 resolved_spec
99 .effective_from()
100 .map_or("-inf".to_string(), |d| d.to_string()),
101 eff_to.map_or("+inf".to_string(), |d| d.to_string()),
102 ),
103 None,
104 Some("The effective datetime must fall within the pinned version's temporal range"),
105 ))
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
109struct SpecName(String);
110
111impl SpecName {
112 fn new(name: impl Into<String>) -> Self {
113 Self(name.into())
114 }
115}
116
117impl std::fmt::Display for SpecName {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 write!(f, "{}", self.0)
120 }
121}
122
123fn collect_spec_dependencies(
124 spec: &LemmaSpec,
125 known_names: &BTreeSet<SpecName>,
126) -> BTreeSet<SpecName> {
127 let mut deps = BTreeSet::new();
128 let current = SpecName::new(spec.name.clone());
129
130 for fact in &spec.facts {
131 match &fact.value {
132 ParsedFactValue::SpecReference(r) => {
133 let dep = SpecName::new(r.name.clone());
134 if dep != current && known_names.contains(&dep) {
135 deps.insert(dep);
136 }
137 }
138 ParsedFactValue::TypeDeclaration {
139 from: Some(from_ref),
140 ..
141 } => {
142 let dep = SpecName::new(from_ref.name.clone());
143 if dep != current && known_names.contains(&dep) {
144 deps.insert(dep);
145 }
146 }
147 _ => {}
148 }
149 }
150
151 for type_def in &spec.types {
152 if let TypeDef::Import { from, .. } = type_def {
153 let dep = SpecName::new(from.name.clone());
154 if dep != current && known_names.contains(&dep) {
155 deps.insert(dep);
156 }
157 }
158 }
159
160 deps
161}
162
163pub(crate) fn order_specs_for_planning_graph(
166 specs: Vec<Arc<LemmaSpec>>,
167) -> Result<Vec<Arc<LemmaSpec>>, Vec<Error>> {
168 let all_names: BTreeSet<SpecName> = specs
169 .iter()
170 .map(|s| SpecName::new(s.name.clone()))
171 .collect();
172
173 let mut deps_by_name: BTreeMap<SpecName, BTreeSet<SpecName>> = BTreeMap::new();
174 for name in &all_names {
175 deps_by_name.insert(name.clone(), BTreeSet::new());
176 }
177 for spec in &specs {
178 let deps = collect_spec_dependencies(spec, &all_names);
179 deps_by_name.insert(SpecName::new(spec.name.clone()), deps);
180 }
181
182 let mut in_degree: BTreeMap<SpecName, usize> = BTreeMap::new();
184 for (name, deps) in &deps_by_name {
185 in_degree.insert(name.clone(), deps.len());
186 }
187
188 let mut dependents: BTreeMap<SpecName, BTreeSet<SpecName>> = BTreeMap::new();
190 for name in &all_names {
191 dependents.insert(name.clone(), BTreeSet::new());
192 }
193 for (name, deps) in &deps_by_name {
194 for dep in deps {
195 if let Some(children) = dependents.get_mut(dep) {
196 children.insert(name.clone());
197 }
198 }
199 }
200
201 let mut queue: VecDeque<SpecName> = VecDeque::new();
202 for (name, degree) in &in_degree {
203 if *degree == 0 {
204 queue.push_back(name.clone());
205 }
206 }
207
208 let mut ordered_names: Vec<SpecName> = Vec::new();
209 while let Some(name) = queue.pop_front() {
210 ordered_names.push(name.clone());
211 if let Some(children) = dependents.get(&name) {
212 for child in children {
213 if let Some(degree) = in_degree.get_mut(child) {
214 *degree -= 1;
215 if *degree == 0 {
216 queue.push_back(child.clone());
217 }
218 }
219 }
220 }
221 }
222
223 if ordered_names.len() != all_names.len() {
224 let mut cycle_nodes: Vec<SpecName> = in_degree
225 .iter()
226 .filter_map(|(name, degree)| {
227 if *degree > 0 {
228 Some(name.clone())
229 } else {
230 None
231 }
232 })
233 .collect();
234 cycle_nodes.sort();
235 let cycle_path = if cycle_nodes.len() > 1 {
236 let mut path = cycle_nodes.clone();
237 path.push(cycle_nodes[0].clone());
238 path.iter()
239 .map(std::string::ToString::to_string)
240 .collect::<Vec<_>>()
241 .join(" -> ")
242 } else {
243 cycle_nodes
244 .iter()
245 .map(std::string::ToString::to_string)
246 .collect::<Vec<_>>()
247 .join(" -> ")
248 };
249 return Err(vec![Error::validation(
250 format!("Spec dependency cycle: {}", cycle_path),
251 None,
252 None::<String>,
253 )]);
254 }
255
256 let mut by_name: HashMap<SpecName, Vec<Arc<LemmaSpec>>> = HashMap::new();
257 for s in specs {
258 by_name
259 .entry(SpecName::new(s.name.clone()))
260 .or_default()
261 .push(s);
262 }
263 for v in by_name.values_mut() {
264 v.sort_by(|a, b| a.effective_from().cmp(&b.effective_from()));
265 }
266
267 let mut out = Vec::new();
268 for name in ordered_names {
269 if let Some(mut vec) = by_name.remove(&name) {
270 out.append(&mut vec);
271 }
272 }
273 Ok(out)
274}
275
276#[derive(Debug, Clone)]
278pub struct SpecPlanningResult {
279 pub spec: Arc<LemmaSpec>,
281 pub plans: Vec<ExecutionPlan>,
283 pub errors: Vec<Error>,
285}
286
287#[derive(Debug, Clone)]
289pub struct PlanningResult {
290 pub per_spec: Vec<SpecPlanningResult>,
292 pub global_errors: Vec<Error>,
294 pub plan_hash_registry: PlanHashRegistry,
296}
297
298pub fn plan(context: &Context, sources: HashMap<String, String>) -> PlanningResult {
308 let mut global_errors: Vec<Error> = Vec::new();
309 global_errors.extend(temporal::validate_temporal_coverage(context));
310
311 let all_specs: Vec<_> = match order_specs_for_planning_graph(context.iter().collect()) {
312 Ok(specs) => specs,
313 Err(mut cycle_errors) => {
314 global_errors.append(&mut cycle_errors);
315 return PlanningResult {
316 per_spec: Vec::new(),
317 global_errors,
318 plan_hash_registry: PlanHashRegistry::default(),
319 };
320 }
321 };
322
323 let mut per_spec: Vec<SpecPlanningResult> = Vec::new();
324 let mut plan_hashes = PlanHashRegistry::default();
325
326 for spec_arc in &all_specs {
327 let slices = temporal::compute_temporal_slices(spec_arc, context);
328 let mut spec_plans: Vec<ExecutionPlan> = Vec::new();
329 let mut spec_errors: Vec<Error> = Vec::new();
330 let mut slice_resolved_types: Vec<HashMap<Arc<LemmaSpec>, types::ResolvedSpecTypes>> =
331 Vec::new();
332
333 for slice in &slices {
334 match graph::Graph::build(
335 spec_arc,
336 context,
337 sources.clone(),
338 slice.from.clone(),
339 &plan_hashes,
340 ) {
341 Ok((graph, slice_types)) => {
342 let execution_plan = execution_plan::build_execution_plan(
343 &graph,
344 &slice_types,
345 slice.from.clone(),
346 slice.to.clone(),
347 );
348 let value_errors =
349 execution_plan::validate_literal_facts_against_types(&execution_plan);
350 if value_errors.is_empty() {
351 let hash = execution_plan.plan_hash();
352 plan_hashes.insert(spec_arc, slice.from.clone(), hash);
353 spec_plans.push(execution_plan);
354 } else {
355 spec_errors.extend(value_errors);
356 }
357 slice_resolved_types.push(slice_types);
358 }
359 Err(build_errors) => {
360 spec_errors.extend(build_errors);
361 }
362 }
363 }
364
365 if spec_errors.is_empty() && spec_plans.len() > 1 {
366 spec_errors.extend(slice_interface::validate_slice_interfaces(
367 spec_arc,
368 &spec_plans,
369 &slice_resolved_types,
370 ));
371 }
372
373 per_spec.push(SpecPlanningResult {
374 spec: Arc::clone(spec_arc),
375 plans: spec_plans,
376 errors: spec_errors,
377 });
378 }
379
380 PlanningResult {
381 per_spec,
382 global_errors,
383 plan_hash_registry: plan_hashes,
384 }
385}
386
387#[cfg(test)]
392mod internal_tests {
393 use super::{order_specs_for_planning_graph, plan};
394 use crate::engine::Context;
395 use crate::parsing::ast::{FactValue, LemmaFact, LemmaSpec, ParentType, Reference, Span};
396 use crate::parsing::source::Source;
397 use crate::planning::execution_plan::ExecutionPlan;
398 use crate::planning::semantics::{FactPath, PathSegment, TypeDefiningSpec, TypeExtends};
399 use crate::{parse, Error, ResourceLimits};
400 use std::collections::HashMap;
401 use std::sync::Arc;
402
403 fn plan_single(
405 main_spec: &LemmaSpec,
406 all_specs: &[LemmaSpec],
407 sources: HashMap<String, String>,
408 ) -> Result<ExecutionPlan, Vec<Error>> {
409 let mut ctx = Context::new();
410 for spec in all_specs {
411 if let Err(e) = ctx.insert_spec(Arc::new(spec.clone()), spec.from_registry) {
412 return Err(vec![e]);
413 }
414 }
415 let main_spec_arc = ctx
416 .get_spec_effective_from(main_spec.name.as_str(), main_spec.effective_from())
417 .expect("main_spec must be in all_specs");
418 let result = plan(&ctx, sources);
419 let all_errors: Vec<Error> = result
420 .global_errors
421 .into_iter()
422 .chain(
423 result
424 .per_spec
425 .iter()
426 .flat_map(|r| r.errors.clone())
427 .collect::<Vec<_>>(),
428 )
429 .collect();
430 if !all_errors.is_empty() {
431 return Err(all_errors);
432 }
433 match result
434 .per_spec
435 .into_iter()
436 .find(|r| Arc::ptr_eq(&r.spec, &main_spec_arc))
437 {
438 Some(spec_result) if !spec_result.plans.is_empty() => {
439 let mut plans = spec_result.plans;
440 Ok(plans.remove(0))
441 }
442 _ => Err(vec![Error::validation(
443 format!("No execution plan produced for spec '{}'", main_spec.name),
444 Some(crate::planning::semantics::Source::new(
445 "<test>",
446 crate::planning::semantics::Span {
447 start: 0,
448 end: 0,
449 line: 1,
450 col: 0,
451 },
452 )),
453 None::<String>,
454 )]),
455 }
456 }
457
458 #[test]
459 fn test_basic_validation() {
460 let input = r#"spec person
461fact name: "John"
462fact age: 25
463rule is_adult: age >= 18"#;
464
465 let specs = parse(input, "test.lemma", &ResourceLimits::default())
466 .unwrap()
467 .specs;
468
469 let mut sources = HashMap::new();
470 sources.insert("test.lemma".to_string(), input.to_string());
471
472 for spec in &specs {
473 let result = plan_single(spec, &specs, sources.clone());
474 assert!(
475 result.is_ok(),
476 "Basic validation should pass: {:?}",
477 result.err()
478 );
479 }
480 }
481
482 #[test]
483 fn test_duplicate_facts() {
484 let input = r#"spec person
485fact name: "John"
486fact name: "Jane""#;
487
488 let specs = parse(input, "test.lemma", &ResourceLimits::default())
489 .unwrap()
490 .specs;
491
492 let mut sources = HashMap::new();
493 sources.insert("test.lemma".to_string(), input.to_string());
494
495 let result = plan_single(&specs[0], &specs, sources);
496
497 assert!(
498 result.is_err(),
499 "Duplicate facts should cause validation error"
500 );
501 let errors = result.unwrap_err();
502 let error_string = errors
503 .iter()
504 .map(|e| e.to_string())
505 .collect::<Vec<_>>()
506 .join(", ");
507 assert!(
508 error_string.contains("Duplicate fact"),
509 "Error should mention duplicate fact: {}",
510 error_string
511 );
512 assert!(error_string.contains("name"));
513 }
514
515 #[test]
516 fn test_duplicate_rules() {
517 let input = r#"spec person
518fact age: 25
519rule is_adult: age >= 18
520rule is_adult: age >= 21"#;
521
522 let specs = parse(input, "test.lemma", &ResourceLimits::default())
523 .unwrap()
524 .specs;
525
526 let mut sources = HashMap::new();
527 sources.insert("test.lemma".to_string(), input.to_string());
528
529 let result = plan_single(&specs[0], &specs, sources);
530
531 assert!(
532 result.is_err(),
533 "Duplicate rules should cause validation error"
534 );
535 let errors = result.unwrap_err();
536 let error_string = errors
537 .iter()
538 .map(|e| e.to_string())
539 .collect::<Vec<_>>()
540 .join(", ");
541 assert!(
542 error_string.contains("Duplicate rule"),
543 "Error should mention duplicate rule: {}",
544 error_string
545 );
546 assert!(error_string.contains("is_adult"));
547 }
548
549 #[test]
550 fn test_circular_dependency() {
551 let input = r#"spec test
552rule a: b
553rule b: a"#;
554
555 let specs = parse(input, "test.lemma", &ResourceLimits::default())
556 .unwrap()
557 .specs;
558
559 let mut sources = HashMap::new();
560 sources.insert("test.lemma".to_string(), input.to_string());
561
562 let result = plan_single(&specs[0], &specs, sources);
563
564 assert!(
565 result.is_err(),
566 "Circular dependency should cause validation error"
567 );
568 let errors = result.unwrap_err();
569 let error_string = errors
570 .iter()
571 .map(|e| e.to_string())
572 .collect::<Vec<_>>()
573 .join(", ");
574 assert!(error_string.contains("Circular dependency") || error_string.contains("circular"));
575 }
576
577 #[test]
578 fn test_unified_references_work() {
579 let input = r#"spec test
580fact age: 25
581rule is_adult: age >= 18
582rule test1: age
583rule test2: is_adult"#;
584
585 let specs = parse(input, "test.lemma", &ResourceLimits::default())
586 .unwrap()
587 .specs;
588
589 let mut sources = HashMap::new();
590 sources.insert("test.lemma".to_string(), input.to_string());
591
592 let result = plan_single(&specs[0], &specs, sources);
593
594 assert!(
595 result.is_ok(),
596 "Unified references should work: {:?}",
597 result.err()
598 );
599 }
600
601 #[test]
602 fn test_multiple_specs() {
603 let input = r#"spec person
604fact name: "John"
605fact age: 25
606
607spec company
608fact name: "Acme Corp"
609fact employee: spec person"#;
610
611 let specs = parse(input, "test.lemma", &ResourceLimits::default())
612 .unwrap()
613 .specs;
614
615 let mut sources = HashMap::new();
616 sources.insert("test.lemma".to_string(), input.to_string());
617
618 let result = plan_single(&specs[0], &specs, sources);
619
620 assert!(
621 result.is_ok(),
622 "Multiple specs should validate successfully: {:?}",
623 result.err()
624 );
625 }
626
627 #[test]
628 fn test_invalid_spec_reference() {
629 let input = r#"spec person
630fact name: "John"
631fact contract: spec nonexistent"#;
632
633 let specs = parse(input, "test.lemma", &ResourceLimits::default())
634 .unwrap()
635 .specs;
636
637 let mut sources = HashMap::new();
638 sources.insert("test.lemma".to_string(), input.to_string());
639
640 let result = plan_single(&specs[0], &specs, sources);
641
642 assert!(
643 result.is_err(),
644 "Invalid spec reference should cause validation error"
645 );
646 let errors = result.unwrap_err();
647 let error_string = errors
648 .iter()
649 .map(|e| e.to_string())
650 .collect::<Vec<_>>()
651 .join(", ");
652 assert!(
653 error_string.contains("not found")
654 || error_string.contains("Spec")
655 || (error_string.contains("nonexistent") && error_string.contains("depends")),
656 "Error should mention spec reference issue: {}",
657 error_string
658 );
659 assert!(error_string.contains("nonexistent"));
660 }
661
662 #[test]
663 fn test_type_declaration_empty_base_returns_lemma_error() {
664 let mut spec = LemmaSpec::new("test".to_string());
665 let source = Source::new(
666 "test.lemma",
667 Span {
668 start: 0,
669 end: 10,
670 line: 1,
671 col: 0,
672 },
673 );
674 spec.facts.push(LemmaFact::new(
675 Reference {
676 segments: vec![],
677 name: "x".to_string(),
678 },
679 FactValue::TypeDeclaration {
680 base: ParentType::Custom {
681 name: String::new(),
682 },
683 constraints: None,
684 from: None,
685 },
686 source,
687 ));
688
689 let specs = vec![spec.clone()];
690 let mut sources = HashMap::new();
691 sources.insert(
692 "test.lemma".to_string(),
693 "spec test\nfact x: []".to_string(),
694 );
695
696 let result = plan_single(&spec, &specs, sources);
697 assert!(
698 result.is_err(),
699 "TypeDeclaration with empty base should fail planning"
700 );
701 let errors = result.unwrap_err();
702 let combined = errors
703 .iter()
704 .map(|e| e.to_string())
705 .collect::<Vec<_>>()
706 .join("\n");
707 assert!(
708 combined.contains("TypeDeclaration base cannot be empty"),
709 "Error should mention empty base; got: {}",
710 combined
711 );
712 }
713
714 #[test]
715 fn test_fact_binding_with_custom_type_resolves_in_correct_spec_context() {
716 let code = r#"
727spec one
728type money: number
729fact x: [money]
730
731spec two
732fact one: spec one
733fact one.x: 7
734rule getx: one.x
735"#;
736
737 let specs = parse(code, "test.lemma", &ResourceLimits::default())
738 .unwrap()
739 .specs;
740 let spec_two = specs.iter().find(|d| d.name == "two").unwrap();
741
742 let mut sources = HashMap::new();
743 sources.insert("test.lemma".to_string(), code.to_string());
744 let execution_plan =
745 plan_single(spec_two, &specs, sources).expect("planning should succeed");
746
747 let one_x_path = FactPath {
749 segments: vec![PathSegment {
750 fact: "one".to_string(),
751 spec: "one".to_string(),
752 }],
753 fact: "x".to_string(),
754 };
755
756 let one_x_type = execution_plan
757 .facts
758 .get(&one_x_path)
759 .and_then(|d| d.schema_type())
760 .expect("one.x should have a resolved type");
761
762 assert_eq!(
763 one_x_type.name(),
764 "money",
765 "one.x should have type 'money', got: {}",
766 one_x_type.name()
767 );
768 assert!(one_x_type.is_number(), "money should be number-based");
769 }
770
771 #[test]
772 fn test_fact_type_declaration_from_spec_has_import_defining_spec() {
773 let code = r#"
774spec examples
775type money: scale
776 -> unit eur 1.00
777
778spec checkout
779type money: scale
780 -> unit eur 1.00
781fact local_price: [money]
782fact imported_price: [money from examples]
783"#;
784
785 let specs = parse(code, "test.lemma", &ResourceLimits::default())
786 .unwrap()
787 .specs;
788
789 let mut ctx = Context::new();
790 for spec in &specs {
791 ctx.insert_spec(Arc::new(spec.clone()), spec.from_registry)
792 .expect("insert spec");
793 }
794
795 let examples_arc = ctx
796 .get_spec_effective_from("examples", None)
797 .expect("examples spec should be present");
798 let checkout_arc = ctx
799 .get_spec_effective_from("checkout", None)
800 .expect("checkout spec should be present");
801
802 let mut sources = HashMap::new();
803 sources.insert("test.lemma".to_string(), code.to_string());
804
805 let result = plan(&ctx, sources);
806 assert!(
807 result.global_errors.is_empty(),
808 "No global errors expected, got: {:?}",
809 result.global_errors
810 );
811
812 let checkout_result = result
813 .per_spec
814 .iter()
815 .find(|r| Arc::ptr_eq(&r.spec, &checkout_arc))
816 .expect("checkout result should exist");
817 assert!(
818 checkout_result.errors.is_empty(),
819 "No checkout planning errors expected, got: {:?}",
820 checkout_result.errors
821 );
822 assert!(
823 !checkout_result.plans.is_empty(),
824 "checkout should produce at least one plan"
825 );
826 let execution_plan = &checkout_result.plans[0];
827
828 let local_type = execution_plan
829 .facts
830 .get(&FactPath::new(vec![], "local_price".to_string()))
831 .and_then(|d| d.schema_type())
832 .expect("local_price should have schema type");
833 let imported_type = execution_plan
834 .facts
835 .get(&FactPath::new(vec![], "imported_price".to_string()))
836 .and_then(|d| d.schema_type())
837 .expect("imported_price should have schema type");
838
839 match &local_type.extends {
840 TypeExtends::Custom {
841 defining_spec: TypeDefiningSpec::Local,
842 ..
843 } => {}
844 other => panic!(
845 "local_price should resolve as local defining_spec, got {:?}",
846 other
847 ),
848 }
849
850 match &imported_type.extends {
851 TypeExtends::Custom {
852 defining_spec: TypeDefiningSpec::Import { spec, .. },
853 ..
854 } => {
855 assert!(
856 Arc::ptr_eq(spec, &examples_arc),
857 "imported_price should point to resolved 'examples' spec arc"
858 );
859 }
860 other => panic!(
861 "imported_price should resolve as import defining_spec, got {:?}",
862 other
863 ),
864 }
865 }
866
867 #[test]
868 fn test_plan_with_registry_style_spec_names() {
869 let source = r#"spec @user/workspace/somespec
870fact quantity: 10
871
872spec user/workspace/example
873fact inventory: spec @user/workspace/somespec
874rule total_quantity: inventory.quantity"#;
875
876 let specs = parse(source, "registry_bundle.lemma", &ResourceLimits::default())
877 .unwrap()
878 .specs;
879 assert_eq!(specs.len(), 2);
880
881 let example_spec = specs
882 .iter()
883 .find(|d| d.name == "user/workspace/example")
884 .expect("should find user/workspace/example");
885
886 let mut sources = HashMap::new();
887 sources.insert("registry_bundle.lemma".to_string(), source.to_string());
888
889 let result = plan_single(example_spec, &specs, sources);
890 assert!(
891 result.is_ok(),
892 "Planning with @... spec names should succeed: {:?}",
893 result.err()
894 );
895 }
896
897 #[test]
898 fn test_multiple_independent_errors_are_all_reported() {
899 let source = r#"spec demo
902type money from nonexistent_type_source
903fact helper: spec nonexistent_spec
904fact price: 10
905rule total: helper.value + price"#;
906
907 let specs = parse(source, "test.lemma", &ResourceLimits::default())
908 .unwrap()
909 .specs;
910
911 let mut sources = HashMap::new();
912 sources.insert("test.lemma".to_string(), source.to_string());
913
914 let result = plan_single(&specs[0], &specs, sources);
915 assert!(result.is_err(), "Planning should fail with multiple errors");
916
917 let errors = result.unwrap_err();
918 let all_messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
919 let combined = all_messages.join("\n");
920
921 assert!(
922 combined.contains("nonexistent_type_source"),
923 "Should report type import error for 'nonexistent_type_source'. Got:\n{}",
924 combined
925 );
926
927 assert!(
929 combined.contains("nonexistent_spec"),
930 "Should report spec reference error for 'nonexistent_spec'. Got:\n{}",
931 combined
932 );
933
934 assert!(
936 errors.len() >= 2,
937 "Expected at least 2 errors, got {}: {}",
938 errors.len(),
939 combined
940 );
941 }
942
943 #[test]
944 fn test_type_error_does_not_suppress_cross_spec_fact_error() {
945 let source = r#"spec demo
949type currency from missing_spec
950fact ext: spec also_missing
951rule val: ext.some_fact"#;
952
953 let specs = parse(source, "test.lemma", &ResourceLimits::default())
954 .unwrap()
955 .specs;
956
957 let mut sources = HashMap::new();
958 sources.insert("test.lemma".to_string(), source.to_string());
959
960 let result = plan_single(&specs[0], &specs, sources);
961 assert!(result.is_err());
962
963 let errors = result.unwrap_err();
964 let combined: String = errors
965 .iter()
966 .map(|e| e.to_string())
967 .collect::<Vec<_>>()
968 .join("\n");
969
970 assert!(
971 combined.contains("missing_spec"),
972 "Should report type import error about 'missing_spec'. Got:\n{}",
973 combined
974 );
975
976 assert!(
978 combined.contains("also_missing"),
979 "Should report error about 'also_missing'. Got:\n{}",
980 combined
981 );
982 }
983
984 #[test]
985 fn test_spec_order_includes_fact_type_declaration_from_edges() {
986 let source = r#"spec dep
987type money: number
988fact x: [money]
989
990spec consumer
991fact imported_amount: [money from dep]
992rule passthrough: imported_amount"#;
993 let specs = parse(source, "test.lemma", &ResourceLimits::default())
994 .unwrap()
995 .specs;
996
997 let mut ctx = Context::new();
998 for spec in &specs {
999 ctx.insert_spec(Arc::new(spec.clone()), spec.from_registry)
1000 .expect("insert spec");
1001 }
1002
1003 let ordered = order_specs_for_planning_graph(ctx.iter().collect())
1004 .expect("spec order should succeed");
1005 let ordered_names: Vec<String> = ordered.iter().map(|s| s.name.clone()).collect();
1006 let dep_idx = ordered_names
1007 .iter()
1008 .position(|n| n == "dep")
1009 .expect("dep must exist");
1010 let consumer_idx = ordered_names
1011 .iter()
1012 .position(|n| n == "consumer")
1013 .expect("consumer must exist");
1014 assert!(
1015 dep_idx < consumer_idx,
1016 "dependency must be planned before dependent. order={:?}",
1017 ordered_names
1018 );
1019 }
1020
1021 #[test]
1022 fn test_spec_dependency_cycle_returns_global_error_and_aborts() {
1023 let source = r#"spec a
1024fact dep_b: spec b
1025
1026spec b
1027fact imported_value: [amount from a]
1028"#;
1029 let specs = parse(source, "test.lemma", &ResourceLimits::default())
1030 .unwrap()
1031 .specs;
1032
1033 let mut ctx = Context::new();
1034 for spec in &specs {
1035 ctx.insert_spec(Arc::new(spec.clone()), spec.from_registry)
1036 .expect("insert spec");
1037 }
1038
1039 let result = plan(&ctx, HashMap::new());
1040 assert!(
1041 result
1042 .global_errors
1043 .iter()
1044 .any(|e| e.to_string().contains("Spec dependency cycle")),
1045 "expected global cycle error, got {:?}",
1046 result
1047 .global_errors
1048 .iter()
1049 .map(|e| e.to_string())
1050 .collect::<Vec<_>>()
1051 );
1052 assert!(
1053 result.per_spec.is_empty(),
1054 "planning should abort before per-spec planning when cycle exists"
1055 );
1056 }
1057
1058 fn has_source_for(plan: &super::execution_plan::ExecutionPlan, name: &str) -> bool {
1063 plan.sources.keys().any(|(n, _)| n == name)
1064 }
1065
1066 #[test]
1067 fn sources_contain_main_and_dep_for_cross_spec_rule_reference() {
1068 let code = r#"
1069spec dep
1070fact x: 10
1071rule val: x
1072
1073spec consumer
1074fact d: spec dep
1075fact d.x: 5
1076rule result: d.val
1077"#;
1078 let specs = parse(code, "test.lemma", &ResourceLimits::default())
1079 .unwrap()
1080 .specs;
1081 let consumer = specs.iter().find(|s| s.name == "consumer").unwrap();
1082
1083 let mut sources = HashMap::new();
1084 sources.insert("test.lemma".to_string(), code.to_string());
1085
1086 let plan = plan_single(consumer, &specs, sources).expect("planning should succeed");
1087
1088 assert_eq!(plan.sources.len(), 2, "main + dep, got: {:?}", plan.sources);
1089 assert!(
1090 has_source_for(&plan, "consumer"),
1091 "sources must include main spec"
1092 );
1093 assert!(
1094 has_source_for(&plan, "dep"),
1095 "sources must include dep spec"
1096 );
1097 }
1098
1099 #[test]
1100 fn sources_contain_only_main_for_standalone_spec() {
1101 let code = r#"
1102spec standalone
1103fact age: 25
1104rule is_adult: age >= 18
1105"#;
1106 let specs = parse(code, "test.lemma", &ResourceLimits::default())
1107 .unwrap()
1108 .specs;
1109
1110 let mut sources = HashMap::new();
1111 sources.insert("test.lemma".to_string(), code.to_string());
1112
1113 let plan = plan_single(&specs[0], &specs, sources).expect("planning should succeed");
1114
1115 assert_eq!(
1116 plan.sources.len(),
1117 1,
1118 "standalone should have only main spec"
1119 );
1120 assert!(has_source_for(&plan, "standalone"));
1121 }
1122
1123 #[test]
1124 fn sources_contain_all_cross_spec_refs() {
1125 let code = r#"
1126spec rates
1127fact base_rate: 0.05
1128rule rate: base_rate
1129
1130spec config
1131fact threshold: 100
1132rule limit: threshold
1133
1134spec calculator
1135fact r: spec rates
1136fact r.base_rate: 0.03
1137fact c: spec config
1138fact c.threshold: 200
1139rule combined: r.rate + c.limit
1140"#;
1141 let specs = parse(code, "test.lemma", &ResourceLimits::default())
1142 .unwrap()
1143 .specs;
1144 let calc = specs.iter().find(|s| s.name == "calculator").unwrap();
1145
1146 let mut sources = HashMap::new();
1147 sources.insert("test.lemma".to_string(), code.to_string());
1148
1149 let plan = plan_single(calc, &specs, sources).expect("planning should succeed");
1150
1151 assert_eq!(
1152 plan.sources.len(),
1153 3,
1154 "calculator + rates + config, got: {:?}",
1155 plan.sources
1156 );
1157 assert!(has_source_for(&plan, "calculator"));
1158 assert!(has_source_for(&plan, "rates"));
1159 assert!(has_source_for(&plan, "config"));
1160 }
1161
1162 #[test]
1163 fn sources_include_spec_ref_even_without_rules() {
1164 let code = r#"
1165spec dep
1166fact x: 10
1167
1168spec consumer
1169fact d: spec dep
1170fact local: 99
1171rule result: local
1172"#;
1173 let specs = parse(code, "test.lemma", &ResourceLimits::default())
1174 .unwrap()
1175 .specs;
1176 let consumer = specs.iter().find(|s| s.name == "consumer").unwrap();
1177
1178 let mut sources = HashMap::new();
1179 sources.insert("test.lemma".to_string(), code.to_string());
1180
1181 let plan = plan_single(consumer, &specs, sources).expect("planning should succeed");
1182
1183 assert_eq!(
1184 plan.sources.len(),
1185 2,
1186 "consumer + dep, got: {:?}",
1187 plan.sources
1188 );
1189 assert!(
1190 has_source_for(&plan, "dep"),
1191 "spec ref dep must be in sources even without rules"
1192 );
1193 }
1194
1195 #[test]
1196 fn sources_round_trip_to_valid_specs() {
1197 let code = r#"
1198spec dep
1199fact x: 42
1200rule val: x
1201
1202spec consumer
1203fact d: spec dep
1204rule result: d.val
1205"#;
1206 let specs = parse(code, "test.lemma", &ResourceLimits::default())
1207 .unwrap()
1208 .specs;
1209 let consumer = specs.iter().find(|s| s.name == "consumer").unwrap();
1210
1211 let mut sources = HashMap::new();
1212 sources.insert("test.lemma".to_string(), code.to_string());
1213
1214 let plan = plan_single(consumer, &specs, sources).expect("planning should succeed");
1215
1216 for ((name, _), source_text) in &plan.sources {
1217 let parsed = parse(source_text, "roundtrip.lemma", &ResourceLimits::default());
1218 assert!(
1219 parsed.is_ok(),
1220 "source for '{}' must re-parse: {:?}\nsource:\n{}",
1221 name,
1222 parsed.err(),
1223 source_text
1224 );
1225 }
1226 }
1227}