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