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