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