Skip to main content

lemma/planning/
mod.rs

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