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