Skip to main content

lemma/
engine.rs

1use crate::evaluation::Evaluator;
2use crate::parsing::ast::{DateTimeValue, LemmaSpec};
3use crate::parsing::EffectiveDate;
4use crate::planning::{LemmaSpecSet, SpecSchema};
5use crate::{parse, Error, ResourceLimits, Response};
6use std::collections::{BTreeMap, HashMap};
7use std::sync::Arc;
8
9#[cfg(not(target_arch = "wasm32"))]
10use std::collections::HashSet;
11#[cfg(not(target_arch = "wasm32"))]
12use std::path::Path;
13
14/// Load failure: errors plus the source files we attempted to load.
15#[derive(Debug, Clone)]
16pub struct Errors {
17    pub errors: Vec<Error>,
18    pub sources: HashMap<String, String>,
19}
20
21impl Errors {
22    /// Iterate over the errors.
23    pub fn iter(&self) -> std::slice::Iter<'_, Error> {
24        self.errors.iter()
25    }
26}
27
28// ─── Spec store with temporal resolution ──────────────────────────────
29
30/// Ordered set of specs grouped into `LemmaSpecSet`s by name.
31///
32/// Specs with the same name are ordered by effective_from.
33/// A spec's temporal end is derived from the next spec's effective_from, or +inf.
34#[derive(Debug, Default)]
35pub struct Context {
36    spec_sets: BTreeMap<String, LemmaSpecSet>,
37}
38
39impl Context {
40    pub fn new() -> Self {
41        Self {
42            spec_sets: BTreeMap::new(),
43        }
44    }
45
46    /// All spec sets (name → specs keyed by effective_from), ordered by spec name.
47    #[must_use]
48    pub fn spec_sets(&self) -> &BTreeMap<String, LemmaSpecSet> {
49        &self.spec_sets
50    }
51
52    pub fn iter(&self) -> impl Iterator<Item = Arc<LemmaSpec>> + '_ {
53        self.spec_sets.values().flat_map(|ss| ss.iter_specs())
54    }
55
56    /// Every loaded spec paired with its half-open
57    /// `[effective_from, effective_to)` validity range.
58    ///
59    /// Iteration order: spec name ascending, then by `effective_from` ascending
60    /// within the same name — identical to [`Self::iter`]. Each tuple is
61    /// `(spec, effective_from, effective_to)`; see
62    /// [`crate::planning::LemmaSpecSet::iter_with_ranges`] for the range
63    /// semantics (the last row of each name has `effective_to = None`).
64    pub fn iter_with_ranges(
65        &self,
66    ) -> impl Iterator<Item = (Arc<LemmaSpec>, Option<DateTimeValue>, Option<DateTimeValue>)> + '_
67    {
68        self.spec_sets
69            .values()
70            .flat_map(|spec_set| spec_set.iter_with_ranges())
71    }
72
73    /// Insert a spec. Set `from_registry` to `true` for pre-fetched registry
74    /// specs; `false` rejects `@`-prefixed spec definitions.
75    ///
76    /// When `from_registry` is true, only `@`-prefixed specs are accepted —
77    /// registry bundles must not introduce bare-named specs into the local namespace.
78    pub fn insert_spec(&mut self, spec: Arc<LemmaSpec>, from_registry: bool) -> Result<(), Error> {
79        if spec.from_registry && !from_registry {
80            return Err(Error::validation_with_context(
81                format!(
82                    "Spec '{}' uses '@' registry prefix, which is reserved for dependencies",
83                    spec.name
84                ),
85                None,
86                Some("Remove the '@' prefix, or load this file as a dependency."),
87                Some(Arc::clone(&spec)),
88                None,
89            ));
90        }
91
92        if from_registry && !spec.from_registry {
93            return Err(Error::validation_with_context(
94                format!(
95                    "Registry bundle contains spec '{}' without '@' prefix; \
96                     all specs in a registry bundle must use '@'-prefixed names \
97                     to avoid conflicts with local specs",
98                    spec.name
99                ),
100                None,
101                Some("Prefix the spec name with '@' (e.g. spec @org/project/name)."),
102                Some(Arc::clone(&spec)),
103                None,
104            ));
105        }
106
107        let name = spec.name.clone();
108        if self
109            .spec_sets
110            .get(&name)
111            .is_some_and(|ss| ss.get_exact(spec.effective_from()).is_some())
112        {
113            return Err(Error::validation_with_context(
114                format!(
115                    "Duplicate spec '{}' (same name and effective_from already in context)",
116                    spec.name
117                ),
118                None,
119                None::<String>,
120                Some(Arc::clone(&spec)),
121                None,
122            ));
123        }
124
125        let inserted = self
126            .spec_sets
127            .entry(name.clone())
128            .or_insert_with(|| LemmaSpecSet::new(name))
129            .insert(spec);
130        debug_assert!(inserted);
131        Ok(())
132    }
133
134    pub fn remove_spec(&mut self, spec: &Arc<LemmaSpec>) -> bool {
135        let key = spec.effective_from().cloned();
136        let Some(ss) = self.spec_sets.get_mut(&spec.name) else {
137            return false;
138        };
139        if !ss.remove(key.as_ref()) {
140            return false;
141        }
142        if ss.is_empty() {
143            self.spec_sets.remove(&spec.name);
144        }
145        true
146    }
147
148    #[cfg(test)]
149    pub(crate) fn len(&self) -> usize {
150        self.spec_sets.values().map(LemmaSpecSet::len).sum()
151    }
152}
153
154// ─── Engine ──────────────────────────────────────────────────────────
155
156/// How a single buffer is identified in parse/plan diagnostics and the engine source map.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum SourceType<'a> {
159    /// Path, URI, test name, or any non-empty stable id.
160    Labeled(&'a str),
161    /// No stable path (pasted string, REPL). Stored under [`SourceType::INLINE_KEY`].
162    Inline,
163    // Pre-resolved registry bundle
164    Dependency(&'a str),
165}
166
167impl SourceType<'_> {
168    /// Source map key and span attribute for [`SourceType::Inline`].
169    pub const INLINE_KEY: &'static str = "inline source (no path)";
170
171    fn storage_key(self) -> Result<String, Vec<Error>> {
172        match self {
173            SourceType::Labeled(s) => {
174                if s.trim().is_empty() {
175                    return Err(vec![Error::request(
176                        "source label must be non-empty, or use SourceType::Inline",
177                        None::<String>,
178                    )]);
179                }
180                Ok(s.to_string())
181            }
182            SourceType::Inline => Ok(Self::INLINE_KEY.to_string()),
183            SourceType::Dependency(s) => Ok(s.to_string()),
184        }
185    }
186}
187
188/// Engine for evaluating Lemma rules.
189///
190/// Pure Rust implementation that evaluates Lemma specs directly from the AST.
191/// Uses pre-built execution plans that are self-contained and ready for evaluation.
192///
193/// The engine never performs network calls. External `@...` references must be
194/// pre-resolved before loading — either by including dep files
195/// in the file map or by calling `resolve_registry_references` separately
196/// (e.g. in a `lemma fetch` command).
197pub struct Engine {
198    /// Spec name → resolved plans (ordered by `effective`; slice end from next plan).
199    plan_sets: HashMap<String, crate::planning::ExecutionPlanSet>,
200    specs: Context,
201    sources: HashMap<String, String>,
202    evaluator: Evaluator,
203    limits: ResourceLimits,
204    total_expression_count: usize,
205}
206
207impl Default for Engine {
208    fn default() -> Self {
209        Self {
210            plan_sets: HashMap::new(),
211            specs: Context::new(),
212            sources: HashMap::new(),
213            evaluator: Evaluator,
214            limits: ResourceLimits::default(),
215            total_expression_count: 0,
216        }
217    }
218}
219
220impl Engine {
221    pub fn new() -> Self {
222        Self::default()
223    }
224
225    /// Source code map (attribute -> content). Used for error display.
226    pub fn sources(&self) -> &HashMap<String, String> {
227        &self.sources
228    }
229
230    /// Create an engine with custom resource limits.
231    pub fn with_limits(limits: ResourceLimits) -> Self {
232        Self {
233            plan_sets: HashMap::new(),
234            specs: Context::new(),
235            sources: HashMap::new(),
236            evaluator: Evaluator,
237            limits,
238            total_expression_count: 0,
239        }
240    }
241
242    fn apply_planning_result(&mut self, pr: crate::planning::PlanningResult) {
243        self.plan_sets.clear();
244        for r in &pr.results {
245            self.plan_sets
246                .insert(r.name.clone(), r.execution_plan_set());
247        }
248    }
249
250    /// Load a single spec from source code.
251    /// When `source` is [`SourceType::Dependency`], content is treated as from a registry bundle (`from_registry: true`).
252    pub fn load(&mut self, code: &str, source: SourceType<'_>) -> Result<(), Errors> {
253        let from_registry = matches!(source, SourceType::Dependency(_));
254        let mut files = HashMap::new();
255        let key = source.storage_key().map_err(|errs| Errors {
256            errors: errs,
257            sources: HashMap::new(),
258        })?;
259        files.insert(key, code.to_string());
260        self.add_files_inner(files, from_registry)
261    }
262
263    /// Load .lemma files from paths (files and/or directories). Directories are expanded one level only (direct child .lemma files). Resource limits `max_files`, `max_loaded_bytes`, `max_file_size_bytes` are enforced in [`add_files_inner`].
264    ///
265    /// Set `from_registry` to `true` for pre-fetched registry bundles (same rules as [`Context::insert_spec`] with `from_registry`). Not available on wasm32 (no filesystem).
266    #[cfg(not(target_arch = "wasm32"))]
267    pub fn load_from_paths<P: AsRef<Path>>(
268        &mut self,
269        paths: &[P],
270        from_registry: bool,
271    ) -> Result<(), Errors> {
272        use std::fs;
273
274        let mut files = HashMap::new();
275        let mut seen = HashSet::<String>::new();
276
277        for path in paths {
278            let path = path.as_ref();
279            if path.is_file() {
280                // Skip non-`.lemma` files (extension missing or wrong).
281                if !path.extension().map(|e| e == "lemma").unwrap_or(false) {
282                    continue;
283                }
284                let key = path.display().to_string();
285                if seen.contains(&key) {
286                    continue;
287                }
288                seen.insert(key.clone());
289                let content = fs::read_to_string(path).map_err(|e| Errors {
290                    errors: vec![Error::request(
291                        format!("Cannot read '{}': {}", path.display(), e),
292                        None::<String>,
293                    )],
294                    sources: HashMap::new(),
295                })?;
296                files.insert(key, content);
297            } else if path.is_dir() {
298                let read_dir = fs::read_dir(path).map_err(|e| Errors {
299                    errors: vec![Error::request(
300                        format!("Cannot read directory '{}': {}", path.display(), e),
301                        None::<String>,
302                    )],
303                    sources: HashMap::new(),
304                })?;
305                for entry in read_dir.filter_map(Result::ok) {
306                    let p = entry.path();
307                    if !p.is_file() || !p.extension().map(|e| e == "lemma").unwrap_or(false) {
308                        continue;
309                    }
310                    let key = p.display().to_string();
311                    if seen.contains(&key) {
312                        continue;
313                    }
314                    seen.insert(key.clone());
315                    let Ok(content) = fs::read_to_string(&p) else {
316                        continue;
317                    };
318                    files.insert(key, content);
319                }
320            }
321        }
322
323        self.add_files_inner(files, from_registry)
324    }
325
326    fn add_files_inner(
327        &mut self,
328        files: HashMap<String, String>,
329        from_registry: bool,
330    ) -> Result<(), Errors> {
331        let limits = &self.limits;
332        if files.len() > limits.max_files {
333            return Err(Errors {
334                errors: vec![Error::resource_limit_exceeded(
335                    "max_files",
336                    limits.max_files.to_string(),
337                    files.len().to_string(),
338                    "Reduce the number of paths or files",
339                    None::<crate::Source>,
340                    None,
341                    None,
342                )],
343                sources: files,
344            });
345        }
346        let total_loaded_bytes: usize = files.values().map(|s| s.len()).sum();
347        if total_loaded_bytes > limits.max_loaded_bytes {
348            return Err(Errors {
349                errors: vec![Error::resource_limit_exceeded(
350                    "max_loaded_bytes",
351                    limits.max_loaded_bytes.to_string(),
352                    total_loaded_bytes.to_string(),
353                    "Load fewer or smaller files",
354                    None::<crate::Source>,
355                    None,
356                    None,
357                )],
358                sources: files,
359            });
360        }
361        for code in files.values() {
362            if code.len() > limits.max_file_size_bytes {
363                return Err(Errors {
364                    errors: vec![Error::resource_limit_exceeded(
365                        "max_file_size_bytes",
366                        limits.max_file_size_bytes.to_string(),
367                        code.len().to_string(),
368                        "Use a smaller file or increase limit",
369                        None::<crate::Source>,
370                        None,
371                        None,
372                    )],
373                    sources: files,
374                });
375            }
376        }
377
378        let mut errors: Vec<Error> = Vec::new();
379
380        for (source_id, code) in &files {
381            match parse(code, source_id, &self.limits) {
382                Ok(result) => {
383                    self.total_expression_count += result.expression_count;
384                    if self.total_expression_count > self.limits.max_total_expression_count {
385                        errors.push(Error::resource_limit_exceeded(
386                            "max_total_expression_count",
387                            self.limits.max_total_expression_count.to_string(),
388                            self.total_expression_count.to_string(),
389                            "Split logic across fewer files or reduce expression complexity",
390                            None::<crate::Source>,
391                            None,
392                            None,
393                        ));
394                        return Err(Errors {
395                            errors,
396                            sources: files,
397                        });
398                    }
399                    let new_specs = result.specs;
400                    for spec in new_specs {
401                        let attribute = spec.attribute.clone().unwrap_or_else(|| spec.name.clone());
402                        let start_line = spec.start_line;
403
404                        if from_registry {
405                            let bare_refs =
406                                crate::planning::graph::collect_bare_registry_refs(&spec);
407                            if !bare_refs.is_empty() {
408                                let source = crate::Source::new(
409                                    &attribute,
410                                    crate::parsing::ast::Span {
411                                        start: 0,
412                                        end: 0,
413                                        line: start_line,
414                                        col: 0,
415                                    },
416                                );
417                                errors.push(Error::validation(
418                                    format!(
419                                        "Registry spec '{}' contains references without '@' prefix: {}. \
420                                         The registry must rewrite all references to use '@'-prefixed names",
421                                        spec.name,
422                                        bare_refs.join(", ")
423                                    ),
424                                    Some(source),
425                                    Some(
426                                        "The registry must prefix all spec references with '@' \
427                                         before serving the bundle.",
428                                    ),
429                                ));
430                                continue;
431                            }
432                        }
433
434                        match self.specs.insert_spec(Arc::new(spec), from_registry) {
435                            Ok(()) => {
436                                self.sources.insert(attribute, code.clone());
437                            }
438                            Err(e) => {
439                                let source = crate::Source::new(
440                                    &attribute,
441                                    crate::parsing::ast::Span {
442                                        start: 0,
443                                        end: 0,
444                                        line: start_line,
445                                        col: 0,
446                                    },
447                                );
448                                errors.push(Error::validation(
449                                    e.to_string(),
450                                    Some(source),
451                                    None::<String>,
452                                ));
453                            }
454                        }
455                    }
456                }
457                Err(e) => errors.push(e),
458            }
459        }
460
461        let planning_result = crate::planning::plan(&self.specs);
462        for set_result in &planning_result.results {
463            for spec_result in &set_result.specs {
464                let ctx = Arc::clone(&spec_result.spec);
465                for err in &spec_result.errors {
466                    errors.push(err.clone().with_spec_context(Arc::clone(&ctx)));
467                }
468            }
469        }
470        self.apply_planning_result(planning_result);
471
472        if errors.is_empty() {
473            Ok(())
474        } else {
475            Err(Errors {
476                errors,
477                sources: files,
478            })
479        }
480    }
481
482    /// Name-scoped access to all temporal versions of a spec.
483    ///
484    /// Returns the full `LemmaSpecSet` (every row and its `[effective_from, next)` range),
485    /// or `None` when no spec by that name is loaded. This is the primitive for catalog
486    /// and version-inventory queries. Point-in-time resolution goes through
487    /// [`Engine::get_spec`], which delegates here.
488    #[must_use]
489    pub fn get_spec_set(&self, name: &str) -> Option<&LemmaSpecSet> {
490        self.specs.spec_sets().get(name)
491    }
492
493    pub fn get_spec(
494        &self,
495        name: &str,
496        effective: Option<&DateTimeValue>,
497    ) -> Result<Arc<LemmaSpec>, Error> {
498        let effective = self.effective_or_now(effective);
499
500        self.get_spec_set(name)
501            .and_then(|spec_set| spec_set.spec_at(&EffectiveDate::DateTimeValue(effective.clone())))
502            .ok_or_else(|| self.spec_not_found_error(name, &effective))
503    }
504
505    /// All specs ordered by (name, effective_from).
506    pub fn list_specs(&self) -> Vec<Arc<LemmaSpec>> {
507        self.specs.iter().collect()
508    }
509
510    /// All specs paired with their half-open
511    /// `[effective_from, effective_to)` validity ranges.
512    ///
513    /// Same order as [`Self::list_specs`]. Each entry is
514    /// `(spec, effective_from, effective_to)`; for every spec name, the last
515    /// row's `effective_to` is `None` (no successor).
516    pub fn list_specs_with_ranges(
517        &self,
518    ) -> Vec<(Arc<LemmaSpec>, Option<DateTimeValue>, Option<DateTimeValue>)> {
519        self.specs.iter_with_ranges().collect()
520    }
521
522    /// Specs active at `effective` (one per name).
523    /// Todo: clone the specs instead of returning references
524    /// Consider removing this method: does it make sense to list specs by effective date?
525    pub fn list_specs_effective(&self, effective: &DateTimeValue) -> Vec<Arc<LemmaSpec>> {
526        let mut seen_names = std::collections::HashSet::new();
527        let mut result = Vec::new();
528        for spec in self.specs.iter() {
529            if seen_names.contains(&spec.name) {
530                continue;
531            }
532            if let Some(active) = self
533                .specs
534                .spec_sets()
535                .get(&spec.name)
536                .and_then(|ss| ss.spec_at(&EffectiveDate::DateTimeValue(effective.clone())))
537            {
538                if seen_names.insert(active.name.clone()) {
539                    result.push(active);
540                }
541            }
542        }
543        result.sort_by(|a, b| a.name.cmp(&b.name));
544        result
545    }
546
547    /// Resolve spec identifier and return the spec schema. Uses `effective` or now when None.
548    pub fn schema(
549        &self,
550        name: &str,
551        effective: Option<&DateTimeValue>,
552    ) -> Result<SpecSchema, Error> {
553        let effective = self.effective_or_now(effective);
554        Ok(self.get_plan(name, Some(&effective))?.schema())
555    }
556
557    /// Run a spec: resolve by SpecSet id, then [`run_plan`]. Returns all rules; filter via [`Response::filter_rules`] if needed.
558    ///
559    /// When `record_operations` is true, each rule's [`RuleResult::operations`] will
560    /// contain a trace of data used, rules used, computations, and branch evaluations.
561    pub fn run(
562        &self,
563        name: &str,
564        effective: Option<&DateTimeValue>,
565        data_values: HashMap<String, String>,
566        record_operations: bool,
567    ) -> Result<Response, Error> {
568        let effective = self.effective_or_now(effective);
569        let plan = self.get_plan(name, Some(&effective))?;
570        self.run_plan(plan, Some(&effective), data_values, record_operations)
571    }
572
573    /// Invert a rule to find input domains that produce a desired outcome.
574    ///
575    /// Values are provided as name -> value string pairs (e.g., "quantity" -> "5").
576    /// They are automatically parsed to the expected type based on the spec schema.
577    pub fn invert(
578        &self,
579        name: &str,
580        effective: Option<&DateTimeValue>,
581        rule_name: &str,
582        target: crate::inversion::Target,
583        values: HashMap<String, String>,
584    ) -> Result<crate::InversionResponse, Error> {
585        let effective = self.effective_or_now(effective);
586        let base_plan = self.get_plan(name, Some(&effective))?;
587
588        let plan = base_plan.clone().with_data_values(values, &self.limits)?;
589        let provided_data: std::collections::HashSet<_> = plan
590            .data
591            .iter()
592            .filter(|(_, d)| d.value().is_some())
593            .map(|(p, _)| p.clone())
594            .collect();
595
596        crate::inversion::invert(rule_name, target, &plan, &provided_data)
597    }
598
599    /// Resolve spec identifier and return the execution plan. Uses `effective` or now when None.
600    pub fn get_plan(
601        &self,
602        name: &str,
603        effective: Option<&DateTimeValue>,
604    ) -> Result<&crate::planning::ExecutionPlan, Error> {
605        let effective = self.effective_or_now(effective);
606
607        if self
608            .specs
609            .spec_sets()
610            .get(name)
611            .and_then(|ss| ss.spec_at(&EffectiveDate::DateTimeValue(effective.clone())))
612            .is_none()
613        {
614            return Err(self.spec_not_found_error(name, &effective));
615        }
616
617        let plan_set = self.plan_sets.get(name).ok_or_else(|| {
618            Error::request_not_found(
619                format!("No execution plans for spec '{}'", name),
620                Some("Ensure sources loaded and planning succeeded"),
621            )
622        })?;
623
624        plan_set
625            .plan_at(&EffectiveDate::DateTimeValue(effective.clone()))
626            .ok_or_else(|| {
627                Error::request_not_found(
628                    format!(
629                        "No execution plan slice for spec '{}' at effective {}",
630                        name, effective
631                    ),
632                    None::<String>,
633                )
634            })
635    }
636
637    /// Run a plan from [`get_plan`]: apply data values and evaluate all rules.
638    ///
639    /// When `record_operations` is true, each rule's [`RuleResult::operations`] will
640    /// contain a trace of data used, rules used, computations, and branch evaluations.
641    pub fn run_plan(
642        &self,
643        plan: &crate::planning::ExecutionPlan,
644        effective: Option<&DateTimeValue>,
645        data_values: HashMap<String, String>,
646        record_operations: bool,
647    ) -> Result<Response, Error> {
648        let effective = self.effective_or_now(effective);
649        let plan = plan.clone().with_data_values(data_values, &self.limits)?;
650        self.evaluate_plan(plan, &effective, record_operations)
651    }
652
653    pub fn remove(&mut self, name: &str, effective: Option<&DateTimeValue>) -> Result<(), Error> {
654        let effective = self.effective_or_now(effective);
655        let arc = self.get_spec(name, Some(&effective))?;
656        self.specs.remove_spec(&arc);
657        let pr = crate::planning::plan(&self.specs);
658        let planning_errs: Vec<Error> = pr
659            .results
660            .iter()
661            .flat_map(|r| r.errors().cloned())
662            .collect();
663        self.apply_planning_result(pr);
664        if let Some(e) = planning_errs.into_iter().next() {
665            return Err(e);
666        }
667        Ok(())
668    }
669
670    /// Build a "not found" error listing available specs when the name exists
671    /// but no spec covers the requested effective date.
672    fn spec_not_found_error(&self, spec_name: &str, effective: &DateTimeValue) -> Error {
673        let available = self
674            .specs
675            .spec_sets()
676            .get(spec_name)
677            .map(|ss| ss.iter_specs().collect::<Vec<_>>())
678            .unwrap_or_default();
679        let msg = if available.is_empty() {
680            format!("Spec '{}' not found", spec_name)
681        } else {
682            let listing: Vec<String> = available
683                .iter()
684                .map(|s| match s.effective_from() {
685                    Some(dt) => format!("  {} (effective from {})", s.name, dt),
686                    None => format!("  {} (no effective_from)", s.name),
687                })
688                .collect();
689            format!(
690                "Spec '{}' not found for effective {}. Available specs:\n{}",
691                spec_name,
692                effective,
693                listing.join("\n")
694            )
695        };
696        Error::request_not_found(msg, None::<String>)
697    }
698
699    fn evaluate_plan(
700        &self,
701        plan: crate::planning::ExecutionPlan,
702        effective: &DateTimeValue,
703        record_operations: bool,
704    ) -> Result<Response, Error> {
705        let now_semantic = crate::planning::semantics::date_time_to_semantic(effective);
706        let now_literal = crate::planning::semantics::LiteralValue {
707            value: crate::planning::semantics::ValueKind::Date(now_semantic),
708            lemma_type: crate::planning::semantics::primitive_date().clone(),
709        };
710        Ok(self
711            .evaluator
712            .evaluate(&plan, now_literal, record_operations))
713    }
714
715    /// Effective datetime for a request: `explicit` or now.
716    #[must_use]
717    fn effective_or_now(&self, effective: Option<&DateTimeValue>) -> DateTimeValue {
718        effective.cloned().unwrap_or_else(DateTimeValue::now)
719    }
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725    use rust_decimal::Decimal;
726    use std::str::FromStr;
727
728    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
729        DateTimeValue {
730            year,
731            month,
732            day,
733            hour: 0,
734            minute: 0,
735            second: 0,
736            microsecond: 0,
737            timezone: None,
738        }
739    }
740
741    fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
742        let mut spec = LemmaSpec::new(name.to_string());
743        spec.effective_from = crate::parsing::ast::EffectiveDate::from_option(effective_from);
744        spec
745    }
746
747    /// list_specs (and Context::iter) return specs in (name, effective_from) ascending order.
748    /// Same-name specs appear in temporal order; definition order in the file is irrelevant.
749    #[test]
750    fn list_specs_order_is_name_then_effective_from_ascending() {
751        let mut ctx = Context::new();
752        let s_2026 = Arc::new(make_spec_with_range("mortgage", Some(date(2026, 1, 1))));
753        let s_2025 = Arc::new(make_spec_with_range("mortgage", Some(date(2025, 1, 1))));
754        ctx.insert_spec(Arc::clone(&s_2026), false).unwrap();
755        ctx.insert_spec(Arc::clone(&s_2025), false).unwrap();
756        let listed: Vec<_> = ctx.iter().collect();
757        assert_eq!(listed.len(), 2);
758        assert_eq!(listed[0].effective_from(), Some(&date(2025, 1, 1)));
759        assert_eq!(listed[1].effective_from(), Some(&date(2026, 1, 1)));
760    }
761
762    #[test]
763    fn get_spec_resolves_temporal_version_by_effective() {
764        let mut engine = Engine::new();
765        engine
766            .load(
767                r#"
768        spec pricing 2025-01-01
769        data x: 1
770        rule r: x
771    "#,
772                SourceType::Labeled("a.lemma"),
773            )
774            .unwrap();
775        engine
776            .load(
777                r#"
778        spec pricing 2025-06-01
779        data x: 2
780        rule r: x
781    "#,
782                SourceType::Labeled("b.lemma"),
783            )
784            .unwrap();
785
786        let jan = DateTimeValue {
787            year: 2025,
788            month: 1,
789            day: 15,
790            hour: 0,
791            minute: 0,
792            second: 0,
793            microsecond: 0,
794            timezone: None,
795        };
796        let jul = DateTimeValue {
797            year: 2025,
798            month: 7,
799            day: 1,
800            hour: 0,
801            minute: 0,
802            second: 0,
803            microsecond: 0,
804            timezone: None,
805        };
806
807        let v1 = DateTimeValue {
808            year: 2025,
809            month: 1,
810            day: 1,
811            hour: 0,
812            minute: 0,
813            second: 0,
814            microsecond: 0,
815            timezone: None,
816        };
817        let v2 = DateTimeValue {
818            year: 2025,
819            month: 6,
820            day: 1,
821            hour: 0,
822            minute: 0,
823            second: 0,
824            microsecond: 0,
825            timezone: None,
826        };
827
828        let s_jan = engine.get_spec("pricing", Some(&jan)).expect("jan spec");
829        let s_jul = engine.get_spec("pricing", Some(&jul)).expect("jul spec");
830        assert_eq!(s_jan.effective_from(), Some(&v1));
831        assert_eq!(s_jul.effective_from(), Some(&v2));
832    }
833
834    /// `get_spec_set` exposes every temporal version of a spec name with its
835    /// half-open `[effective_from, effective_to)` range. The latest row's
836    /// `effective_to` is `None` (no successor); earlier rows' `effective_to`
837    /// equals the next row's `effective_from`.
838    #[test]
839    fn get_spec_set_returns_all_versions_with_half_open_ranges() {
840        let mut engine = Engine::new();
841        engine
842            .load(
843                r#"
844        spec pricing 2025-01-01
845        data x: 1
846        rule r: x
847    "#,
848                SourceType::Labeled("a.lemma"),
849            )
850            .unwrap();
851        engine
852            .load(
853                r#"
854        spec pricing 2025-06-01
855        data x: 2
856        rule r: x
857    "#,
858                SourceType::Labeled("b.lemma"),
859            )
860            .unwrap();
861
862        let january = date(2025, 1, 1);
863        let june = date(2025, 6, 1);
864
865        let spec_set = engine
866            .get_spec_set("pricing")
867            .expect("spec set must exist after load");
868
869        let versions: Vec<_> = spec_set
870            .iter_specs()
871            .map(|spec| spec_set.effective_range(&spec))
872            .collect();
873
874        assert_eq!(versions.len(), 2);
875        assert_eq!(
876            versions[0],
877            (Some(january.clone()), Some(june.clone())),
878            "earlier row ends at the next row's effective_from"
879        );
880        assert_eq!(
881            versions[1],
882            (Some(june.clone()), None),
883            "latest row has no successor; effective_to is None"
884        );
885
886        assert!(engine.get_spec_set("unknown").is_none());
887    }
888
889    /// `Engine::list_specs_with_ranges` flattens every spec set into a flat
890    /// list of `(spec, effective_from, effective_to)` triples in the same
891    /// order as [`Engine::list_specs`]. This is the canonical flat surface
892    /// consumed by language bindings (Hex NIF, WASM) so both `effective_from`
893    /// and `effective_to` reach every engine user without a second lookup.
894    #[test]
895    fn list_specs_with_ranges_flattens_all_spec_sets_with_half_open_ranges() {
896        let mut engine = Engine::new();
897        engine
898            .load(
899                r#"
900        spec pricing 2025-01-01
901        data x: 1
902        rule r: x
903    "#,
904                SourceType::Labeled("pricing_v1.lemma"),
905            )
906            .unwrap();
907        engine
908            .load(
909                r#"
910        spec pricing 2026-01-01
911        data x: 2
912        rule r: x
913    "#,
914                SourceType::Labeled("pricing_v2.lemma"),
915            )
916            .unwrap();
917        engine
918            .load(
919                r#"
920        spec taxes
921        data rate: 0.21
922        rule amount: rate
923    "#,
924                SourceType::Labeled("taxes.lemma"),
925            )
926            .unwrap();
927
928        let entries = engine.list_specs_with_ranges();
929        assert_eq!(
930            entries.len(),
931            3,
932            "one row per loaded spec version across all names"
933        );
934
935        let names: Vec<&str> = entries
936            .iter()
937            .map(|(spec, _, _)| spec.name.as_str())
938            .collect();
939        assert_eq!(
940            names,
941            vec!["pricing", "pricing", "taxes"],
942            "ordered by spec name ascending, then by effective_from ascending"
943        );
944
945        let (_, pricing_v1_from, pricing_v1_to) = &entries[0];
946        assert_eq!(pricing_v1_from, &Some(date(2025, 1, 1)));
947        assert_eq!(
948            pricing_v1_to,
949            &Some(date(2026, 1, 1)),
950            "earlier pricing row ends at the next pricing row's effective_from"
951        );
952
953        let (_, pricing_v2_from, pricing_v2_to) = &entries[1];
954        assert_eq!(pricing_v2_from, &Some(date(2026, 1, 1)));
955        assert_eq!(
956            pricing_v2_to, &None,
957            "latest pricing row has no successor; effective_to is None"
958        );
959
960        let (_, taxes_from, taxes_to) = &entries[2];
961        assert_eq!(
962            taxes_from, &None,
963            "unversioned spec has no declared effective_from"
964        );
965        assert_eq!(
966            taxes_to, &None,
967            "unversioned spec has no successor; effective_to is None"
968        );
969    }
970
971    #[test]
972    fn test_evaluate_spec_all_rules() {
973        let mut engine = Engine::new();
974        engine
975            .load(
976                r#"
977        spec test
978        data x: 10
979        data y: 5
980        rule sum: x + y
981        rule product: x * y
982    "#,
983                SourceType::Labeled("test.lemma"),
984            )
985            .unwrap();
986
987        let now = DateTimeValue::now();
988        let response = engine
989            .run("test", Some(&now), HashMap::new(), false)
990            .unwrap();
991        assert_eq!(response.results.len(), 2);
992
993        let sum_result = response
994            .results
995            .values()
996            .find(|r| r.rule.name == "sum")
997            .unwrap();
998        assert_eq!(
999            sum_result.result,
1000            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1001                Decimal::from_str("15").unwrap()
1002            )))
1003        );
1004
1005        let product_result = response
1006            .results
1007            .values()
1008            .find(|r| r.rule.name == "product")
1009            .unwrap();
1010        assert_eq!(
1011            product_result.result,
1012            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1013                Decimal::from_str("50").unwrap()
1014            )))
1015        );
1016    }
1017
1018    #[test]
1019    fn test_evaluate_empty_data() {
1020        let mut engine = Engine::new();
1021        engine
1022            .load(
1023                r#"
1024        spec test
1025        data price: 100
1026        rule total: price * 2
1027    "#,
1028                SourceType::Labeled("test.lemma"),
1029            )
1030            .unwrap();
1031
1032        let now = DateTimeValue::now();
1033        let response = engine
1034            .run("test", Some(&now), HashMap::new(), false)
1035            .unwrap();
1036        assert_eq!(response.results.len(), 1);
1037        assert_eq!(
1038            response.results.values().next().unwrap().result,
1039            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1040                Decimal::from_str("200").unwrap()
1041            )))
1042        );
1043    }
1044
1045    #[test]
1046    fn test_evaluate_boolean_rule() {
1047        let mut engine = Engine::new();
1048        engine
1049            .load(
1050                r#"
1051        spec test
1052        data age: 25
1053        rule is_adult: age >= 18
1054    "#,
1055                SourceType::Labeled("test.lemma"),
1056            )
1057            .unwrap();
1058
1059        let now = DateTimeValue::now();
1060        let response = engine
1061            .run("test", Some(&now), HashMap::new(), false)
1062            .unwrap();
1063        assert_eq!(
1064            response.results.values().next().unwrap().result,
1065            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::from_bool(true)))
1066        );
1067    }
1068
1069    #[test]
1070    fn test_evaluate_with_unless_clause() {
1071        let mut engine = Engine::new();
1072        engine
1073            .load(
1074                r#"
1075        spec test
1076        data quantity: 15
1077        rule discount: 0
1078          unless quantity >= 10 then 10
1079    "#,
1080                SourceType::Labeled("test.lemma"),
1081            )
1082            .unwrap();
1083
1084        let now = DateTimeValue::now();
1085        let response = engine
1086            .run("test", Some(&now), HashMap::new(), false)
1087            .unwrap();
1088        assert_eq!(
1089            response.results.values().next().unwrap().result,
1090            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1091                Decimal::from_str("10").unwrap()
1092            )))
1093        );
1094    }
1095
1096    #[test]
1097    fn test_spec_not_found() {
1098        let engine = Engine::new();
1099        let now = DateTimeValue::now();
1100        let result = engine.run("nonexistent", Some(&now), HashMap::new(), false);
1101        assert!(result.is_err());
1102        assert!(result.unwrap_err().to_string().contains("not found"));
1103    }
1104
1105    #[test]
1106    fn test_multiple_specs() {
1107        let mut engine = Engine::new();
1108        engine
1109            .load(
1110                r#"
1111        spec spec1
1112        data x: 10
1113        rule result: x * 2
1114    "#,
1115                SourceType::Labeled("spec 1.lemma"),
1116            )
1117            .unwrap();
1118
1119        engine
1120            .load(
1121                r#"
1122        spec spec2
1123        data y: 5
1124        rule result: y * 3
1125    "#,
1126                SourceType::Labeled("spec 2.lemma"),
1127            )
1128            .unwrap();
1129
1130        let now = DateTimeValue::now();
1131        let response1 = engine
1132            .run("spec1", Some(&now), HashMap::new(), false)
1133            .unwrap();
1134        assert_eq!(
1135            response1.results[0].result,
1136            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1137                Decimal::from_str("20").unwrap()
1138            )))
1139        );
1140
1141        let response2 = engine
1142            .run("spec2", Some(&now), HashMap::new(), false)
1143            .unwrap();
1144        assert_eq!(
1145            response2.results[0].result,
1146            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1147                Decimal::from_str("15").unwrap()
1148            )))
1149        );
1150    }
1151
1152    #[test]
1153    fn test_runtime_error_mapping() {
1154        let mut engine = Engine::new();
1155        engine
1156            .load(
1157                r#"
1158        spec test
1159        data numerator: 10
1160        data denominator: 0
1161        rule division: numerator / denominator
1162    "#,
1163                SourceType::Labeled("test.lemma"),
1164            )
1165            .unwrap();
1166
1167        let now = DateTimeValue::now();
1168        let result = engine.run("test", Some(&now), HashMap::new(), false);
1169        // Division by zero returns a Veto (not an error)
1170        assert!(result.is_ok(), "Evaluation should succeed");
1171        let response = result.unwrap();
1172        let division_result = response
1173            .results
1174            .values()
1175            .find(|r| r.rule.name == "division");
1176        assert!(
1177            division_result.is_some(),
1178            "Should have division rule result"
1179        );
1180        match &division_result.unwrap().result {
1181            crate::OperationResult::Veto(crate::VetoType::Computation { message }) => {
1182                assert!(
1183                    message.contains("Division by zero"),
1184                    "Veto message should mention division by zero: {:?}",
1185                    message
1186                );
1187            }
1188            other => panic!("Expected Veto for division by zero, got {:?}", other),
1189        }
1190    }
1191
1192    #[test]
1193    fn test_rules_sorted_by_source_order() {
1194        let mut engine = Engine::new();
1195        engine
1196            .load(
1197                r#"
1198        spec test
1199        data a: 1
1200        data b: 2
1201        rule z: a + b
1202        rule y: a * b
1203        rule x: a - b
1204    "#,
1205                SourceType::Labeled("test.lemma"),
1206            )
1207            .unwrap();
1208
1209        let now = DateTimeValue::now();
1210        let response = engine
1211            .run("test", Some(&now), HashMap::new(), false)
1212            .unwrap();
1213        assert_eq!(response.results.len(), 3);
1214
1215        // Verify source positions increase (z < y < x)
1216        let z_pos = response
1217            .results
1218            .values()
1219            .find(|r| r.rule.name == "z")
1220            .unwrap()
1221            .rule
1222            .source_location
1223            .span
1224            .start;
1225        let y_pos = response
1226            .results
1227            .values()
1228            .find(|r| r.rule.name == "y")
1229            .unwrap()
1230            .rule
1231            .source_location
1232            .span
1233            .start;
1234        let x_pos = response
1235            .results
1236            .values()
1237            .find(|r| r.rule.name == "x")
1238            .unwrap()
1239            .rule
1240            .source_location
1241            .span
1242            .start;
1243
1244        assert!(z_pos < y_pos);
1245        assert!(y_pos < x_pos);
1246    }
1247
1248    #[test]
1249    fn test_rule_filtering_evaluates_dependencies() {
1250        let mut engine = Engine::new();
1251        engine
1252            .load(
1253                r#"
1254        spec test
1255        data base: 100
1256        rule subtotal: base * 2
1257        rule tax: subtotal * 10%
1258        rule total: subtotal + tax
1259    "#,
1260                SourceType::Labeled("test.lemma"),
1261            )
1262            .unwrap();
1263
1264        // User filters to 'total' after run (deps were still computed)
1265        let now = DateTimeValue::now();
1266        let rules = vec!["total".to_string()];
1267        let mut response = engine
1268            .run("test", Some(&now), HashMap::new(), false)
1269            .unwrap();
1270        response.filter_rules(&rules);
1271
1272        assert_eq!(response.results.len(), 1);
1273        assert_eq!(response.results.keys().next().unwrap(), "total");
1274
1275        // But the value should be correct (dependencies were computed)
1276        let total = response.results.values().next().unwrap();
1277        assert_eq!(
1278            total.result,
1279            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1280                Decimal::from_str("220").unwrap()
1281            )))
1282        );
1283    }
1284
1285    // -------------------------------------------------------------------
1286    // Pre-resolved dependency tests (Engine never fetches from registry)
1287    // -------------------------------------------------------------------
1288
1289    use crate::parsing::ast::DateTimeValue;
1290
1291    #[test]
1292    fn pre_resolved_deps_in_file_map_evaluates_external_spec() {
1293        let mut engine = Engine::new();
1294
1295        engine
1296            .load(
1297                "spec @org/project/helper\ndata quantity: 42",
1298                SourceType::Dependency("deps/org_project_helper.lemma"),
1299            )
1300            .expect("should load dependency files");
1301
1302        engine
1303            .load(
1304                r#"spec main_spec
1305with external: @org/project/helper
1306rule value: external.quantity"#,
1307                SourceType::Labeled("main.lemma"),
1308            )
1309            .expect("should succeed with pre-resolved deps");
1310
1311        let now = DateTimeValue::now();
1312        let response = engine
1313            .run("main_spec", Some(&now), HashMap::new(), false)
1314            .expect("evaluate should succeed");
1315
1316        let value_result = response
1317            .results
1318            .get("value")
1319            .expect("rule 'value' should exist");
1320        assert_eq!(
1321            value_result.result,
1322            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1323                Decimal::from_str("42").unwrap()
1324            )))
1325        );
1326    }
1327
1328    #[test]
1329    fn load_no_external_refs_works() {
1330        let mut engine = Engine::new();
1331
1332        engine
1333            .load(
1334                r#"spec local_only
1335data price: 100
1336rule doubled: price * 2"#,
1337                SourceType::Labeled("local.lemma"),
1338            )
1339            .expect("should succeed when there are no @... references");
1340
1341        let now = DateTimeValue::now();
1342        let response = engine
1343            .run("local_only", Some(&now), HashMap::new(), false)
1344            .expect("evaluate should succeed");
1345
1346        let doubled = response
1347            .results
1348            .get("doubled")
1349            .expect("doubled rule")
1350            .result
1351            .value()
1352            .expect("value");
1353        assert_eq!(doubled.to_string(), "200");
1354    }
1355
1356    #[test]
1357    fn unresolved_external_ref_without_deps_fails() {
1358        let mut engine = Engine::new();
1359
1360        let result = engine.load(
1361            r#"spec main_spec
1362with external: @org/project/missing
1363rule value: external.quantity"#,
1364            SourceType::Labeled("main.lemma"),
1365        );
1366
1367        let errs = result.expect_err("Should fail when @... dep is not in file map");
1368        let msg = errs
1369            .iter()
1370            .map(|e| e.to_string())
1371            .collect::<Vec<_>>()
1372            .join(" ");
1373        assert!(
1374            msg.contains("missing") || msg.contains("not found") || msg.contains("Unknown"),
1375            "error should indicate missing dep: {msg}"
1376        );
1377    }
1378
1379    #[test]
1380    fn pre_resolved_deps_with_spec_and_type_refs() {
1381        let mut engine = Engine::new();
1382
1383        let mut deps = HashMap::new();
1384        deps.insert(
1385            "deps/helper.lemma".to_string(),
1386            "spec @org/example/helper\ndata value: 42".to_string(),
1387        );
1388        deps.insert(
1389            "deps/finance.lemma".to_string(),
1390            "spec @lemma/std/finance\ndata money: scale\n -> unit eur 1.00\n -> decimals 2"
1391                .to_string(),
1392        );
1393        engine
1394            .load(
1395                "spec @org/example/helper\ndata value: 42",
1396                SourceType::Dependency("deps/helper.lemma"),
1397            )
1398            .expect("should load helper file");
1399
1400        engine
1401            .load(
1402                "spec @lemma/std/finance\ndata money: scale\n -> unit eur 1.00\n -> decimals 2",
1403                SourceType::Dependency("deps/finance.lemma"),
1404            )
1405            .expect("should load finance file");
1406
1407        engine
1408            .load(
1409                r#"spec registry_demo
1410data money from @lemma/std/finance
1411data unit_price: 5 eur
1412with @org/example/helper
1413rule helper_value: helper.value
1414rule line_total: unit_price * 2
1415rule formatted: helper_value + 0"#,
1416                SourceType::Labeled("main.lemma"),
1417            )
1418            .expect("should succeed with pre-resolved spec and type deps");
1419
1420        let now = DateTimeValue::now();
1421        let response = engine
1422            .run("registry_demo", Some(&now), HashMap::new(), false)
1423            .expect("evaluate should succeed");
1424
1425        assert_eq!(
1426            response
1427                .results
1428                .get("helper_value")
1429                .expect("helper_value")
1430                .result
1431                .value()
1432                .expect("value")
1433                .to_string(),
1434            "42"
1435        );
1436        let line = response
1437            .results
1438            .get("line_total")
1439            .expect("line_total")
1440            .result
1441            .value()
1442            .expect("value")
1443            .to_string();
1444        assert!(
1445            line.contains("10") && line.to_lowercase().contains("eur"),
1446            "5 eur * 2 => ~10 eur, got {line}"
1447        );
1448        assert_eq!(
1449            response
1450                .results
1451                .get("formatted")
1452                .expect("formatted")
1453                .result
1454                .value()
1455                .expect("value")
1456                .to_string(),
1457            "42"
1458        );
1459    }
1460
1461    #[test]
1462    fn load_empty_labeled_source_is_error() {
1463        let mut engine = Engine::new();
1464        let err = engine
1465            .load("spec x\ndata a: 1", SourceType::Labeled("  "))
1466            .unwrap_err();
1467        assert!(err.errors.iter().any(|e| e.message().contains("non-empty")));
1468    }
1469
1470    #[test]
1471    fn load_rejects_registry_spec_definitions() {
1472        let mut engine = Engine::new();
1473        let result = engine.load(
1474            "spec @org/example/helper\ndata x: 1",
1475            SourceType::Labeled("bad.lemma"),
1476        );
1477        assert!(result.is_err(), "should reject @-prefixed spec in load");
1478        let errors = result.unwrap_err();
1479        assert!(
1480            errors
1481                .errors
1482                .iter()
1483                .any(|e| e.message().contains("registry prefix")),
1484            "error should mention registry prefix, got: {:?}",
1485            errors
1486        );
1487    }
1488
1489    #[test]
1490    fn add_dependency_files_accepts_registry_spec_definitions() {
1491        let mut engine = Engine::new();
1492        let mut files = HashMap::new();
1493        files.insert(
1494            "deps/helper.lemma".to_string(),
1495            "spec @org/my/helper\ndata x: 1".to_string(),
1496        );
1497        engine
1498            .load(
1499                "spec @org/my/helper\ndata x: 1",
1500                SourceType::Dependency("helper.lemma"),
1501            )
1502            .expect("add_dependency_files should accept @-prefixed specs");
1503    }
1504
1505    #[test]
1506    fn add_dependency_files_rejects_bare_named_spec_in_registry_bundle() {
1507        let mut engine = Engine::new();
1508        let result = engine.load(
1509            "spec local_looking_name\ndata x: 1",
1510            SourceType::Dependency("bundle.lemma"),
1511        );
1512        assert!(
1513            result.is_err(),
1514            "should reject non-@-prefixed spec in registry bundle"
1515        );
1516        let errors = result.unwrap_err();
1517        assert!(
1518            errors
1519                .errors
1520                .iter()
1521                .any(|e| e.message().contains("without '@' prefix")),
1522            "error should mention missing @ prefix, got: {:?}",
1523            errors
1524        );
1525    }
1526
1527    #[test]
1528    fn add_dependency_files_rejects_spec_with_bare_spec_reference() {
1529        let mut engine = Engine::new();
1530        let result = engine.load(
1531            "spec @org/billing\nwith rates: local_rates",
1532            SourceType::Dependency("billing.lemma"),
1533        );
1534        assert!(
1535            result.is_err(),
1536            "should reject registry spec referencing non-@ spec"
1537        );
1538        let errors = result.unwrap_err();
1539        assert!(
1540            errors
1541                .errors
1542                .iter()
1543                .any(|e| e.message().contains("local_rates")),
1544            "error should mention bare ref name, got: {:?}",
1545            errors
1546        );
1547    }
1548
1549    #[test]
1550    fn add_dependency_files_rejects_spec_with_bare_type_import() {
1551        let mut engine = Engine::new();
1552        let result = engine.load(
1553            "spec @org/billing\ndata money from local_finance",
1554            SourceType::Dependency("billing.lemma"),
1555        );
1556        assert!(
1557            result.is_err(),
1558            "should reject registry spec importing type from non-@ spec"
1559        );
1560        let errors = result.unwrap_err();
1561        assert!(
1562            errors
1563                .errors
1564                .iter()
1565                .any(|e| e.message().contains("local_finance")),
1566            "error should mention bare ref name, got: {:?}",
1567            errors
1568        );
1569    }
1570
1571    #[test]
1572    fn add_dependency_files_accepts_fully_qualified_references() {
1573        let mut engine = Engine::new();
1574        let mut files = HashMap::new();
1575        files.insert(
1576            "deps/bundle.lemma".to_string(),
1577            r#"spec @org/billing
1578with @org/rates
1579
1580spec @org/rates
1581data rate: 10"#
1582                .to_string(),
1583        );
1584        engine
1585            .load(
1586                r#"spec @org/billing
1587with @org/rates
1588
1589spec @org/rates
1590data rate: 10"#,
1591                SourceType::Dependency("bundle.lemma"),
1592            )
1593            .expect("fully @-prefixed bundle should be accepted");
1594    }
1595
1596    #[test]
1597    fn load_returns_all_errors_not_just_first() {
1598        let mut engine = Engine::new();
1599
1600        let result = engine.load(
1601            r#"spec demo
1602data money from nonexistent_type_source
1603with helper: nonexistent_spec
1604data price: 10
1605rule total: helper.value + price"#,
1606            SourceType::Labeled("test.lemma"),
1607        );
1608
1609        assert!(result.is_err(), "Should fail with multiple errors");
1610        let load_err = result.unwrap_err();
1611        assert!(
1612            load_err.errors.len() >= 2,
1613            "expected at least 2 errors (type + spec ref), got {}",
1614            load_err.errors.len()
1615        );
1616        let error_message = load_err
1617            .errors
1618            .iter()
1619            .map(ToString::to_string)
1620            .collect::<Vec<_>>()
1621            .join("; ");
1622
1623        assert!(
1624            error_message.contains("nonexistent_type_source"),
1625            "Should mention type import source spec. Got:\n{}",
1626            error_message
1627        );
1628        assert!(
1629            error_message.contains("nonexistent_spec"),
1630            "Should mention spec reference error about 'nonexistent_spec'. Got:\n{}",
1631            error_message
1632        );
1633    }
1634
1635    // ── Default value type validation ────────────────────────────────
1636    // Planning must reject default values that don't match the type.
1637    // These tests cover both primitives and named types (which the parser
1638    // can't validate because it doesn't resolve type names).
1639
1640    #[test]
1641    fn planning_rejects_invalid_number_default() {
1642        let mut engine = Engine::new();
1643        let result = engine.load(
1644            "spec t\ndata x: number -> default \"10 $$\"]\nrule r: x",
1645            SourceType::Labeled("t.lemma"),
1646        );
1647        assert!(
1648            result.is_err(),
1649            "must reject non-numeric default on number type"
1650        );
1651    }
1652
1653    #[test]
1654    fn planning_rejects_text_literal_as_number_default() {
1655        // `default "10"` produces a typed `CommandArg::Literal(Value::Text("10"))`.
1656        // Planning matches on the literal's variant — a `Text` literal is rejected
1657        // where a `Number` literal is required, even though `"10"` would parse as
1658        // a valid `Decimal` if coerced.
1659        let mut engine = Engine::new();
1660        let result = engine.load(
1661            "spec t\ndata x: number -> default \"10\"]\nrule r: x",
1662            SourceType::Labeled("t.lemma"),
1663        );
1664        assert!(
1665            result.is_err(),
1666            "must reject text literal \"10\" as default for number type"
1667        );
1668    }
1669
1670    #[test]
1671    fn planning_rejects_invalid_boolean_default() {
1672        let mut engine = Engine::new();
1673        let result = engine.load(
1674            "spec t\ndata x: [boolean -> default \"maybe\"]\nrule r: x",
1675            SourceType::Labeled("t.lemma"),
1676        );
1677        assert!(
1678            result.is_err(),
1679            "must reject non-boolean default on boolean type"
1680        );
1681    }
1682
1683    #[test]
1684    fn planning_rejects_invalid_named_type_default() {
1685        // Named type: the parser can't validate this, only planning can.
1686        let mut engine = Engine::new();
1687        let result = engine.load("spec t\ndata custom: number -> minimum 0\ndata x: [custom -> default \"abc\"]\nrule r: x", SourceType::Labeled("t.lemma",));
1688        assert!(
1689            result.is_err(),
1690            "must reject non-numeric default on named number type"
1691        );
1692    }
1693}