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