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