Skip to main content

lemma/
engine.rs

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