Skip to main content

lemma/
engine.rs

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