Skip to main content

lemma/
engine.rs

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