Skip to main content

lemma/
engine.rs

1use crate::evaluation::Evaluator;
2use crate::parsing::ast::{DateTimeValue, LemmaRepository, LemmaSpec};
3use crate::parsing::source::SourceType;
4use crate::parsing::EffectiveDate;
5use crate::planning::{DataOverlay, DataValueInput, LemmaSpecSet, SpecSchema};
6use crate::{parse, Error, ResourceLimits, Response};
7use indexmap::IndexMap;
8use std::collections::HashMap;
9use std::sync::Arc;
10
11#[cfg(not(target_arch = "wasm32"))]
12use std::collections::HashSet;
13#[cfg(not(target_arch = "wasm32"))]
14use std::path::Path;
15
16/// Load failure: errors plus the source texts we attempted to load.
17#[derive(Debug, Clone)]
18pub struct Errors {
19    pub errors: Vec<Error>,
20    pub sources: HashMap<SourceType, String>,
21}
22
23impl Errors {
24    /// Iterate over the errors.
25    pub fn iter(&self) -> std::slice::Iter<'_, Error> {
26        self.errors.iter()
27    }
28}
29
30/// Repository name reserved for the embedded standard library (`repo lemma`, `spec units`).
31/// User [`Engine::load`] / [`Engine::load_batch`] must not target this name.
32pub const EMBEDDED_STDLIB_REPOSITORY: &str = "lemma";
33
34/// Collect `.lemma` source texts from filesystem paths (paths and one-level directories).
35/// Does not touch an [`Engine`]; pair with [`Engine::load`] / [`Engine::load_batch`].
36#[cfg(not(target_arch = "wasm32"))]
37pub fn collect_lemma_sources<P: AsRef<Path>>(
38    paths: &[P],
39) -> Result<HashMap<SourceType, String>, Errors> {
40    use std::fs;
41    use std::path::PathBuf;
42    use std::sync::Arc;
43
44    let mut sources = HashMap::new();
45    let mut seen = HashSet::<PathBuf>::new();
46
47    for path in paths {
48        let path = path.as_ref();
49        if path.is_file() {
50            if path.extension().is_none_or(|e| e != "lemma") {
51                continue;
52            }
53            let p = path.to_path_buf();
54            if seen.contains(&p) {
55                continue;
56            }
57            seen.insert(p.clone());
58            let content = fs::read_to_string(path).map_err(|e| Errors {
59                errors: vec![Error::request(
60                    format!("Cannot read '{}': {}", path.display(), e),
61                    None::<String>,
62                )],
63                sources: HashMap::new(),
64            })?;
65            sources.insert(SourceType::Path(Arc::new(p)), content);
66        } else if path.is_dir() {
67            let read_dir = fs::read_dir(path).map_err(|e| Errors {
68                errors: vec![Error::request(
69                    format!("Cannot read directory '{}': {}", path.display(), e),
70                    None::<String>,
71                )],
72                sources: HashMap::new(),
73            })?;
74            for entry_result in read_dir {
75                let entry = entry_result.map_err(|e| Errors {
76                    errors: vec![Error::request(
77                        format!("Cannot read directory entry in '{}': {}", path.display(), e),
78                        None::<String>,
79                    )],
80                    sources: HashMap::new(),
81                })?;
82                let p = entry.path();
83                if !p.is_file() || p.extension().is_none_or(|e| e != "lemma") {
84                    continue;
85                }
86                if seen.contains(&p) {
87                    continue;
88                }
89                seen.insert(p.clone());
90                let content = fs::read_to_string(&p).map_err(|e| Errors {
91                    errors: vec![Error::request(
92                        format!("Cannot read '{}': {}", p.display(), e),
93                        None::<String>,
94                    )],
95                    sources: HashMap::new(),
96                })?;
97                sources.insert(SourceType::Path(Arc::new(p)), content);
98            }
99        }
100    }
101
102    Ok(sources)
103}
104
105/// A loaded repository with all its spec sets.
106///
107/// Provenance: [`LemmaRepository::start_line`], [`LemmaRepository::source_type`], and each
108/// temporal [`LemmaSpec`] in the spec sets carries [`LemmaSpec::start_line`] and
109/// [`LemmaSpec::source_type`] from the parse of the `repo` / `spec` headers.
110#[derive(Debug, Clone, serde::Serialize)]
111pub struct ResolvedRepository {
112    pub repository: Arc<LemmaRepository>,
113    pub specs: Vec<LemmaSpecSet>,
114}
115
116// ─── Spec store with temporal resolution ──────────────────────────────
117
118/// Ordered store of specs keyed by `(repository, name)` and grouped into
119/// [`LemmaSpecSet`]s.
120///
121/// Specs with the same `(repository, name)` identity are ordered by `effective_from`.
122/// A spec version's temporal end is derived from the successor spec's `effective_from`, or
123/// `+∞`. The repository identity is preserved as `Arc<LemmaRepository>` — never via string
124/// prefixes on the spec name. Repository names include the `@` prefix when present
125/// (e.g. `"@org/repo"`). Dependency isolation is enforced at `insert_spec`: all specs
126/// in a repository must share the same `dependency` provenance ID.
127#[derive(Debug)]
128pub struct Context {
129    repositories: IndexMap<Arc<LemmaRepository>, IndexMap<String, LemmaSpecSet>>,
130    workspace: Arc<LemmaRepository>,
131}
132
133impl Default for Context {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl Context {
140    /// Empty workspace repository; specs are inserted via [`Self::insert_spec`].
141    pub fn new() -> Self {
142        let workspace = Arc::new(LemmaRepository::new(None));
143        let mut repositories = IndexMap::new();
144        repositories.insert(Arc::clone(&workspace), IndexMap::new());
145        Self {
146            repositories,
147            workspace,
148        }
149    }
150
151    /// Workspace-global grouping for every locally loaded spec. The single
152    /// namespace runtime APIs operate on (entry-point specs live here).
153    /// Stable identity across calls; `name = None`, `dependency = None`.
154    #[must_use]
155    pub fn workspace(&self) -> Arc<LemmaRepository> {
156        Arc::clone(&self.workspace)
157    }
158
159    /// Look up a repository by name without creating a new one.
160    #[must_use]
161    pub fn find_repository(&self, name: &str) -> Option<Arc<LemmaRepository>> {
162        let probe = Arc::new(LemmaRepository::new(Some(name.to_string())));
163        self.repositories
164            .get_key_value(&probe)
165            .map(|(k, _)| Arc::clone(k))
166    }
167
168    /// All spec sets, keyed by `(repository, name)`. Iteration order: repository first
169    /// (insertion order), then spec name ascending.
170    #[must_use]
171    pub fn repositories(&self) -> &IndexMap<Arc<LemmaRepository>, IndexMap<String, LemmaSpecSet>> {
172        &self.repositories
173    }
174
175    pub fn iter(&self) -> impl Iterator<Item = Arc<LemmaSpec>> + '_ {
176        self.repositories
177            .values()
178            .flat_map(|m| m.values())
179            .flat_map(|ss| ss.iter_specs())
180    }
181
182    /// Every loaded spec paired with its half-open
183    /// `[effective_from, effective_to)` validity range. Iteration order
184    /// matches [`Self::iter`].
185    pub fn iter_with_ranges(
186        &self,
187    ) -> impl Iterator<Item = (Arc<LemmaSpec>, Option<DateTimeValue>, Option<DateTimeValue>)> + '_
188    {
189        self.repositories
190            .values()
191            .flat_map(|m| m.values())
192            .flat_map(|ss| ss.iter_with_ranges())
193    }
194
195    /// Look up a spec set by `(repository, name)`. Returns `None` if no such spec set
196    /// is loaded.
197    #[must_use]
198    pub fn spec_set(&self, repository: &Arc<LemmaRepository>, name: &str) -> Option<&LemmaSpecSet> {
199        let canonical_name = crate::parsing::ast::ascii_lowercase_logical_name(name.to_string());
200        self.repositories
201            .get(repository)
202            .and_then(|m| m.get(&canonical_name))
203    }
204
205    /// All spec sets belonging to a repository. Panics if the repository is not in the map
206    /// (caller must ensure it was returned by this Context).
207    #[must_use]
208    pub(crate) fn spec_sets_for(&self, repository: &Arc<LemmaRepository>) -> Vec<LemmaSpecSet> {
209        self.repositories
210            .get(repository)
211            .expect("BUG: repository not in context")
212            .values()
213            .cloned()
214            .collect()
215    }
216
217    /// Insert a spec under repository`. Validates that an identical
218    /// `(repository, name, effective_from)` triple is not already loaded.
219    /// Insert a spec under `repository`. Enforces two invariants:
220    /// 1. Dependency isolation: all specs in a repo must share the same `dependency`
221    ///    provenance. A workspace repo cannot be merged with a dependency repo, and
222    ///    two different dependencies cannot contribute to the same repo name.
223    /// 2. No duplicate `(repository, name, effective_from)` triples.
224    pub fn insert_spec(
225        &mut self,
226        repository: Arc<LemmaRepository>,
227        spec: Arc<LemmaSpec>,
228    ) -> Result<(), Error> {
229        if let Some((existing_repo, _)) = self.repositories.get_key_value(&repository) {
230            if existing_repo.dependency != repository.dependency {
231                let repo_display = repository.name.as_deref().unwrap_or("(main)");
232                let existing_owner = match &existing_repo.dependency {
233                    None => "the workspace".to_string(),
234                    Some(id) => format!("dependency '{id}'"),
235                };
236                let new_owner = match &repository.dependency {
237                    None => "the workspace".to_string(),
238                    Some(id) => format!("dependency '{id}'"),
239                };
240                return Err(Error::validation_with_context(
241                    format!(
242                        "Repository '{repo_display}' was introduced by {existing_owner} but {new_owner} also declares it"
243                    ),
244                    None,
245                    Some("Each dependency's repositories must be unique across all loaded sources"),
246                    Some(Arc::clone(&spec)),
247                    None,
248                ));
249            }
250        }
251
252        let entry = self
253            .repositories
254            .entry(Arc::clone(&repository))
255            .or_default();
256        if entry
257            .get(&spec.name)
258            .is_some_and(|ss| ss.get_exact(spec.effective_from()).is_some())
259        {
260            return Err(Error::validation_with_context(
261                format!(
262                    "Duplicate spec '{}' (same repository, name and effective_from already in context)",
263                    spec.name
264                ),
265                None,
266                None::<String>,
267                Some(Arc::clone(&spec)),
268                None,
269            ));
270        }
271
272        let name = spec.name.clone();
273        let inserted = entry
274            .entry(name.clone())
275            .or_insert_with(|| LemmaSpecSet::new(repository, name))
276            .insert(spec);
277        debug_assert!(inserted);
278        Ok(())
279    }
280
281    pub fn remove_spec(
282        &mut self,
283        repository: &Arc<LemmaRepository>,
284        spec: &Arc<LemmaSpec>,
285    ) -> bool {
286        let Some(inner) = self.repositories.get_mut(repository) else {
287            return false;
288        };
289        let Some(ss) = inner.get_mut(&spec.name) else {
290            return false;
291        };
292        if !ss.remove(spec.effective_from()) {
293            return false;
294        }
295        if ss.is_empty() {
296            inner.shift_remove(&spec.name);
297        }
298        true
299    }
300
301    #[cfg(test)]
302    pub(crate) fn len(&self) -> usize {
303        self.repositories
304            .values()
305            .flat_map(|m| m.values())
306            .map(LemmaSpecSet::len)
307            .sum()
308    }
309}
310
311// ─── Engine ──────────────────────────────────────────────────────────
312
313/// Engine for evaluating Lemma rules.
314///
315/// Pure Rust implementation that evaluates Lemma specs directly from the AST.
316/// Uses pre-built execution plans that are self-contained and ready for evaluation.
317///
318/// The engine never performs network calls. External `@...` references must be
319/// pre-resolved before loading — either by including dependency sources
320/// in the source map or by calling `resolve_registry_references` separately
321/// (e.g. in a `lemma fetch` command).
322pub struct Engine {
323    /// Repository → spec name → resolved plans (ordered by `effective`; slice end from next plan).
324    plan_sets: HashMap<
325        Arc<crate::parsing::ast::LemmaRepository>,
326        HashMap<String, crate::planning::ExecutionPlanSet>,
327    >,
328    specs: Context,
329    evaluator: Evaluator,
330    limits: ResourceLimits,
331    total_expression_count: usize,
332}
333
334impl Default for Engine {
335    fn default() -> Self {
336        Self::new()
337    }
338}
339
340impl Engine {
341    pub fn new() -> Self {
342        Self::with_limits(ResourceLimits::default())
343    }
344
345    pub fn with_limits(limits: ResourceLimits) -> Self {
346        let mut engine = Self {
347            plan_sets: HashMap::new(),
348            specs: Context::new(),
349            evaluator: Evaluator,
350            limits,
351            total_expression_count: 0,
352        };
353        engine
354            .add_sources_inner(
355                HashMap::from([(SourceType::Volatile, crate::stdlib::UNITS_LEMMA.to_string())]),
356                Some("lemma"),
357                true,
358            )
359            .expect("BUG: embedded stdlib must load");
360        engine
361    }
362
363    pub fn specs(&self) -> &Context {
364        &self.specs
365    }
366
367    pub fn specs_mut(&mut self) -> &mut Context {
368        &mut self.specs
369    }
370
371    /// Resolve an optional effective datetime string for planning or evaluation.
372    ///
373    /// `None` or whitespace-only input resolves to [`DateTimeValue::now`].
374    /// Non-empty invalid strings return a request [`Error`].
375    pub fn resolve_effective(raw: Option<&str>) -> Result<DateTimeValue, Error> {
376        match raw {
377            Some(s) if !s.trim().is_empty() => s.trim().parse::<DateTimeValue>().map_err(|_| {
378                Error::request(
379                    format!(
380                        "Invalid effective value '{}'. Expected: YYYY, YYYY-MM, YYYY-MM-DD, or ISO 8601 datetime",
381                        s.trim()
382                    ),
383                    None::<String>,
384                )
385            }),
386            _ => Ok(DateTimeValue::now()),
387        }
388    }
389
390    fn apply_planning_result(&mut self, pr: crate::planning::PlanningResult) {
391        self.plan_sets.clear();
392        for r in &pr.results {
393            self.plan_sets
394                .entry(Arc::clone(&r.repository))
395                .or_default()
396                .insert(r.name.clone(), r.execution_plan_set());
397        }
398    }
399
400    /// Load one Lemma source (workspace; not a tagged dependency).
401    pub fn load(&mut self, code: impl Into<String>, source: SourceType) -> Result<(), Errors> {
402        self.load_batch(HashMap::from([(source, code.into())]), None)
403    }
404
405    /// Load many sources in one planning pass. Pairs are `(source_text, source_id)`.
406    ///
407    /// `dependency`: when `Some`, repositories parsed from these sources are tagged with that id
408    /// (same as previous `load(..., Some(id))` for path bundles).
409    pub fn load_batch(
410        &mut self,
411        sources: HashMap<SourceType, String>,
412        dependency: Option<&str>,
413    ) -> Result<(), Errors> {
414        self.add_sources_inner(sources, dependency, false)
415    }
416
417    fn validate_source_for_load(source: &SourceType) -> Result<(), Errors> {
418        match source {
419            SourceType::Path(p) if p.as_os_str().to_string_lossy().trim().is_empty() => {
420                Err(Errors {
421                    errors: vec![Error::request(
422                        "Source path must be non-empty",
423                        None::<String>,
424                    )],
425                    sources: HashMap::new(),
426                })
427            }
428            SourceType::Registry(repo) => {
429                if repo.name.as_deref().unwrap_or("").is_empty() {
430                    Err(Errors {
431                        errors: vec![Error::request(
432                            "Registry source identifier must be non-empty",
433                            None::<String>,
434                        )],
435                        sources: HashMap::new(),
436                    })
437                } else {
438                    Ok(())
439                }
440            }
441            _ => Ok(()),
442        }
443    }
444
445    fn reject_reserved_stdlib_repository(repository: &Arc<LemmaRepository>) -> Option<Error> {
446        if repository.name.as_deref() == Some(EMBEDDED_STDLIB_REPOSITORY) {
447            Some(Error::validation_with_context(
448                format!(
449                    "Repository '{EMBEDDED_STDLIB_REPOSITORY}' is reserved for the embedded standard library and cannot be loaded via load or load_batch; use '@lemma/std' for registry packages such as finance"
450                ),
451                None,
452                Some("Load registry dependencies as '@lemma/std', not the reserved 'lemma' stdlib repository".to_string()),
453                None,
454                None,
455            ))
456        } else {
457            None
458        }
459    }
460
461    fn add_sources_inner(
462        &mut self,
463        sources: HashMap<SourceType, String>,
464        dependency: Option<&str>,
465        embedded_stdlib: bool,
466    ) -> Result<(), Errors> {
467        for st in sources.keys() {
468            Self::validate_source_for_load(st)?;
469        }
470        if !embedded_stdlib {
471            let limits = &self.limits;
472            if sources.len() > limits.max_sources {
473                return Err(Errors {
474                    errors: vec![Error::resource_limit_exceeded(
475                        "max_sources",
476                        limits.max_sources.to_string(),
477                        sources.len().to_string(),
478                        "Reduce the number of paths or sources in one load",
479                        None::<crate::parsing::source::Source>,
480                        None,
481                        None,
482                    )],
483                    sources,
484                });
485            }
486            let total_loaded_bytes: usize = sources.values().map(|s| s.len()).sum();
487            if total_loaded_bytes > limits.max_loaded_bytes {
488                return Err(Errors {
489                    errors: vec![Error::resource_limit_exceeded(
490                        "max_loaded_bytes",
491                        limits.max_loaded_bytes.to_string(),
492                        total_loaded_bytes.to_string(),
493                        "Load fewer or smaller sources",
494                        None::<crate::parsing::source::Source>,
495                        None,
496                        None,
497                    )],
498                    sources,
499                });
500            }
501            for code in sources.values() {
502                if code.len() > limits.max_source_size_bytes {
503                    return Err(Errors {
504                        errors: vec![Error::resource_limit_exceeded(
505                            "max_source_size_bytes",
506                            limits.max_source_size_bytes.to_string(),
507                            code.len().to_string(),
508                            "Use a smaller source text or increase limit",
509                            None::<crate::parsing::source::Source>,
510                            None,
511                            None,
512                        )],
513                        sources,
514                    });
515                }
516            }
517        }
518
519        let parse_limits = if embedded_stdlib {
520            &ResourceLimits::default()
521        } else {
522            &self.limits
523        };
524        let mut errors: Vec<Error> = Vec::new();
525
526        for (source_id, code) in &sources {
527            match parse(code, source_id.clone(), parse_limits) {
528                Ok(result) => {
529                    if !embedded_stdlib {
530                        self.total_expression_count += result.expression_count;
531                        if self.total_expression_count > self.limits.max_total_expression_count {
532                            errors.push(Error::resource_limit_exceeded(
533                                "max_total_expression_count",
534                                self.limits.max_total_expression_count.to_string(),
535                                self.total_expression_count.to_string(),
536                                "Split logic across fewer sources or reduce expression complexity",
537                                None::<crate::parsing::source::Source>,
538                                None,
539                                None,
540                            ));
541                            return Err(Errors { errors, sources });
542                        }
543                    }
544                    if result.repositories.is_empty() {
545                        continue;
546                    }
547
548                    for (parsed_repo, specs) in &result.repositories {
549                        let repository_arc = if let Some(dep_id) = dependency {
550                            let repo_name = parsed_repo
551                                .name
552                                .clone()
553                                // Use the dependency id as the repository name for the dependency's workspace specs
554                                .or_else(|| Some(dep_id.to_string()));
555                            Arc::new(
556                                LemmaRepository::new(repo_name)
557                                    .with_dependency(dep_id)
558                                    .with_start_line(parsed_repo.start_line),
559                            )
560                        } else {
561                            Arc::clone(parsed_repo)
562                        };
563                        if !embedded_stdlib {
564                            if let Some(reserved_err) =
565                                Self::reject_reserved_stdlib_repository(&repository_arc)
566                            {
567                                let source = crate::parsing::source::Source::new(
568                                    source_id.clone(),
569                                    crate::parsing::ast::Span {
570                                        start: 0,
571                                        end: 0,
572                                        line: parsed_repo.start_line,
573                                        col: 0,
574                                    },
575                                );
576                                errors.push(Error::validation(
577                                    reserved_err.to_string(),
578                                    Some(source),
579                                    reserved_err.suggestion().map(str::to_string),
580                                ));
581                                continue;
582                            }
583                        }
584                        for spec in specs {
585                            match self
586                                .specs
587                                .insert_spec(Arc::clone(&repository_arc), Arc::new(spec.clone()))
588                            {
589                                Ok(()) => {}
590                                Err(e) => {
591                                    let source = crate::parsing::source::Source::new(
592                                        source_id.clone(),
593                                        crate::parsing::ast::Span {
594                                            start: 0,
595                                            end: 0,
596                                            line: spec.start_line,
597                                            col: 0,
598                                        },
599                                    );
600                                    errors.push(Error::validation(
601                                        e.to_string(),
602                                        Some(source),
603                                        None::<String>,
604                                    ));
605                                }
606                            }
607                        }
608                    }
609                }
610                Err(e) => errors.push(e),
611            }
612        }
613
614        let planning_result = crate::planning::plan(&self.specs, &self.limits);
615        for set_result in &planning_result.results {
616            for spec_result in &set_result.slice_results {
617                let ctx = Arc::clone(&spec_result.spec);
618                for err in &spec_result.errors {
619                    errors.push(err.clone().with_spec_context(Arc::clone(&ctx)));
620                }
621            }
622        }
623        self.apply_planning_result(planning_result);
624
625        if errors.is_empty() {
626            Ok(())
627        } else {
628            Err(Errors { errors, sources })
629        }
630    }
631
632    /// Active [`LemmaSpec`] slice for `name` at the resolved effective instant.
633    ///
634    /// When `effective` is `None`, uses the current time. The name must be unique
635    /// across loaded repositories at that instant (same rule as [`Self::get_plan`] with
636    /// `repo: None`). For repository scope or all temporal rows, use [`Self::get_workspace`]
637    /// or [`Self::get_repository`].
638    pub fn get_spec(
639        &self,
640        name: &str,
641        effective: Option<&DateTimeValue>,
642    ) -> Result<Arc<LemmaSpec>, Error> {
643        let effective_dt = self.effective_or_now(effective);
644        let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
645        let repository = self.specs.workspace();
646        let spec_set = self
647            .specs
648            .spec_set(&repository, name)
649            .ok_or_else(|| self.spec_not_found_error(name, &effective_dt))?;
650        spec_set
651            .spec_at(&instant)
652            .ok_or_else(|| self.spec_not_found_error(name, &effective_dt))
653    }
654
655    /// Every loaded repository in insertion order (workspace, embedded stdlib [`EMBEDDED_STDLIB_REPOSITORY`], dependencies).
656    ///
657    /// Each [`ResolvedRepository::repository`] and every [`LemmaSpec`] under [`ResolvedRepository::specs`]
658    /// includes source metadata (`start_line`, `source_type`) from load.
659    /// Inspectable stdlib text: [`Self::format_repository`] with `"lemma"`.
660    #[must_use]
661    pub fn list(&self) -> Vec<ResolvedRepository> {
662        self.specs
663            .repositories()
664            .iter()
665            .map(|(repo, inner)| ResolvedRepository {
666                repository: Arc::clone(repo),
667                specs: inner.values().cloned().collect(),
668            })
669            .collect()
670    }
671
672    /// Workspace-local repository (`name == None`).
673    #[must_use]
674    pub fn get_workspace(&self) -> ResolvedRepository {
675        let repo = self.specs.workspace();
676        let specs = self.specs.spec_sets_for(&repo);
677        ResolvedRepository {
678            repository: repo,
679            specs,
680        }
681    }
682
683    /// Resolve a loaded repository by qualifier string. Matches against
684    /// repository names (which include `@` when present).
685    pub fn get_repository(&self, qualifier: &str) -> Result<ResolvedRepository, Error> {
686        let q = qualifier.trim();
687        if q.is_empty() {
688            return Err(Error::request(
689                "Repository qualifier cannot be empty",
690                None::<String>,
691            ));
692        }
693        match self.specs.find_repository(q) {
694            Some(repo) => {
695                let specs = self.specs.spec_sets_for(&repo);
696                Ok(ResolvedRepository {
697                    repository: repo,
698                    specs,
699                })
700            }
701            None => Err(Error::request_not_found(
702                format!("Repository '{qualifier}' not loaded"),
703                Some(format!(
704                    "List repositories with `{}` after loading your workspace",
705                    "lemma list"
706                )),
707            )),
708        }
709    }
710
711    /// Canonical Lemma source for every spec in `repository`, formatted from the in-engine AST.
712    pub fn format_repository(&self, repository: &str) -> Result<String, Error> {
713        let resolved = self.get_repository(repository)?;
714        let mut specs: Vec<Arc<LemmaSpec>> = resolved
715            .specs
716            .iter()
717            .flat_map(|ss| ss.iter_specs())
718            .collect();
719        specs.sort_by(|a, b| {
720            a.name
721                .cmp(&b.name)
722                .then_with(|| a.effective_from.cmp(&b.effective_from))
723        });
724        let spec_refs: Vec<&LemmaSpec> = specs.iter().map(AsRef::as_ref).collect();
725        let body = crate::formatting::format_spec_refs(&spec_refs);
726        let mut out = String::new();
727        if let Some(name) = resolved.repository.name.as_deref() {
728            out.push_str("repo ");
729            out.push_str(name);
730            out.push_str("\n\n");
731        }
732        out.push_str(&body);
733        Ok(out)
734    }
735
736    /// Planning schema for `name`. When `repo` is `None`, the spec must be
737    /// unambiguous across all loaded repositories; when `Some`, scoped to that
738    /// repository qualifier (e.g. `"@org/pkg"`).
739    pub fn schema(
740        &self,
741        repo: Option<&str>,
742        spec: &str,
743        effective: Option<&DateTimeValue>,
744    ) -> Result<SpecSchema, Error> {
745        Ok(self
746            .get_plan(repo, spec, effective)?
747            .schema(&DataOverlay::default()))
748    }
749
750    /// Return the resource limits configured for this engine.
751    ///
752    /// Exposed so callers can pass the same limits to [`DataOverlay::resolve`].
753    pub fn limits(&self) -> &ResourceLimits {
754        &self.limits
755    }
756
757    /// Evaluate a spec. When `repo` is `None`, the spec must be unambiguous
758    /// across loaded repositories; when `Some`, scoped to that repository
759    /// qualifier (e.g. `"@org/pkg"`).
760    ///
761    /// When `explain` is `true`, each requested rule's [`RuleResult::explanation`]
762    /// contains a structural explanation tree; when `false`, explanations are omitted.
763    ///
764    /// `rules` scopes which rule results appear in the response. `None` means all local
765    /// rules. `Some(&[])` is an error. When `explain` is `true`, all local rules (and
766    /// nested `uses` rules needed for explanation resolution) are evaluated so explanation
767    /// trees can embed dependency rules.
768    pub fn run(
769        &self,
770        repo: Option<&str>,
771        spec: &str,
772        effective: Option<&DateTimeValue>,
773        data_values: HashMap<String, String>,
774        explain: bool,
775        rules: Option<&[String]>,
776    ) -> Result<Response, Error> {
777        let effective = self.effective_or_now(effective);
778        let plan = self.get_plan(repo, spec, Some(&effective))?;
779        let data_values: HashMap<String, DataValueInput> = data_values
780            .into_iter()
781            .map(|(k, v)| (k, DataValueInput::convenience(v)))
782            .collect();
783        self.run_plan(plan, Some(&effective), data_values, explain, rules)
784    }
785
786    /// Execution plan for `name`. When `repo` is `None`, the spec must be
787    /// unambiguous across all loaded repositories; when `Some`, scoped to that
788    /// repository qualifier (e.g. `"@org/pkg"`).
789    pub fn get_plan(
790        &self,
791        repo: Option<&str>,
792        name: &str,
793        effective: Option<&DateTimeValue>,
794    ) -> Result<&crate::planning::ExecutionPlan, Error> {
795        let effective_dt = self.effective_or_now(effective);
796        let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
797        let canonical_name = crate::parsing::ast::ascii_lowercase_logical_name(name.to_string());
798
799        let repository = match repo {
800            Some(q) => self.specs.find_repository(q).ok_or_else(|| {
801                Error::request_not_found(
802                    format!("Repository '{q}' not loaded"),
803                    Some("List repositories with `lemma list` after loading your workspace"),
804                )
805            })?,
806            None => self.specs.workspace(),
807        };
808
809        let Some(spec_set) = self.specs.spec_set(&repository, &canonical_name) else {
810            return Err(self.spec_not_found_in_repository_error(&repository, name, &effective_dt));
811        };
812
813        if spec_set.spec_at(&instant).is_none() {
814            return Err(self.spec_not_found_in_repository_error(&repository, name, &effective_dt));
815        }
816
817        let plan_set = self
818            .plan_sets
819            .get(&repository)
820            .and_then(|by_name| by_name.get(&canonical_name))
821            .ok_or_else(|| {
822                Error::request_not_found(
823                    format!("No execution plans for spec '{name}'"),
824                    Some("Ensure sources loaded and planning succeeded"),
825                )
826            })?;
827
828        plan_set.plan_at(&instant).ok_or_else(|| {
829            Error::request_not_found(
830                format!("No execution plan slice for spec '{name}' at effective {effective_dt}"),
831                None::<String>,
832            )
833        })
834    }
835
836    /// Run a plan from [`get_plan`]: apply data values and evaluate rules.
837    ///
838    /// Spec-declared defaults (`-> default ...`) are applied by the evaluator when
839    /// the caller does not supply a value for that data path.
840    ///
841    /// `rules`: `None` evaluates all local rules in the response; `Some(slice)` evaluates
842    /// only those names (`Some(&[])` errors). Each name in `Some` must be a local rule.
843    ///
844    /// When `explain` is `false`, only the VM scope runs (requested rules, or all local
845    /// when `None`). When `explain` is `true`, all local and nested `uses` rules run so
846    /// explanation trees have full `rule_results`, but only the response scope appears in
847    /// `response.results` and receives attached explanations.
848    pub fn run_plan(
849        &self,
850        plan: &crate::planning::ExecutionPlan,
851        effective: Option<&DateTimeValue>,
852        data_values: HashMap<String, DataValueInput>,
853        explain: bool,
854        rules: Option<&[String]>,
855    ) -> Result<Response, Error> {
856        let response_rules = plan.validated_response_rule_names(rules)?;
857        let effective = self.effective_or_now(effective);
858        let overlay = DataOverlay::resolve(plan, data_values, &self.limits)?;
859        crate::planning::execution_plan::validate_unit_index_references(plan)?;
860        let now_semantic = crate::planning::semantics::date_time_to_semantic(&effective);
861        let now_literal = crate::planning::semantics::LiteralValue {
862            value: crate::planning::semantics::ValueKind::Date(now_semantic),
863            lemma_type: crate::planning::semantics::primitive_date_arc().clone(),
864        };
865        let (mut response, context) =
866            self.evaluator
867                .evaluate(plan, &overlay, now_literal, explain, &response_rules);
868        if explain {
869            self.evaluator.explain(&mut response, plan, &context);
870        }
871        Ok(response)
872    }
873
874    pub fn remove(&mut self, name: &str, effective: Option<&DateTimeValue>) -> Result<(), Error> {
875        let effective = self.effective_or_now(effective);
876        let repository_arc = self.specs.workspace();
877        let spec_arc = self.get_spec(name, Some(&effective))?;
878        self.specs.remove_spec(&repository_arc, &spec_arc);
879        let pr = crate::planning::plan(&self.specs, &self.limits);
880        let planning_errs: Vec<Error> = pr
881            .results
882            .iter()
883            .flat_map(|r| r.errors().cloned())
884            .collect();
885        self.apply_planning_result(pr);
886        if let Some(e) = planning_errs.into_iter().next() {
887            return Err(e);
888        }
889        Ok(())
890    }
891
892    /// Build a "not found" error listing available temporal versions when the name exists
893    /// but no version covers the requested effective date.
894    fn spec_not_found_error(&self, spec_name: &str, effective: &DateTimeValue) -> Error {
895        let workspace = self.specs.workspace();
896        let available = match self.specs.spec_set(&workspace, spec_name) {
897            Some(ss) => ss.iter_specs().collect::<Vec<_>>(),
898            None => Vec::new(),
899        };
900        let msg = if available.is_empty() {
901            format!("Spec '{}' not found", spec_name)
902        } else {
903            let listing: Vec<String> = available
904                .iter()
905                .map(|s| match s.effective_from() {
906                    Some(dt) => format!("  {} (effective from {})", s.name, dt),
907                    None => format!("  {} (no effective_from)", s.name),
908                })
909                .collect();
910            format!(
911                "Spec '{}' not found for effective {}. Available versions:\n{}",
912                spec_name,
913                effective,
914                listing.join("\n")
915            )
916        };
917        Error::request_not_found(msg, None::<String>)
918    }
919
920    fn spec_not_found_in_repository_error(
921        &self,
922        repository: &LemmaRepository,
923        spec_name: &str,
924        effective: &DateTimeValue,
925    ) -> Error {
926        let repo_label = match &repository.name {
927            Some(n) => n.clone(),
928            None => "(workspace)".to_string(),
929        };
930        Error::request_not_found(
931            format!(
932                "Spec '{spec_name}' not found in repository {repo_label} at effective {effective}",
933            ),
934            Some("Try `lemma list`"),
935        )
936    }
937
938    /// Effective datetime for a request: `explicit` or now.
939    #[must_use]
940    fn effective_or_now(&self, effective: Option<&DateTimeValue>) -> DateTimeValue {
941        effective.cloned().unwrap_or_else(DateTimeValue::now)
942    }
943}
944
945#[cfg(test)]
946mod tests {
947    use super::*;
948
949    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
950        DateTimeValue {
951            year,
952            month,
953            day,
954            hour: 0,
955            minute: 0,
956            second: 0,
957            microsecond: 0,
958            timezone: None,
959            granularity: crate::literals::DateGranularity::Full,
960        }
961    }
962
963    fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
964        let mut spec = LemmaSpec::new(name.to_string());
965        spec.effective_from = crate::parsing::ast::EffectiveDate::from_option(effective_from);
966        spec
967    }
968
969    /// Context::iter returns specs in (name, effective_from) ascending order.
970    /// Same-name specs appear in temporal order; definition order in the source is irrelevant.
971    #[test]
972    fn list_specs_order_is_name_then_effective_from_ascending() {
973        let mut ctx = Context::new();
974        let repository = ctx.workspace();
975        let s_2026 = Arc::new(make_spec_with_range("mortgage", Some(date(2026, 1, 1))));
976        let s_2025 = Arc::new(make_spec_with_range("mortgage", Some(date(2025, 1, 1))));
977        ctx.insert_spec(Arc::clone(&repository), Arc::clone(&s_2026))
978            .unwrap();
979        ctx.insert_spec(Arc::clone(&repository), Arc::clone(&s_2025))
980            .unwrap();
981        let listed: Vec<_> = ctx
982            .spec_set(&repository, "mortgage")
983            .expect("mortgage set")
984            .iter_specs()
985            .collect();
986        assert_eq!(listed.len(), 2);
987        assert_eq!(listed[0].effective_from(), Some(&date(2025, 1, 1)));
988        assert_eq!(listed[1].effective_from(), Some(&date(2026, 1, 1)));
989    }
990
991    #[test]
992    fn get_spec_resolves_temporal_version_by_effective() {
993        let mut engine = Engine::new();
994        engine
995            .load(
996                r#"
997        spec pricing 2025-01-01
998        data x: 1
999        rule r: x
1000    "#,
1001                SourceType::Path(Arc::new(std::path::PathBuf::from("a.lemma"))),
1002            )
1003            .unwrap();
1004        engine
1005            .load(
1006                r#"
1007        spec pricing 2025-06-01
1008        data x: 2
1009        rule r: x
1010    "#,
1011                SourceType::Path(Arc::new(std::path::PathBuf::from("b.lemma"))),
1012            )
1013            .unwrap();
1014
1015        let jan = DateTimeValue {
1016            year: 2025,
1017            month: 1,
1018            day: 15,
1019            hour: 0,
1020            minute: 0,
1021            second: 0,
1022            microsecond: 0,
1023            timezone: None,
1024            granularity: crate::literals::DateGranularity::Full,
1025        };
1026        let jul = DateTimeValue {
1027            year: 2025,
1028            month: 7,
1029            day: 1,
1030            hour: 0,
1031            minute: 0,
1032            second: 0,
1033            microsecond: 0,
1034            timezone: None,
1035            granularity: crate::literals::DateGranularity::Full,
1036        };
1037
1038        let v1 = DateTimeValue {
1039            year: 2025,
1040            month: 1,
1041            day: 1,
1042            hour: 0,
1043            minute: 0,
1044            second: 0,
1045            microsecond: 0,
1046            timezone: None,
1047            granularity: crate::literals::DateGranularity::Full,
1048        };
1049        let v2 = DateTimeValue {
1050            year: 2025,
1051            month: 6,
1052            day: 1,
1053            hour: 0,
1054            minute: 0,
1055            second: 0,
1056            microsecond: 0,
1057            timezone: None,
1058            granularity: crate::literals::DateGranularity::Full,
1059        };
1060
1061        let s_jan = engine.get_spec("pricing", Some(&jan)).expect("jan spec");
1062        let s_jul = engine.get_spec("pricing", Some(&jul)).expect("jul spec");
1063        assert_eq!(s_jan.effective_from(), Some(&v1));
1064        assert_eq!(s_jul.effective_from(), Some(&v2));
1065    }
1066
1067    /// Every temporal row for a workspace spec name exposes half-open
1068    /// `[effective_from, effective_to)` via [`LemmaSpecSet::iter_with_ranges`]. The latest row's
1069    /// `effective_to` is `None` (no successor); earlier rows' `effective_to`
1070    /// equals the next row's `effective_from`.
1071    #[test]
1072    fn list_specs_returns_half_open_ranges_per_temporal_version() {
1073        let mut engine = Engine::new();
1074        engine
1075            .load(
1076                r#"
1077        spec pricing 2025-01-01
1078        data x: 1
1079        rule r: x
1080    "#,
1081                SourceType::Path(Arc::new(std::path::PathBuf::from("a.lemma"))),
1082            )
1083            .unwrap();
1084        engine
1085            .load(
1086                r#"
1087        spec pricing 2025-06-01
1088        data x: 2
1089        rule r: x
1090    "#,
1091                SourceType::Path(Arc::new(std::path::PathBuf::from("b.lemma"))),
1092            )
1093            .unwrap();
1094
1095        let january = date(2025, 1, 1);
1096        let june = date(2025, 6, 1);
1097
1098        let workspace = engine.get_workspace();
1099        let pricing_set = workspace
1100            .specs
1101            .iter()
1102            .find(|ss| ss.name == "pricing")
1103            .expect("pricing spec set exists");
1104        let mut ranges: Vec<(Option<DateTimeValue>, Option<DateTimeValue>)> = pricing_set
1105            .iter_with_ranges()
1106            .map(|(_, from, to)| (from, to))
1107            .collect();
1108        ranges.sort_by(|a, b| match (&a.0, &b.0) {
1109            (Some(x), Some(y)) => x.cmp(y),
1110            (None, Some(_)) => std::cmp::Ordering::Less,
1111            (Some(_), None) => std::cmp::Ordering::Greater,
1112            (None, None) => std::cmp::Ordering::Equal,
1113        });
1114        assert_eq!(ranges.len(), 2);
1115        assert_eq!(
1116            ranges[0],
1117            (Some(january.clone()), Some(june.clone())),
1118            "earlier row ends at the next row's effective_from"
1119        );
1120        assert_eq!(
1121            ranges[1],
1122            (Some(june.clone()), None),
1123            "latest row has no successor; effective_to is None"
1124        );
1125
1126        assert!(
1127            !engine
1128                .get_workspace()
1129                .specs
1130                .iter()
1131                .any(|ss| ss.name == "unknown"),
1132            "no rows for unknown spec"
1133        );
1134    }
1135
1136    /// `Engine::get_workspace()` provides spec sets grouped by name.
1137    /// Each spec set exposes half-open `[effective_from, effective_to)` ranges.
1138    #[test]
1139    fn get_workspace_specs_with_half_open_ranges() {
1140        let mut engine = Engine::new();
1141        engine
1142            .load(
1143                r#"
1144        spec pricing 2025-01-01
1145        data x: 1
1146        rule r: x
1147    "#,
1148                SourceType::Path(Arc::new(std::path::PathBuf::from("pricing_v1.lemma"))),
1149            )
1150            .unwrap();
1151        engine
1152            .load(
1153                r#"
1154        spec pricing 2026-01-01
1155        data x: 2
1156        rule r: x
1157    "#,
1158                SourceType::Path(Arc::new(std::path::PathBuf::from("pricing_v2.lemma"))),
1159            )
1160            .unwrap();
1161        engine
1162            .load(
1163                r#"
1164        spec taxes
1165        data rate: 0.21
1166        rule amount: rate
1167    "#,
1168                SourceType::Path(Arc::new(std::path::PathBuf::from("taxes.lemma"))),
1169            )
1170            .unwrap();
1171
1172        let workspace = engine.get_workspace();
1173        assert_eq!(workspace.specs.len(), 2, "two spec sets: pricing and taxes");
1174
1175        let pricing_set = workspace
1176            .specs
1177            .iter()
1178            .find(|ss| ss.name == "pricing")
1179            .expect("pricing spec set exists");
1180        let ranges: Vec<_> = pricing_set.iter_with_ranges().collect();
1181        assert_eq!(ranges.len(), 2);
1182        assert_eq!(ranges[0].1, Some(date(2025, 1, 1)));
1183        assert_eq!(
1184            ranges[0].2,
1185            Some(date(2026, 1, 1)),
1186            "earlier pricing row ends at the next pricing row's effective_from"
1187        );
1188        assert_eq!(ranges[1].1, Some(date(2026, 1, 1)));
1189        assert_eq!(
1190            ranges[1].2, None,
1191            "latest pricing row has no successor; effective_to is None"
1192        );
1193
1194        let taxes_set = workspace
1195            .specs
1196            .iter()
1197            .find(|ss| ss.name == "taxes")
1198            .expect("taxes spec set exists");
1199        let tax_ranges: Vec<_> = taxes_set.iter_with_ranges().collect();
1200        assert_eq!(tax_ranges.len(), 1);
1201        assert_eq!(
1202            tax_ranges[0].1, None,
1203            "unversioned spec has no declared effective_from"
1204        );
1205        assert_eq!(
1206            tax_ranges[0].2, None,
1207            "unversioned spec has no successor; effective_to is None"
1208        );
1209    }
1210
1211    #[test]
1212    fn test_evaluate_spec_all_rules() {
1213        let mut engine = Engine::new();
1214        engine
1215            .load(
1216                r#"
1217        spec test
1218        data x: 10
1219        data y: 5
1220        rule sum: x + y
1221        rule product: x * y
1222    "#,
1223                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1224            )
1225            .unwrap();
1226
1227        let now = DateTimeValue::now();
1228        let response = engine
1229            .run(None, "test", Some(&now), HashMap::new(), false, None)
1230            .unwrap();
1231        assert_eq!(response.results.len(), 2);
1232
1233        let sum_result = response
1234            .results
1235            .values()
1236            .find(|r| r.rule.name == "sum")
1237            .unwrap();
1238        assert_eq!(sum_result.display.clone().expect("display"), "15");
1239
1240        let product_result = response
1241            .results
1242            .values()
1243            .find(|r| r.rule.name == "product")
1244            .unwrap();
1245        assert_eq!(product_result.display.clone().expect("display"), "50");
1246    }
1247
1248    #[test]
1249    fn test_evaluate_empty_data() {
1250        let mut engine = Engine::new();
1251        engine
1252            .load(
1253                r#"
1254        spec test
1255        data price: 100
1256        rule total: price * 2
1257    "#,
1258                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1259            )
1260            .unwrap();
1261
1262        let now = DateTimeValue::now();
1263        let response = engine
1264            .run(None, "test", Some(&now), HashMap::new(), false, None)
1265            .unwrap();
1266        assert_eq!(response.results.len(), 1);
1267        assert_eq!(
1268            response
1269                .results
1270                .values()
1271                .next()
1272                .unwrap()
1273                .display
1274                .clone()
1275                .expect("display"),
1276            "200"
1277        );
1278    }
1279
1280    #[test]
1281    fn test_evaluate_boolean_rule() {
1282        let mut engine = Engine::new();
1283        engine
1284            .load(
1285                r#"
1286        spec test
1287        data age: 25
1288        rule is_adult: age >= 18
1289    "#,
1290                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1291            )
1292            .unwrap();
1293
1294        let now = DateTimeValue::now();
1295        let response = engine
1296            .run(None, "test", Some(&now), HashMap::new(), false, None)
1297            .unwrap();
1298        assert_eq!(
1299            response.results.values().next().unwrap().boolean,
1300            Some(true)
1301        );
1302    }
1303
1304    #[test]
1305    fn test_evaluate_with_unless_clause() {
1306        let mut engine = Engine::new();
1307        engine
1308            .load(
1309                r#"
1310        spec test
1311        data quantity: 15
1312        rule discount: 0
1313          unless quantity >= 10 then 10
1314    "#,
1315                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1316            )
1317            .unwrap();
1318
1319        let now = DateTimeValue::now();
1320        let response = engine
1321            .run(None, "test", Some(&now), HashMap::new(), false, None)
1322            .unwrap();
1323        assert_eq!(
1324            response
1325                .results
1326                .values()
1327                .next()
1328                .unwrap()
1329                .display
1330                .clone()
1331                .expect("display"),
1332            "10"
1333        );
1334    }
1335
1336    #[test]
1337    fn test_spec_not_found() {
1338        let engine = Engine::new();
1339        let now = DateTimeValue::now();
1340        let result = engine.run(None, "nonexistent", Some(&now), HashMap::new(), false, None);
1341        assert!(result.is_err());
1342        assert!(result.unwrap_err().to_string().contains("not found"));
1343    }
1344
1345    #[test]
1346    fn test_multiple_specs() {
1347        let mut engine = Engine::new();
1348        engine
1349            .load(
1350                r#"
1351        spec spec1
1352        data x: 10
1353        rule result: x * 2
1354    "#,
1355                SourceType::Path(Arc::new(std::path::PathBuf::from("spec 1.lemma"))),
1356            )
1357            .unwrap();
1358
1359        engine
1360            .load(
1361                r#"
1362        spec spec2
1363        data y: 5
1364        rule result: y * 3
1365    "#,
1366                SourceType::Path(Arc::new(std::path::PathBuf::from("spec 2.lemma"))),
1367            )
1368            .unwrap();
1369
1370        let now = DateTimeValue::now();
1371        let response1 = engine
1372            .run(None, "spec1", Some(&now), HashMap::new(), false, None)
1373            .unwrap();
1374        assert_eq!(response1.results[0].display.clone().expect("display"), "20");
1375        let response2 = engine
1376            .run(None, "spec2", Some(&now), HashMap::new(), false, None)
1377            .unwrap();
1378        assert_eq!(response2.results[0].display.clone().expect("display"), "15");
1379    }
1380
1381    #[test]
1382    fn test_runtime_error_mapping() {
1383        let mut engine = Engine::new();
1384        engine
1385            .load(
1386                r#"
1387        spec test
1388        data numerator: 10
1389        data denominator: 0
1390        rule division: numerator / denominator
1391    "#,
1392                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1393            )
1394            .unwrap();
1395
1396        let now = DateTimeValue::now();
1397        let result = engine.run(None, "test", Some(&now), HashMap::new(), false, None);
1398        // Division by zero returns a Veto (not an error)
1399        assert!(result.is_ok(), "Evaluation should succeed");
1400        let response = result.unwrap();
1401        let division_result = response
1402            .results
1403            .values()
1404            .find(|r| r.rule.name == "division");
1405        assert!(
1406            division_result.is_some(),
1407            "Should have division rule result"
1408        );
1409        let division = division_result.unwrap();
1410        assert!(division.vetoed);
1411        assert!(
1412            division
1413                .veto_reason
1414                .as_deref()
1415                .unwrap()
1416                .contains("Division by zero"),
1417            "Veto message should mention division by zero: {:?}",
1418            division.veto_reason
1419        );
1420    }
1421
1422    #[test]
1423    fn test_rules_sorted_by_source_order() {
1424        let mut engine = Engine::new();
1425        engine
1426            .load(
1427                r#"
1428        spec test
1429        data a: 1
1430        data b: 2
1431        rule z: a + b
1432        rule y: a * b
1433        rule x: a - b
1434    "#,
1435                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1436            )
1437            .unwrap();
1438
1439        let now = DateTimeValue::now();
1440        let response = engine
1441            .run(None, "test", Some(&now), HashMap::new(), false, None)
1442            .unwrap();
1443        assert_eq!(response.results.len(), 3);
1444
1445        // Verify source positions increase (z < y < x)
1446        let z_pos = response
1447            .results
1448            .values()
1449            .find(|r| r.rule.name == "z")
1450            .unwrap()
1451            .rule
1452            .source_location
1453            .span
1454            .start;
1455        let y_pos = response
1456            .results
1457            .values()
1458            .find(|r| r.rule.name == "y")
1459            .unwrap()
1460            .rule
1461            .source_location
1462            .span
1463            .start;
1464        let x_pos = response
1465            .results
1466            .values()
1467            .find(|r| r.rule.name == "x")
1468            .unwrap()
1469            .rule
1470            .source_location
1471            .span
1472            .start;
1473
1474        assert!(z_pos < y_pos);
1475        assert!(y_pos < x_pos);
1476    }
1477
1478    #[test]
1479    fn test_rule_filtering_evaluates_dependencies() {
1480        let mut engine = Engine::new();
1481        engine
1482            .load(
1483                r#"
1484        spec test
1485        data base: 100
1486        rule subtotal: base * 2
1487        rule tax: subtotal * 10%
1488        rule total: subtotal + tax
1489    "#,
1490                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1491            )
1492            .unwrap();
1493
1494        let now = DateTimeValue::now();
1495        let response = engine
1496            .run(
1497                None,
1498                "test",
1499                Some(&now),
1500                HashMap::new(),
1501                false,
1502                Some(&["total".to_string()]),
1503            )
1504            .unwrap();
1505
1506        assert_eq!(response.results.len(), 1);
1507        assert_eq!(response.results.keys().next().unwrap(), "total");
1508
1509        // But the value should be correct (dependencies were computed)
1510        let total = response.results.values().next().unwrap();
1511        assert_eq!(total.display.clone().expect("display"), "220");
1512    }
1513
1514    // -------------------------------------------------------------------
1515    // Pre-resolved dependency tests (Engine never fetches from registry)
1516    // -------------------------------------------------------------------
1517
1518    use crate::parsing::ast::DateTimeValue;
1519
1520    #[test]
1521    fn pre_resolved_deps_in_file_map_evaluates_external_spec() {
1522        let mut engine = Engine::new();
1523
1524        engine
1525            .load_batch(
1526                HashMap::from([(
1527                    SourceType::Volatile,
1528                    "repo @org/project\nspec helper\ndata quantity: 42".to_string(),
1529                )]),
1530                Some("@org/project"),
1531            )
1532            .expect("should load dependency files");
1533
1534        engine
1535            .load(
1536                r#"spec main_spec
1537uses external: @org/project helper
1538rule value: external.quantity"#,
1539                SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1540            )
1541            .expect("should succeed with pre-resolved deps");
1542
1543        let now = DateTimeValue::now();
1544        let response = engine
1545            .run(None, "main_spec", Some(&now), HashMap::new(), false, None)
1546            .expect("evaluate should succeed");
1547
1548        let value_result = response
1549            .results
1550            .get("value")
1551            .expect("rule 'value' should exist");
1552        assert_eq!(value_result.display.clone().expect("display"), "42");
1553    }
1554
1555    #[test]
1556    fn schema_with_repo_resolves_registry_spec() {
1557        let mut engine = Engine::new();
1558        engine
1559            .load_batch(
1560                HashMap::from([(
1561                    SourceType::Volatile,
1562                    "repo @org/project\nspec helper\ndata quantity: 42\nrule expose: quantity"
1563                        .to_string(),
1564                )]),
1565                Some("@org/project"),
1566            )
1567            .expect("registry bundle loads");
1568
1569        engine
1570            .load(
1571                r#"spec main_spec
1572data x: 1"#,
1573                SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1574            )
1575            .expect("main loads");
1576
1577        let now = DateTimeValue::now();
1578        let schema = engine
1579            .schema(Some("@org/project"), "helper", Some(&now))
1580            .expect("schema for registry spec");
1581        assert!(schema.data.contains_key("quantity"));
1582    }
1583
1584    #[test]
1585    fn load_no_external_refs_works() {
1586        let mut engine = Engine::new();
1587
1588        engine
1589            .load(
1590                r#"spec local_only
1591data price: 100
1592rule doubled: price * 2"#,
1593                SourceType::Path(Arc::new(std::path::PathBuf::from("local.lemma"))),
1594            )
1595            .expect("should succeed when there are no @... references");
1596
1597        let now = DateTimeValue::now();
1598        let response = engine
1599            .run(None, "local_only", Some(&now), HashMap::new(), false, None)
1600            .expect("evaluate should succeed");
1601
1602        let doubled = response.results.get("doubled").expect("doubled rule");
1603        assert_eq!(doubled.display.clone().expect("display"), "200");
1604    }
1605
1606    #[test]
1607    fn unresolved_external_ref_without_deps_fails() {
1608        let mut engine = Engine::new();
1609
1610        let result = engine.load(
1611            r#"spec main_spec
1612uses external: @org/project missing
1613rule value: external.quantity"#,
1614            SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1615        );
1616
1617        let errs = result.expect_err("Should fail when registry dep is not loaded");
1618        assert!(
1619            errs.iter()
1620                .any(|e| e.kind() == crate::ErrorKind::MissingRepository),
1621            "expected MissingRepository, got: {:?}",
1622            errs.iter().map(|e| e.kind()).collect::<Vec<_>>()
1623        );
1624    }
1625
1626    #[test]
1627    fn pre_resolved_deps_with_spec_and_type_refs() {
1628        let mut engine = Engine::new();
1629
1630        engine
1631            .load_batch(
1632                HashMap::from([(
1633                    SourceType::Volatile,
1634                    "repo @org/example\nspec helper\ndata value: 42".to_string(),
1635                )]),
1636                Some("@org/example"),
1637            )
1638            .expect("should load helper file");
1639
1640        engine
1641            .load_batch(
1642                HashMap::from([(
1643                    SourceType::Volatile,
1644                    "repo @lemma/std\nspec finance\ndata money: quantity\n -> unit eur 1.00\n -> decimals 2".to_string(),
1645                )]),
1646                Some("@lemma/std"),
1647            )
1648            .expect("should load finance file");
1649
1650        engine
1651            .load(
1652                r#"spec registry_demo
1653uses @lemma/std finance
1654data money: finance.money
1655data unit_price: 5 eur
1656uses @org/example helper
1657rule helper_value: helper.value
1658rule line_total: unit_price * 2
1659rule formatted: helper_value + 0"#,
1660                SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1661            )
1662            .expect("should succeed with pre-resolved spec and type deps");
1663
1664        let now = DateTimeValue::now();
1665        let response = engine
1666            .run(
1667                None,
1668                "registry_demo",
1669                Some(&now),
1670                HashMap::new(),
1671                false,
1672                None,
1673            )
1674            .expect("evaluate should succeed");
1675
1676        assert_eq!(
1677            response
1678                .results
1679                .get("helper_value")
1680                .expect("helper_value")
1681                .display
1682                .clone()
1683                .expect("display"),
1684            "42"
1685        );
1686        let line = response
1687            .results
1688            .get("line_total")
1689            .expect("line_total")
1690            .display
1691            .clone()
1692            .expect("display");
1693        assert!(
1694            line.contains("10") && line.to_lowercase().contains("eur"),
1695            "5 eur * 2 => ~10 eur, got {line}"
1696        );
1697        assert_eq!(
1698            response
1699                .results
1700                .get("formatted")
1701                .expect("formatted")
1702                .display
1703                .clone()
1704                .expect("display"),
1705            "42"
1706        );
1707    }
1708
1709    #[test]
1710    fn load_empty_labeled_source_is_error() {
1711        let mut engine = Engine::new();
1712        let err = engine
1713            .load(
1714                "spec x\ndata a: 1",
1715                SourceType::Path(Arc::new(std::path::PathBuf::from("  "))),
1716            )
1717            .unwrap_err();
1718        assert!(err.errors.iter().any(|e| e.message().contains("non-empty")));
1719    }
1720
1721    #[test]
1722    fn add_dependency_files_accepts_registry_bundle_specs() {
1723        let mut engine = Engine::new();
1724        engine
1725            .load_batch(
1726                HashMap::from([(
1727                    SourceType::Volatile,
1728                    "repo @org/my\nspec helper\ndata x: 1".to_string(),
1729                )]),
1730                Some("@org/my"),
1731            )
1732            .expect("dependency bundle specs should be accepted");
1733    }
1734
1735    #[test]
1736    fn user_load_rejects_reserved_embedded_stdlib_repository() {
1737        let mut engine = Engine::new();
1738        let batch = engine.load_batch(
1739            HashMap::from([(
1740                SourceType::Volatile,
1741                "spec finance\ndata money: ratio -> decimals 2".to_string(),
1742            )]),
1743            Some(EMBEDDED_STDLIB_REPOSITORY),
1744        );
1745        assert!(
1746            batch.is_err(),
1747            "load_batch must not write reserved lemma stdlib repo"
1748        );
1749        let msg = batch
1750            .unwrap_err()
1751            .errors
1752            .iter()
1753            .map(ToString::to_string)
1754            .collect::<Vec<_>>()
1755            .join("\n");
1756        assert!(
1757            msg.contains(EMBEDDED_STDLIB_REPOSITORY) && msg.contains("reserved"),
1758            "expected reserved-repo error, got: {msg}"
1759        );
1760
1761        let workspace = engine.load("repo lemma\nspec x\ndata a: 1", SourceType::Volatile);
1762        assert!(workspace.is_err(), "workspace repo lemma must be rejected");
1763        let msg = workspace
1764            .unwrap_err()
1765            .errors
1766            .iter()
1767            .map(ToString::to_string)
1768            .collect::<Vec<_>>()
1769            .join("\n");
1770        assert!(
1771            msg.contains(EMBEDDED_STDLIB_REPOSITORY) && msg.contains("reserved"),
1772            "expected reserved-repo error, got: {msg}"
1773        );
1774    }
1775
1776    #[test]
1777    fn dependency_cannot_merge_with_workspace_repo() {
1778        let mut engine = Engine::new();
1779        engine
1780            .load(
1781                "repo billing\nspec local_billing\ndata x: 1",
1782                SourceType::Path(Arc::new(std::path::PathBuf::from("local.lemma"))),
1783            )
1784            .expect("workspace load");
1785
1786        let result = engine.load_batch(
1787            HashMap::from([(
1788                SourceType::Volatile,
1789                "repo billing\nspec dep_billing\ndata y: 2".to_string(),
1790            )]),
1791            Some("@evil/pkg"),
1792        );
1793        assert!(
1794            result.is_err(),
1795            "dependency declaring same repo name as workspace must be rejected"
1796        );
1797        let msg = result
1798            .unwrap_err()
1799            .errors
1800            .iter()
1801            .map(ToString::to_string)
1802            .collect::<Vec<_>>()
1803            .join("\n");
1804        assert!(
1805            msg.contains("billing") && msg.contains("workspace"),
1806            "error should mention repo name and workspace provenance, got: {msg}"
1807        );
1808    }
1809
1810    #[test]
1811    fn load_rejects_empty_registry_source_identifier() {
1812        let mut engine = Engine::new();
1813        let result = engine.load(
1814            "spec helper\ndata x: 1",
1815            SourceType::Registry(Arc::new(LemmaRepository::new(Some("".to_string())))),
1816        );
1817        assert!(
1818            result.is_err(),
1819            "empty registry dependency source identifier must be rejected"
1820        );
1821    }
1822
1823    #[test]
1824    fn load_dependency_accepts_split_bundles() {
1825        let mut engine = Engine::new();
1826        engine
1827            .load_batch(
1828                HashMap::from([(
1829                    SourceType::Volatile,
1830                    "repo @org/rates\nspec rates\ndata rate: 10".to_string(),
1831                )]),
1832                Some("@org/rates"),
1833            )
1834            .expect("rates bundle should load");
1835        engine
1836            .load_batch(
1837                HashMap::from([(
1838                    SourceType::Volatile,
1839                    "repo @org/billing\nspec billing\nuses @org/rates rates".to_string(),
1840                )]),
1841                Some("@org/billing"),
1842            )
1843            .expect("billing bundle should load");
1844    }
1845
1846    #[test]
1847    fn load_returns_all_errors_not_just_first() {
1848        let mut engine = Engine::new();
1849
1850        let result = engine.load(
1851            r#"spec demo
1852uses type_src: nonexistent_type_source
1853with type_src.amount: 10
1854uses helper: nonexistent_spec
1855data price: 10
1856rule total: helper.value + price"#,
1857            SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1858        );
1859
1860        assert!(result.is_err(), "Should fail with multiple errors");
1861        let load_err = result.unwrap_err();
1862        assert!(
1863            load_err.errors.len() >= 2,
1864            "expected at least 2 errors (type + spec ref), got {}",
1865            load_err.errors.len()
1866        );
1867        let error_message = load_err
1868            .errors
1869            .iter()
1870            .map(ToString::to_string)
1871            .collect::<Vec<_>>()
1872            .join("; ");
1873
1874        assert!(
1875            error_message.contains("nonexistent_type_source"),
1876            "Should mention data import source spec. Got:\n{}",
1877            error_message
1878        );
1879        assert!(
1880            error_message.contains("nonexistent_spec"),
1881            "Should mention spec reference error about 'nonexistent_spec'. Got:\n{}",
1882            error_message
1883        );
1884    }
1885
1886    // ── Default value type validation ────────────────────────────────
1887    // Planning must reject default values that don't match the type.
1888    // These tests cover both primitives and named types (which the parser
1889    // can't validate because it doesn't resolve type names).
1890
1891    #[test]
1892    fn planning_rejects_invalid_number_default() {
1893        let mut engine = Engine::new();
1894        let result = engine.load(
1895            "spec t\ndata x: number -> default \"10 $$\"]\nrule r: x",
1896            SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
1897        );
1898        assert!(
1899            result.is_err(),
1900            "must reject non-numeric default on number type"
1901        );
1902    }
1903
1904    #[test]
1905    fn planning_rejects_text_literal_as_number_default() {
1906        // `default "10"` produces a typed `CommandArg::Literal(Value::Text("10"))`.
1907        // Planning matches on the literal's variant — a `Text` literal is rejected
1908        // where a `Number` literal is required, even though `"10"` would parse as
1909        // a valid `Decimal` if coerced.
1910        let mut engine = Engine::new();
1911        let result = engine.load(
1912            "spec t\ndata x: number -> default \"10\"]\nrule r: x",
1913            SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
1914        );
1915        assert!(
1916            result.is_err(),
1917            "must reject text literal \"10\" as default for number type"
1918        );
1919    }
1920
1921    #[test]
1922    fn planning_rejects_invalid_boolean_default() {
1923        let mut engine = Engine::new();
1924        let result = engine.load(
1925            "spec t\ndata x: [boolean -> default \"maybe\"]\nrule r: x",
1926            SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
1927        );
1928        assert!(
1929            result.is_err(),
1930            "must reject non-boolean default on boolean type"
1931        );
1932    }
1933
1934    #[test]
1935    fn planning_rejects_invalid_named_type_default() {
1936        // Named type: the parser can't validate this, only planning can.
1937        let mut engine = Engine::new();
1938        let result = engine.load("spec t\ndata custom: number -> minimum 0\ndata x: [custom -> default \"abc\"]\nrule r: x", SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))));
1939        assert!(
1940            result.is_err(),
1941            "must reject non-numeric default on named number type"
1942        );
1943    }
1944
1945    #[test]
1946    fn context_merges_cross_file_repo_identities() {
1947        let mut engine = Engine::new();
1948
1949        // Load two files with the same named repo, but different spec names.
1950        engine
1951            .load(
1952                "repo shared\nspec a\ndata x: 1",
1953                SourceType::Path(Arc::new(std::path::PathBuf::from("file1.lemma"))),
1954            )
1955            .expect("first file should load");
1956
1957        engine
1958            .load(
1959                "repo shared\nspec b\ndata y: 2",
1960                SourceType::Path(Arc::new(std::path::PathBuf::from("file2.lemma"))),
1961            )
1962            .expect("second file should load");
1963
1964        // Both specs should land under the same repo entry.
1965        // Workspace, embedded stdlib (`lemma`), plus the "shared" repo.
1966        assert_eq!(
1967            engine.specs.repositories().len(),
1968            3,
1969            "should have workspace, stdlib repository, and one named user repository"
1970        );
1971
1972        let shared_repo = engine
1973            .specs
1974            .find_repository("shared")
1975            .expect("shared repo should exist");
1976        let shared_specs = engine.specs.repositories().get(&shared_repo).unwrap();
1977        assert_eq!(
1978            shared_specs.len(),
1979            2,
1980            "shared repo should contain both specs"
1981        );
1982        assert!(shared_specs.contains_key("a"));
1983        assert!(shared_specs.contains_key("b"));
1984
1985        // Loading a dependency with the same repo name should be rejected.
1986        let result = engine.load_batch(
1987            HashMap::from([(
1988                SourceType::Volatile,
1989                "repo shared\nspec c\ndata z: 3".to_string(),
1990            )]),
1991            Some("@some/dep"),
1992        );
1993        assert!(
1994            result.is_err(),
1995            "dependency repo with same name as workspace repo must be rejected"
1996        );
1997    }
1998
1999    #[test]
2000    fn context_rejects_duplicate_spec_in_same_repo_across_files() {
2001        let mut engine = Engine::new();
2002
2003        engine
2004            .load(
2005                "repo shared\nspec a\ndata x: 1",
2006                SourceType::Path(Arc::new(std::path::PathBuf::from("file1.lemma"))),
2007            )
2008            .expect("first file should load");
2009
2010        let result = engine.load(
2011            "repo shared\nspec a\ndata y: 2",
2012            SourceType::Path(Arc::new(std::path::PathBuf::from("file2.lemma"))),
2013        );
2014
2015        assert!(
2016            result.is_err(),
2017            "should reject duplicate spec name in same repo"
2018        );
2019        let err_msg = result.unwrap_err().errors[0].to_string();
2020        assert!(
2021            err_msg.contains("Duplicate spec 'a'"),
2022            "error should mention duplicate spec"
2023        );
2024    }
2025
2026    #[test]
2027    fn test_list_serialization() {
2028        let mut engine = Engine::new();
2029        engine
2030            .load(
2031                "repo shared\nspec a\ndata x: 1\nrule r: x",
2032                SourceType::Path(Arc::new(std::path::PathBuf::from("file1.lemma"))),
2033            )
2034            .expect("file should load");
2035
2036        let repos = engine.list();
2037        let json = serde_json::to_string(&repos).expect("should serialize");
2038
2039        // Should include expected nesting
2040        assert!(json.contains("\"repository\""));
2041        assert!(json.contains("\"name\":\"shared\""));
2042        assert!(json.contains("\"specs\""));
2043        assert!(json.contains("\"name\":\"a\""));
2044        assert!(json.contains("\"data\""));
2045        assert!(json.contains("\"rules\""));
2046    }
2047
2048    #[test]
2049    fn out_of_memory_during_exact_multiply_vetoes_without_crashing() {
2050        use crate::computation::bigint::{test_clear_alloc_fail, test_force_alloc_fail};
2051
2052        let code = r#"
2053spec oom_multiply
2054data a: 9999999999999999999999999999
2055data b: 9999999999999999999999999999
2056rule huge: a * b
2057"#;
2058
2059        let mut engine = Engine::new();
2060        engine
2061            .load(code, SourceType::Volatile)
2062            .expect("load must succeed");
2063
2064        test_force_alloc_fail(50);
2065
2066        let now = DateTimeValue::now();
2067        let response = engine
2068            .run(
2069                None,
2070                "oom_multiply",
2071                Some(&now),
2072                HashMap::new(),
2073                false,
2074                None,
2075            )
2076            .expect("evaluation must complete without process crash");
2077
2078        test_clear_alloc_fail();
2079
2080        let rule = response
2081            .results
2082            .get("huge")
2083            .expect("huge rule must be present");
2084
2085        assert!(rule.vetoed);
2086        assert_eq!(rule.veto_reason.as_deref(), Some("out of memory"));
2087    }
2088}