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