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            if r.errors().next().is_some() {
394                continue;
395            }
396            self.plan_sets
397                .entry(Arc::clone(&r.repository))
398                .or_default()
399                .insert(r.name.clone(), r.execution_plan_set());
400        }
401    }
402
403    /// Load one Lemma source (workspace; not a tagged dependency).
404    pub fn load(&mut self, code: impl Into<String>, source: SourceType) -> Result<(), Errors> {
405        self.load_batch(HashMap::from([(source, code.into())]), None)
406    }
407
408    /// Load many sources in one planning pass. Pairs are `(source_text, source_id)`.
409    ///
410    /// `dependency`: when `Some`, repositories parsed from these sources are tagged with that id
411    /// (same as previous `load(..., Some(id))` for path bundles).
412    pub fn load_batch(
413        &mut self,
414        sources: HashMap<SourceType, String>,
415        dependency: Option<&str>,
416    ) -> Result<(), Errors> {
417        self.add_sources_inner(sources, dependency, false)
418    }
419
420    fn validate_source_for_load(source: &SourceType) -> Result<(), Errors> {
421        match source {
422            SourceType::Path(p) if p.as_os_str().to_string_lossy().trim().is_empty() => {
423                Err(Errors {
424                    errors: vec![Error::request(
425                        "Source path must be non-empty",
426                        None::<String>,
427                    )],
428                    sources: HashMap::new(),
429                })
430            }
431            SourceType::Registry(repo) => {
432                if repo.name.as_deref().unwrap_or("").is_empty() {
433                    Err(Errors {
434                        errors: vec![Error::request(
435                            "Registry source identifier must be non-empty",
436                            None::<String>,
437                        )],
438                        sources: HashMap::new(),
439                    })
440                } else {
441                    Ok(())
442                }
443            }
444            _ => Ok(()),
445        }
446    }
447
448    fn reject_reserved_stdlib_repository(repository: &Arc<LemmaRepository>) -> Option<Error> {
449        if repository.name.as_deref() == Some(EMBEDDED_STDLIB_REPOSITORY) {
450            Some(Error::validation_with_context(
451                format!(
452                    "Repository '{EMBEDDED_STDLIB_REPOSITORY}' is reserved for the embedded standard library and cannot be loaded via load or load_batch; use @owner/repo qualifiers (e.g. '@iso/countries'), not the reserved 'lemma' repository"
453                ),
454                None,
455                Some("Load registry dependencies with @owner/repo qualifiers, not the reserved 'lemma' stdlib repository".to_string()),
456                None,
457                None,
458            ))
459        } else {
460            None
461        }
462    }
463
464    fn add_sources_inner(
465        &mut self,
466        sources: HashMap<SourceType, String>,
467        dependency: Option<&str>,
468        embedded_stdlib: bool,
469    ) -> Result<(), Errors> {
470        for st in sources.keys() {
471            Self::validate_source_for_load(st)?;
472        }
473        if !embedded_stdlib {
474            let limits = &self.limits;
475            if sources.len() > limits.max_sources {
476                return Err(Errors {
477                    errors: vec![Error::resource_limit_exceeded(
478                        "max_sources",
479                        limits.max_sources.to_string(),
480                        sources.len().to_string(),
481                        "Reduce the number of paths or sources in one load",
482                        None::<crate::parsing::source::Source>,
483                        None,
484                        None,
485                    )],
486                    sources,
487                });
488            }
489            let total_loaded_bytes: usize = sources.values().map(|s| s.len()).sum();
490            if total_loaded_bytes > limits.max_loaded_bytes {
491                return Err(Errors {
492                    errors: vec![Error::resource_limit_exceeded(
493                        "max_loaded_bytes",
494                        limits.max_loaded_bytes.to_string(),
495                        total_loaded_bytes.to_string(),
496                        "Load fewer or smaller sources",
497                        None::<crate::parsing::source::Source>,
498                        None,
499                        None,
500                    )],
501                    sources,
502                });
503            }
504            for code in sources.values() {
505                if code.len() > limits.max_source_size_bytes {
506                    return Err(Errors {
507                        errors: vec![Error::resource_limit_exceeded(
508                            "max_source_size_bytes",
509                            limits.max_source_size_bytes.to_string(),
510                            code.len().to_string(),
511                            "Use a smaller source text or increase limit",
512                            None::<crate::parsing::source::Source>,
513                            None,
514                            None,
515                        )],
516                        sources,
517                    });
518                }
519            }
520        }
521
522        let parse_limits = if embedded_stdlib {
523            &ResourceLimits::default()
524        } else {
525            &self.limits
526        };
527        let mut errors: Vec<Error> = Vec::new();
528
529        for (source_id, code) in &sources {
530            match parse(code, source_id.clone(), parse_limits) {
531                Ok(result) => {
532                    if !embedded_stdlib {
533                        self.total_expression_count += result.expression_count;
534                        if self.total_expression_count > self.limits.max_total_expression_count {
535                            errors.push(Error::resource_limit_exceeded(
536                                "max_total_expression_count",
537                                self.limits.max_total_expression_count.to_string(),
538                                self.total_expression_count.to_string(),
539                                "Split logic across fewer sources or reduce expression complexity",
540                                None::<crate::parsing::source::Source>,
541                                None,
542                                None,
543                            ));
544                            return Err(Errors { errors, sources });
545                        }
546                    }
547                    if result.repositories.is_empty() {
548                        continue;
549                    }
550
551                    for (parsed_repo, specs) in &result.repositories {
552                        let repository_arc = if let Some(dep_id) = dependency {
553                            let repo_name = parsed_repo
554                                .name
555                                .clone()
556                                // Use the dependency id as the repository name for the dependency's workspace specs
557                                .or_else(|| Some(dep_id.to_string()));
558                            Arc::new(
559                                LemmaRepository::new(repo_name)
560                                    .with_dependency(dep_id)
561                                    .with_start_line(parsed_repo.start_line),
562                            )
563                        } else {
564                            Arc::clone(parsed_repo)
565                        };
566                        if !embedded_stdlib {
567                            if let Some(reserved_err) =
568                                Self::reject_reserved_stdlib_repository(&repository_arc)
569                            {
570                                let source = crate::parsing::source::Source::new(
571                                    source_id.clone(),
572                                    crate::parsing::ast::Span {
573                                        start: 0,
574                                        end: 0,
575                                        line: parsed_repo.start_line,
576                                        col: 0,
577                                    },
578                                );
579                                errors.push(Error::validation(
580                                    reserved_err.to_string(),
581                                    Some(source),
582                                    reserved_err.suggestion().map(str::to_string),
583                                ));
584                                continue;
585                            }
586                        }
587                        for spec in specs {
588                            match self
589                                .specs
590                                .insert_spec(Arc::clone(&repository_arc), Arc::new(spec.clone()))
591                            {
592                                Ok(()) => {}
593                                Err(e) => {
594                                    let source = crate::parsing::source::Source::new(
595                                        source_id.clone(),
596                                        crate::parsing::ast::Span {
597                                            start: 0,
598                                            end: 0,
599                                            line: spec.start_line,
600                                            col: 0,
601                                        },
602                                    );
603                                    errors.push(Error::validation(
604                                        e.to_string(),
605                                        Some(source),
606                                        None::<String>,
607                                    ));
608                                }
609                            }
610                        }
611                    }
612                }
613                Err(e) => errors.push(e),
614            }
615        }
616
617        let planning_result = crate::planning::plan(&self.specs, &self.limits);
618        for set_result in &planning_result.results {
619            for spec_result in &set_result.slice_results {
620                let ctx = Arc::clone(&spec_result.spec);
621                for err in &spec_result.errors {
622                    errors.push(err.clone().with_spec_context(Arc::clone(&ctx)));
623                }
624            }
625        }
626        self.apply_planning_result(planning_result);
627
628        if errors.is_empty() {
629            Ok(())
630        } else {
631            Err(Errors { errors, sources })
632        }
633    }
634
635    /// Active [`LemmaSpec`] slice for `name` at the resolved effective instant.
636    ///
637    /// When `effective` is `None`, uses the current time. The name must be unique
638    /// across loaded repositories at that instant (same rule as [`Self::get_plan`] with
639    /// `repo: None`). For repository scope or all temporal rows, use [`Self::get_workspace`]
640    /// or [`Self::get_repository`].
641    pub fn get_spec(
642        &self,
643        name: &str,
644        effective: Option<&DateTimeValue>,
645    ) -> Result<Arc<LemmaSpec>, Error> {
646        let effective_dt = self.effective_or_now(effective);
647        let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
648        let repository = self.specs.workspace();
649        let spec_set = self
650            .specs
651            .spec_set(&repository, name)
652            .ok_or_else(|| self.spec_not_found_error(name, &effective_dt))?;
653        spec_set
654            .spec_at(&instant)
655            .ok_or_else(|| self.spec_not_found_error(name, &effective_dt))
656    }
657
658    /// Every loaded repository in insertion order (workspace, embedded stdlib [`EMBEDDED_STDLIB_REPOSITORY`], dependencies).
659    ///
660    /// Each [`ResolvedRepository::repository`] and every [`LemmaSpec`] under [`ResolvedRepository::specs`]
661    /// includes source metadata (`start_line`, `source_type`) from load.
662    /// Inspectable stdlib text: [`Self::format_repository`] with `"lemma"`.
663    #[must_use]
664    pub fn list(&self) -> Vec<ResolvedRepository> {
665        self.specs
666            .repositories()
667            .iter()
668            .map(|(repo, inner)| ResolvedRepository {
669                repository: Arc::clone(repo),
670                specs: inner.values().cloned().collect(),
671            })
672            .collect()
673    }
674
675    /// Workspace-local repository (`name == None`).
676    #[must_use]
677    pub fn get_workspace(&self) -> ResolvedRepository {
678        let repo = self.specs.workspace();
679        let specs = self.specs.spec_sets_for(&repo);
680        ResolvedRepository {
681            repository: repo,
682            specs,
683        }
684    }
685
686    /// Resolve a loaded repository by qualifier string. Matches against
687    /// repository names (which include `@` when present).
688    pub fn get_repository(&self, qualifier: &str) -> Result<ResolvedRepository, Error> {
689        let q = qualifier.trim();
690        if q.is_empty() {
691            return Err(Error::request(
692                "Repository qualifier cannot be empty",
693                None::<String>,
694            ));
695        }
696        match self.specs.find_repository(q) {
697            Some(repo) => {
698                let specs = self.specs.spec_sets_for(&repo);
699                Ok(ResolvedRepository {
700                    repository: repo,
701                    specs,
702                })
703            }
704            None => Err(Error::request_not_found(
705                format!("Repository '{qualifier}' not loaded"),
706                Some(format!(
707                    "List repositories with `{}` after loading your workspace",
708                    "lemma list"
709                )),
710            )),
711        }
712    }
713
714    /// Canonical Lemma source for every spec in `repository`, formatted from the in-engine AST.
715    pub fn format_repository(&self, repository: &str) -> Result<String, Error> {
716        let resolved = self.get_repository(repository)?;
717        let mut specs: Vec<Arc<LemmaSpec>> = resolved
718            .specs
719            .iter()
720            .flat_map(|ss| ss.iter_specs())
721            .collect();
722        specs.sort_by(|a, b| {
723            a.name
724                .cmp(&b.name)
725                .then_with(|| a.effective_from.cmp(&b.effective_from))
726        });
727        let spec_refs: Vec<&LemmaSpec> = specs.iter().map(AsRef::as_ref).collect();
728        let body = crate::formatting::format_spec_refs(&spec_refs);
729        let mut out = String::new();
730        if let Some(name) = resolved.repository.name.as_deref() {
731            out.push_str("repo ");
732            out.push_str(name);
733            out.push_str("\n\n");
734        }
735        out.push_str(&body);
736        Ok(out)
737    }
738
739    /// Planning schema for `name`. When `repo` is `None`, the spec must be
740    /// unambiguous across all loaded repositories; when `Some`, scoped to that
741    /// repository qualifier (e.g. `"@org/pkg"`).
742    pub fn schema(
743        &self,
744        repo: Option<&str>,
745        spec: &str,
746        effective: Option<&DateTimeValue>,
747    ) -> Result<SpecSchema, Error> {
748        Ok(self
749            .get_plan(repo, spec, effective)?
750            .schema(&DataOverlay::default()))
751    }
752
753    /// Return the resource limits configured for this engine.
754    ///
755    /// Exposed so callers can pass the same limits to [`DataOverlay::resolve`].
756    pub fn limits(&self) -> &ResourceLimits {
757        &self.limits
758    }
759
760    /// Evaluate a spec. When `repo` is `None`, the spec must be unambiguous
761    /// across loaded repositories; when `Some`, scoped to that repository
762    /// qualifier (e.g. `"@org/pkg"`).
763    ///
764    /// When `explain` is `true`, each requested rule's [`RuleResult::explanation`]
765    /// contains a structural explanation tree; when `false`, explanations are omitted.
766    ///
767    /// `rules` scopes which rule results appear in the response. `None` means all local
768    /// rules. `Some(&[])` is an error. When `explain` is `true`, all local rules (and
769    /// nested `uses` rules needed for explanation resolution) are evaluated so explanation
770    /// trees can embed dependency rules.
771    pub fn run(
772        &self,
773        repo: Option<&str>,
774        spec: &str,
775        effective: Option<&DateTimeValue>,
776        data_values: HashMap<String, String>,
777        explain: bool,
778        rules: Option<&[String]>,
779    ) -> Result<Response, Error> {
780        let effective = self.effective_or_now(effective);
781        let plan = self.get_plan(repo, spec, Some(&effective))?;
782        let data_values: HashMap<String, DataValueInput> = data_values
783            .into_iter()
784            .map(|(k, v)| (k, DataValueInput::convenience(v)))
785            .collect();
786        self.run_plan(plan, Some(&effective), data_values, explain, rules)
787    }
788
789    /// Execution plan for `name`. When `repo` is `None`, the spec must be
790    /// unambiguous across all loaded repositories; when `Some`, scoped to that
791    /// repository qualifier (e.g. `"@org/pkg"`).
792    pub fn get_plan(
793        &self,
794        repo: Option<&str>,
795        name: &str,
796        effective: Option<&DateTimeValue>,
797    ) -> Result<&crate::planning::ExecutionPlan, Error> {
798        let effective_dt = self.effective_or_now(effective);
799        let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
800        let canonical_name = crate::parsing::ast::ascii_lowercase_logical_name(name.to_string());
801
802        let repository = match repo {
803            Some(q) => self.specs.find_repository(q).ok_or_else(|| {
804                Error::request_not_found(
805                    format!("Repository '{q}' not loaded"),
806                    Some("List repositories with `lemma list` after loading your workspace"),
807                )
808            })?,
809            None => self.specs.workspace(),
810        };
811
812        let Some(spec_set) = self.specs.spec_set(&repository, &canonical_name) else {
813            return Err(self.spec_not_found_in_repository_error(&repository, name, &effective_dt));
814        };
815
816        if spec_set.spec_at(&instant).is_none() {
817            return Err(self.spec_not_found_in_repository_error(&repository, name, &effective_dt));
818        }
819
820        let plan_set = self
821            .plan_sets
822            .get(&repository)
823            .and_then(|by_name| by_name.get(&canonical_name))
824            .ok_or_else(|| {
825                Error::request_not_found(
826                    format!("No execution plans for spec '{name}'"),
827                    Some("Ensure sources loaded and planning succeeded"),
828                )
829            })?;
830
831        plan_set.plan_at(&instant).ok_or_else(|| {
832            Error::request_not_found(
833                format!("No execution plan slice for spec '{name}' at effective {effective_dt}"),
834                None::<String>,
835            )
836        })
837    }
838
839    /// Run a plan from [`get_plan`]: apply data values and evaluate rules.
840    ///
841    /// Spec-declared defaults (`-> default ...`) are applied by the evaluator when
842    /// the caller does not supply a value for that data path.
843    ///
844    /// `rules`: `None` evaluates all local rules in the response; `Some(slice)` evaluates
845    /// only those names (`Some(&[])` errors). Each name in `Some` must be a local rule.
846    ///
847    /// When `explain` is `false`, only the VM scope runs (requested rules, or all local
848    /// when `None`). When `explain` is `true`, all local and nested `uses` rules run so
849    /// explanation trees have full `rule_results`, but only the response scope appears in
850    /// `response.results` and receives attached explanations.
851    pub fn run_plan(
852        &self,
853        plan: &crate::planning::ExecutionPlan,
854        effective: Option<&DateTimeValue>,
855        data_values: HashMap<String, DataValueInput>,
856        explain: bool,
857        rules: Option<&[String]>,
858    ) -> Result<Response, Error> {
859        let response_rules = plan.validated_response_rule_names(rules)?;
860        let effective = self.effective_or_now(effective);
861        let overlay = DataOverlay::resolve(plan, data_values, &self.limits)?;
862        crate::planning::execution_plan::validate_unit_index_references(plan)?;
863        let now_semantic = crate::planning::semantics::date_time_to_semantic(&effective);
864        let now_literal = crate::planning::semantics::LiteralValue {
865            value: crate::planning::semantics::ValueKind::Date(now_semantic),
866            lemma_type: crate::planning::semantics::primitive_date_arc().clone(),
867        };
868        let (mut response, context) =
869            self.evaluator
870                .evaluate(plan, &overlay, now_literal, explain, &response_rules);
871        if explain {
872            self.evaluator.explain(&mut response, plan, &context);
873        }
874        Ok(response)
875    }
876
877    pub fn remove(&mut self, name: &str, effective: Option<&DateTimeValue>) -> Result<(), Error> {
878        let effective = self.effective_or_now(effective);
879        let repository_arc = self.specs.workspace();
880        let spec_arc = self.get_spec(name, Some(&effective))?;
881        self.specs.remove_spec(&repository_arc, &spec_arc);
882        let pr = crate::planning::plan(&self.specs, &self.limits);
883        let planning_errs: Vec<Error> = pr
884            .results
885            .iter()
886            .flat_map(|r| r.errors().cloned())
887            .collect();
888        self.apply_planning_result(pr);
889        if let Some(e) = planning_errs.into_iter().next() {
890            return Err(e);
891        }
892        Ok(())
893    }
894
895    /// Build a "not found" error listing available temporal versions when the name exists
896    /// but no version covers the requested effective date.
897    fn spec_not_found_error(&self, spec_name: &str, effective: &DateTimeValue) -> Error {
898        let workspace = self.specs.workspace();
899        let available = match self.specs.spec_set(&workspace, spec_name) {
900            Some(ss) => ss.iter_specs().collect::<Vec<_>>(),
901            None => Vec::new(),
902        };
903        let msg = if available.is_empty() {
904            format!("Spec '{}' not found", spec_name)
905        } else {
906            let listing: Vec<String> = available
907                .iter()
908                .map(|s| match s.effective_from() {
909                    Some(dt) => format!("  {} (effective from {})", s.name, dt),
910                    None => format!("  {} (no effective_from)", s.name),
911                })
912                .collect();
913            format!(
914                "Spec '{}' not found for effective {}. Available versions:\n{}",
915                spec_name,
916                effective,
917                listing.join("\n")
918            )
919        };
920        Error::request_not_found(msg, None::<String>)
921    }
922
923    fn spec_not_found_in_repository_error(
924        &self,
925        repository: &LemmaRepository,
926        spec_name: &str,
927        effective: &DateTimeValue,
928    ) -> Error {
929        let repo_label = match &repository.name {
930            Some(n) => n.clone(),
931            None => "(workspace)".to_string(),
932        };
933        Error::request_not_found(
934            format!(
935                "Spec '{spec_name}' not found in repository {repo_label} at effective {effective}",
936            ),
937            Some("Try `lemma list`"),
938        )
939    }
940
941    /// Effective datetime for a request: `explicit` or now.
942    #[must_use]
943    fn effective_or_now(&self, effective: Option<&DateTimeValue>) -> DateTimeValue {
944        effective.cloned().unwrap_or_else(DateTimeValue::now)
945    }
946}
947
948#[cfg(test)]
949mod tests {
950    use super::*;
951
952    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
953        DateTimeValue {
954            year,
955            month,
956            day,
957            hour: 0,
958            minute: 0,
959            second: 0,
960            microsecond: 0,
961            timezone: None,
962            granularity: crate::literals::DateGranularity::Full,
963        }
964    }
965
966    fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
967        let mut spec = LemmaSpec::new(name.to_string());
968        spec.effective_from = crate::parsing::ast::EffectiveDate::from_option(effective_from);
969        spec
970    }
971
972    /// Context::iter returns specs in (name, effective_from) ascending order.
973    /// Same-name specs appear in temporal order; definition order in the source is irrelevant.
974    #[test]
975    fn list_specs_order_is_name_then_effective_from_ascending() {
976        let mut ctx = Context::new();
977        let repository = ctx.workspace();
978        let s_2026 = Arc::new(make_spec_with_range("mortgage", Some(date(2026, 1, 1))));
979        let s_2025 = Arc::new(make_spec_with_range("mortgage", Some(date(2025, 1, 1))));
980        ctx.insert_spec(Arc::clone(&repository), Arc::clone(&s_2026))
981            .unwrap();
982        ctx.insert_spec(Arc::clone(&repository), Arc::clone(&s_2025))
983            .unwrap();
984        let listed: Vec<_> = ctx
985            .spec_set(&repository, "mortgage")
986            .expect("mortgage set")
987            .iter_specs()
988            .collect();
989        assert_eq!(listed.len(), 2);
990        assert_eq!(listed[0].effective_from(), Some(&date(2025, 1, 1)));
991        assert_eq!(listed[1].effective_from(), Some(&date(2026, 1, 1)));
992    }
993
994    #[test]
995    fn get_spec_resolves_temporal_version_by_effective() {
996        let mut engine = Engine::new();
997        engine
998            .load(
999                r#"
1000        spec pricing 2025-01-01
1001        data x: 1
1002        rule r: x
1003    "#,
1004                SourceType::Path(Arc::new(std::path::PathBuf::from("a.lemma"))),
1005            )
1006            .unwrap();
1007        engine
1008            .load(
1009                r#"
1010        spec pricing 2025-06-01
1011        data x: 2
1012        rule r: x
1013    "#,
1014                SourceType::Path(Arc::new(std::path::PathBuf::from("b.lemma"))),
1015            )
1016            .unwrap();
1017
1018        let jan = DateTimeValue {
1019            year: 2025,
1020            month: 1,
1021            day: 15,
1022            hour: 0,
1023            minute: 0,
1024            second: 0,
1025            microsecond: 0,
1026            timezone: None,
1027            granularity: crate::literals::DateGranularity::Full,
1028        };
1029        let jul = DateTimeValue {
1030            year: 2025,
1031            month: 7,
1032            day: 1,
1033            hour: 0,
1034            minute: 0,
1035            second: 0,
1036            microsecond: 0,
1037            timezone: None,
1038            granularity: crate::literals::DateGranularity::Full,
1039        };
1040
1041        let v1 = DateTimeValue {
1042            year: 2025,
1043            month: 1,
1044            day: 1,
1045            hour: 0,
1046            minute: 0,
1047            second: 0,
1048            microsecond: 0,
1049            timezone: None,
1050            granularity: crate::literals::DateGranularity::Full,
1051        };
1052        let v2 = DateTimeValue {
1053            year: 2025,
1054            month: 6,
1055            day: 1,
1056            hour: 0,
1057            minute: 0,
1058            second: 0,
1059            microsecond: 0,
1060            timezone: None,
1061            granularity: crate::literals::DateGranularity::Full,
1062        };
1063
1064        let s_jan = engine.get_spec("pricing", Some(&jan)).expect("jan spec");
1065        let s_jul = engine.get_spec("pricing", Some(&jul)).expect("jul spec");
1066        assert_eq!(s_jan.effective_from(), Some(&v1));
1067        assert_eq!(s_jul.effective_from(), Some(&v2));
1068    }
1069
1070    /// Every temporal row for a workspace spec name exposes half-open
1071    /// `[effective_from, effective_to)` via [`LemmaSpecSet::iter_with_ranges`]. The latest row's
1072    /// `effective_to` is `None` (no successor); earlier rows' `effective_to`
1073    /// equals the next row's `effective_from`.
1074    #[test]
1075    fn list_specs_returns_half_open_ranges_per_temporal_version() {
1076        let mut engine = Engine::new();
1077        engine
1078            .load(
1079                r#"
1080        spec pricing 2025-01-01
1081        data x: 1
1082        rule r: x
1083    "#,
1084                SourceType::Path(Arc::new(std::path::PathBuf::from("a.lemma"))),
1085            )
1086            .unwrap();
1087        engine
1088            .load(
1089                r#"
1090        spec pricing 2025-06-01
1091        data x: 2
1092        rule r: x
1093    "#,
1094                SourceType::Path(Arc::new(std::path::PathBuf::from("b.lemma"))),
1095            )
1096            .unwrap();
1097
1098        let january = date(2025, 1, 1);
1099        let june = date(2025, 6, 1);
1100
1101        let workspace = engine.get_workspace();
1102        let pricing_set = workspace
1103            .specs
1104            .iter()
1105            .find(|ss| ss.name == "pricing")
1106            .expect("pricing spec set exists");
1107        let mut ranges: Vec<(Option<DateTimeValue>, Option<DateTimeValue>)> = pricing_set
1108            .iter_with_ranges()
1109            .map(|(_, from, to)| (from, to))
1110            .collect();
1111        ranges.sort_by(|a, b| match (&a.0, &b.0) {
1112            (Some(x), Some(y)) => x.cmp(y),
1113            (None, Some(_)) => std::cmp::Ordering::Less,
1114            (Some(_), None) => std::cmp::Ordering::Greater,
1115            (None, None) => std::cmp::Ordering::Equal,
1116        });
1117        assert_eq!(ranges.len(), 2);
1118        assert_eq!(
1119            ranges[0],
1120            (Some(january.clone()), Some(june.clone())),
1121            "earlier row ends at the next row's effective_from"
1122        );
1123        assert_eq!(
1124            ranges[1],
1125            (Some(june.clone()), None),
1126            "latest row has no successor; effective_to is None"
1127        );
1128
1129        assert!(
1130            !engine
1131                .get_workspace()
1132                .specs
1133                .iter()
1134                .any(|ss| ss.name == "unknown"),
1135            "no rows for unknown spec"
1136        );
1137    }
1138
1139    /// `Engine::get_workspace()` provides spec sets grouped by name.
1140    /// Each spec set exposes half-open `[effective_from, effective_to)` ranges.
1141    #[test]
1142    fn get_workspace_specs_with_half_open_ranges() {
1143        let mut engine = Engine::new();
1144        engine
1145            .load(
1146                r#"
1147        spec pricing 2025-01-01
1148        data x: 1
1149        rule r: x
1150    "#,
1151                SourceType::Path(Arc::new(std::path::PathBuf::from("pricing_v1.lemma"))),
1152            )
1153            .unwrap();
1154        engine
1155            .load(
1156                r#"
1157        spec pricing 2026-01-01
1158        data x: 2
1159        rule r: x
1160    "#,
1161                SourceType::Path(Arc::new(std::path::PathBuf::from("pricing_v2.lemma"))),
1162            )
1163            .unwrap();
1164        engine
1165            .load(
1166                r#"
1167        spec taxes
1168        data rate: 0.21
1169        rule amount: rate
1170    "#,
1171                SourceType::Path(Arc::new(std::path::PathBuf::from("taxes.lemma"))),
1172            )
1173            .unwrap();
1174
1175        let workspace = engine.get_workspace();
1176        assert_eq!(workspace.specs.len(), 2, "two spec sets: pricing and taxes");
1177
1178        let pricing_set = workspace
1179            .specs
1180            .iter()
1181            .find(|ss| ss.name == "pricing")
1182            .expect("pricing spec set exists");
1183        let ranges: Vec<_> = pricing_set.iter_with_ranges().collect();
1184        assert_eq!(ranges.len(), 2);
1185        assert_eq!(ranges[0].1, Some(date(2025, 1, 1)));
1186        assert_eq!(
1187            ranges[0].2,
1188            Some(date(2026, 1, 1)),
1189            "earlier pricing row ends at the next pricing row's effective_from"
1190        );
1191        assert_eq!(ranges[1].1, Some(date(2026, 1, 1)));
1192        assert_eq!(
1193            ranges[1].2, None,
1194            "latest pricing row has no successor; effective_to is None"
1195        );
1196
1197        let taxes_set = workspace
1198            .specs
1199            .iter()
1200            .find(|ss| ss.name == "taxes")
1201            .expect("taxes spec set exists");
1202        let tax_ranges: Vec<_> = taxes_set.iter_with_ranges().collect();
1203        assert_eq!(tax_ranges.len(), 1);
1204        assert_eq!(
1205            tax_ranges[0].1, None,
1206            "unversioned spec has no declared effective_from"
1207        );
1208        assert_eq!(
1209            tax_ranges[0].2, None,
1210            "unversioned spec has no successor; effective_to is None"
1211        );
1212    }
1213
1214    #[test]
1215    fn test_evaluate_spec_all_rules() {
1216        let mut engine = Engine::new();
1217        engine
1218            .load(
1219                r#"
1220        spec test
1221        data x: 10
1222        data y: 5
1223        rule sum: x + y
1224        rule product: x * y
1225    "#,
1226                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1227            )
1228            .unwrap();
1229
1230        let now = DateTimeValue::now();
1231        let response = engine
1232            .run(None, "test", Some(&now), HashMap::new(), false, None)
1233            .unwrap();
1234        assert_eq!(response.results.len(), 2);
1235
1236        let sum_result = response
1237            .results
1238            .values()
1239            .find(|r| r.rule.name == "sum")
1240            .unwrap();
1241        assert_eq!(sum_result.display.clone().expect("display"), "15");
1242
1243        let product_result = response
1244            .results
1245            .values()
1246            .find(|r| r.rule.name == "product")
1247            .unwrap();
1248        assert_eq!(product_result.display.clone().expect("display"), "50");
1249    }
1250
1251    #[test]
1252    fn test_evaluate_empty_data() {
1253        let mut engine = Engine::new();
1254        engine
1255            .load(
1256                r#"
1257        spec test
1258        data price: 100
1259        rule total: price * 2
1260    "#,
1261                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1262            )
1263            .unwrap();
1264
1265        let now = DateTimeValue::now();
1266        let response = engine
1267            .run(None, "test", Some(&now), HashMap::new(), false, None)
1268            .unwrap();
1269        assert_eq!(response.results.len(), 1);
1270        assert_eq!(
1271            response
1272                .results
1273                .values()
1274                .next()
1275                .unwrap()
1276                .display
1277                .clone()
1278                .expect("display"),
1279            "200"
1280        );
1281    }
1282
1283    #[test]
1284    fn test_evaluate_boolean_rule() {
1285        let mut engine = Engine::new();
1286        engine
1287            .load(
1288                r#"
1289        spec test
1290        data age: 25
1291        rule is_adult: age >= 18
1292    "#,
1293                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1294            )
1295            .unwrap();
1296
1297        let now = DateTimeValue::now();
1298        let response = engine
1299            .run(None, "test", Some(&now), HashMap::new(), false, None)
1300            .unwrap();
1301        assert_eq!(
1302            response.results.values().next().unwrap().boolean,
1303            Some(true)
1304        );
1305    }
1306
1307    #[test]
1308    fn test_evaluate_with_unless_clause() {
1309        let mut engine = Engine::new();
1310        engine
1311            .load(
1312                r#"
1313        spec test
1314        data quantity: 15
1315        rule discount: 0
1316          unless quantity >= 10 then 10
1317    "#,
1318                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1319            )
1320            .unwrap();
1321
1322        let now = DateTimeValue::now();
1323        let response = engine
1324            .run(None, "test", Some(&now), HashMap::new(), false, None)
1325            .unwrap();
1326        assert_eq!(
1327            response
1328                .results
1329                .values()
1330                .next()
1331                .unwrap()
1332                .display
1333                .clone()
1334                .expect("display"),
1335            "10"
1336        );
1337    }
1338
1339    #[test]
1340    fn test_spec_not_found() {
1341        let engine = Engine::new();
1342        let now = DateTimeValue::now();
1343        let result = engine.run(None, "nonexistent", Some(&now), HashMap::new(), false, None);
1344        assert!(result.is_err());
1345        assert!(result.unwrap_err().to_string().contains("not found"));
1346    }
1347
1348    #[test]
1349    fn test_multiple_specs() {
1350        let mut engine = Engine::new();
1351        engine
1352            .load(
1353                r#"
1354        spec spec1
1355        data x: 10
1356        rule result: x * 2
1357    "#,
1358                SourceType::Path(Arc::new(std::path::PathBuf::from("spec 1.lemma"))),
1359            )
1360            .unwrap();
1361
1362        engine
1363            .load(
1364                r#"
1365        spec spec2
1366        data y: 5
1367        rule result: y * 3
1368    "#,
1369                SourceType::Path(Arc::new(std::path::PathBuf::from("spec 2.lemma"))),
1370            )
1371            .unwrap();
1372
1373        let now = DateTimeValue::now();
1374        let response1 = engine
1375            .run(None, "spec1", Some(&now), HashMap::new(), false, None)
1376            .unwrap();
1377        assert_eq!(response1.results[0].display.clone().expect("display"), "20");
1378        let response2 = engine
1379            .run(None, "spec2", Some(&now), HashMap::new(), false, None)
1380            .unwrap();
1381        assert_eq!(response2.results[0].display.clone().expect("display"), "15");
1382    }
1383
1384    #[test]
1385    fn test_runtime_error_mapping() {
1386        let mut engine = Engine::new();
1387        engine
1388            .load(
1389                r#"
1390        spec test
1391        data numerator: 10
1392        data denominator: 0
1393        rule division: numerator / denominator
1394    "#,
1395                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1396            )
1397            .unwrap();
1398
1399        let now = DateTimeValue::now();
1400        let result = engine.run(None, "test", Some(&now), HashMap::new(), false, None);
1401        // Division by zero returns a Veto (not an error)
1402        assert!(result.is_ok(), "Evaluation should succeed");
1403        let response = result.unwrap();
1404        let division_result = response
1405            .results
1406            .values()
1407            .find(|r| r.rule.name == "division");
1408        assert!(
1409            division_result.is_some(),
1410            "Should have division rule result"
1411        );
1412        let division = division_result.unwrap();
1413        assert!(division.vetoed);
1414        assert!(
1415            division
1416                .veto_reason
1417                .as_deref()
1418                .unwrap()
1419                .contains("Division by zero"),
1420            "Veto message should mention division by zero: {:?}",
1421            division.veto_reason
1422        );
1423    }
1424
1425    #[test]
1426    fn test_rules_sorted_by_source_order() {
1427        let mut engine = Engine::new();
1428        engine
1429            .load(
1430                r#"
1431        spec test
1432        data a: 1
1433        data b: 2
1434        rule z: a + b
1435        rule y: a * b
1436        rule x: a - b
1437    "#,
1438                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1439            )
1440            .unwrap();
1441
1442        let now = DateTimeValue::now();
1443        let response = engine
1444            .run(None, "test", Some(&now), HashMap::new(), false, None)
1445            .unwrap();
1446        assert_eq!(response.results.len(), 3);
1447
1448        // Verify source positions increase (z < y < x)
1449        let z_pos = response
1450            .results
1451            .values()
1452            .find(|r| r.rule.name == "z")
1453            .unwrap()
1454            .rule
1455            .source_location
1456            .span
1457            .start;
1458        let y_pos = response
1459            .results
1460            .values()
1461            .find(|r| r.rule.name == "y")
1462            .unwrap()
1463            .rule
1464            .source_location
1465            .span
1466            .start;
1467        let x_pos = response
1468            .results
1469            .values()
1470            .find(|r| r.rule.name == "x")
1471            .unwrap()
1472            .rule
1473            .source_location
1474            .span
1475            .start;
1476
1477        assert!(z_pos < y_pos);
1478        assert!(y_pos < x_pos);
1479    }
1480
1481    #[test]
1482    fn test_rule_filtering_evaluates_dependencies() {
1483        let mut engine = Engine::new();
1484        engine
1485            .load(
1486                r#"
1487        spec test
1488        data base: 100
1489        rule subtotal: base * 2
1490        rule tax: subtotal * 10%
1491        rule total: subtotal + tax
1492    "#,
1493                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1494            )
1495            .unwrap();
1496
1497        let now = DateTimeValue::now();
1498        let response = engine
1499            .run(
1500                None,
1501                "test",
1502                Some(&now),
1503                HashMap::new(),
1504                false,
1505                Some(&["total".to_string()]),
1506            )
1507            .unwrap();
1508
1509        assert_eq!(response.results.len(), 1);
1510        assert_eq!(response.results.keys().next().unwrap(), "total");
1511
1512        // But the value should be correct (dependencies were computed)
1513        let total = response.results.values().next().unwrap();
1514        assert_eq!(total.display.clone().expect("display"), "220");
1515    }
1516
1517    // -------------------------------------------------------------------
1518    // Pre-resolved dependency tests (Engine never fetches from registry)
1519    // -------------------------------------------------------------------
1520
1521    use crate::parsing::ast::DateTimeValue;
1522
1523    #[test]
1524    fn pre_resolved_deps_in_file_map_evaluates_external_spec() {
1525        let mut engine = Engine::new();
1526
1527        engine
1528            .load_batch(
1529                HashMap::from([(
1530                    SourceType::Volatile,
1531                    "repo @org/project\nspec helper\ndata quantity: 42".to_string(),
1532                )]),
1533                Some("@org/project"),
1534            )
1535            .expect("should load dependency files");
1536
1537        engine
1538            .load(
1539                r#"spec main_spec
1540uses external: @org/project helper
1541rule value: external.quantity"#,
1542                SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1543            )
1544            .expect("should succeed with pre-resolved deps");
1545
1546        let now = DateTimeValue::now();
1547        let response = engine
1548            .run(None, "main_spec", Some(&now), HashMap::new(), false, None)
1549            .expect("evaluate should succeed");
1550
1551        let value_result = response
1552            .results
1553            .get("value")
1554            .expect("rule 'value' should exist");
1555        assert_eq!(value_result.display.clone().expect("display"), "42");
1556    }
1557
1558    #[test]
1559    fn schema_with_repo_resolves_registry_spec() {
1560        let mut engine = Engine::new();
1561        engine
1562            .load_batch(
1563                HashMap::from([(
1564                    SourceType::Volatile,
1565                    "repo @org/project\nspec helper\ndata quantity: 42\nrule expose: quantity"
1566                        .to_string(),
1567                )]),
1568                Some("@org/project"),
1569            )
1570            .expect("registry bundle loads");
1571
1572        engine
1573            .load(
1574                r#"spec main_spec
1575data x: 1"#,
1576                SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1577            )
1578            .expect("main loads");
1579
1580        let now = DateTimeValue::now();
1581        let schema = engine
1582            .schema(Some("@org/project"), "helper", Some(&now))
1583            .expect("schema for registry spec");
1584        assert!(schema.data.contains_key("quantity"));
1585    }
1586
1587    #[test]
1588    fn load_no_external_refs_works() {
1589        let mut engine = Engine::new();
1590
1591        engine
1592            .load(
1593                r#"spec local_only
1594data price: 100
1595rule doubled: price * 2"#,
1596                SourceType::Path(Arc::new(std::path::PathBuf::from("local.lemma"))),
1597            )
1598            .expect("should succeed when there are no @... references");
1599
1600        let now = DateTimeValue::now();
1601        let response = engine
1602            .run(None, "local_only", Some(&now), HashMap::new(), false, None)
1603            .expect("evaluate should succeed");
1604
1605        let doubled = response.results.get("doubled").expect("doubled rule");
1606        assert_eq!(doubled.display.clone().expect("display"), "200");
1607    }
1608
1609    #[test]
1610    fn unresolved_external_ref_without_deps_fails() {
1611        let mut engine = Engine::new();
1612
1613        let result = engine.load(
1614            r#"spec main_spec
1615uses external: @org/project missing
1616rule value: external.quantity"#,
1617            SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1618        );
1619
1620        let errs = result.expect_err("Should fail when registry dep is not loaded");
1621        assert!(
1622            errs.iter()
1623                .any(|e| e.kind() == crate::ErrorKind::MissingRepository),
1624            "expected MissingRepository, got: {:?}",
1625            errs.iter().map(|e| e.kind()).collect::<Vec<_>>()
1626        );
1627    }
1628
1629    #[test]
1630    fn pre_resolved_deps_with_spec_and_type_refs() {
1631        let mut engine = Engine::new();
1632
1633        engine
1634            .load_batch(
1635                HashMap::from([(
1636                    SourceType::Volatile,
1637                    "repo @org/example\nspec helper\ndata value: 42".to_string(),
1638                )]),
1639                Some("@org/example"),
1640            )
1641            .expect("should load helper file");
1642
1643        engine
1644            .load_batch(
1645                HashMap::from([(
1646                    SourceType::Volatile,
1647                    "repo @iso/countries\nspec alpha2\ndata code: text\n -> option \"NL\"\n -> option \"BE\"".to_string(),
1648                )]),
1649                Some("@iso/countries"),
1650            )
1651            .expect("should load alpha2 file");
1652
1653        engine
1654            .load(
1655                r#"spec registry_demo
1656uses @iso/countries alpha2
1657data country: alpha2.code
1658data unit_count: 5
1659uses @org/example helper
1660rule helper_value: helper.value
1661rule line_total: unit_count * 2
1662rule formatted: helper_value + 0"#,
1663                SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1664            )
1665            .expect("should succeed with pre-resolved spec and type deps");
1666
1667        let now = DateTimeValue::now();
1668        let response = engine
1669            .run(
1670                None,
1671                "registry_demo",
1672                Some(&now),
1673                HashMap::new(),
1674                false,
1675                None,
1676            )
1677            .expect("evaluate should succeed");
1678
1679        assert_eq!(
1680            response
1681                .results
1682                .get("helper_value")
1683                .expect("helper_value")
1684                .display
1685                .clone()
1686                .expect("display"),
1687            "42"
1688        );
1689        let line = response
1690            .results
1691            .get("line_total")
1692            .expect("line_total")
1693            .display
1694            .clone()
1695            .expect("display");
1696        assert_eq!(line, "10");
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}