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