Skip to main content

lemma/
engine.rs

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