Skip to main content

lemma/
engine.rs

1use crate::evaluation::Evaluator;
2use crate::evaluation::{EvaluateData, EvaluateDataValue};
3use crate::literals::{DateGranularity, TimezoneValue};
4use crate::parsing::ast::{DateTimeValue, LemmaRepository, LemmaSpec};
5use crate::parsing::source::SourceType;
6use crate::parsing::{parse, EffectiveDate};
7use crate::planning::execution_plan::{ExecutionPlan, Show, ShowData};
8use crate::planning::semantics::{DataDefinition, SpecIdentity};
9use crate::planning::{LemmaSpecSet, PlanStore};
10use crate::{Error, ResourceLimits, Response};
11use indexmap::IndexMap;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::sync::Arc;
15
16/// Load failure: errors plus the source texts we attempted to load.
17#[derive(Debug, Clone)]
18pub struct Errors {
19    pub errors: Vec<Error>,
20    pub sources: HashMap<SourceType, String>,
21}
22
23impl Errors {
24    /// Iterate over the errors.
25    pub fn iter(&self) -> std::slice::Iter<'_, Error> {
26        self.errors.iter()
27    }
28}
29
30/// Resolve an optional effective datetime string for planning or evaluation.
31///
32/// `None` or whitespace-only input resolves to [`DateTimeValue::now`].
33/// Non-empty invalid strings return a request [`Error`].
34///
35/// Date-only forms (`YYYY`, `YYYY-MM`, `YYYY-MM-DD`, ISO week) become a precise
36/// UTC instant at the start of that period (`00:00:00Z`, [`DateGranularity::DateTime`]).
37/// Full ISO datetimes keep their parsed clock and timezone.
38pub fn resolve_effective(raw: Option<&str>) -> Result<DateTimeValue, Error> {
39    match raw {
40        Some(s) if !s.trim().is_empty() => {
41            let parsed = s.trim().parse::<DateTimeValue>().map_err(|_| {
42                Error::request(
43                    format!(
44                        "Invalid effective value '{}'. Expected: YYYY, YYYY-MM, YYYY-MM-DD, or ISO 8601 datetime",
45                        s.trim()
46                    ),
47                    None::<String>,
48                )
49            })?;
50            Ok(promote_effective_to_instant(parsed))
51        }
52        _ => Ok(DateTimeValue::now()),
53    }
54}
55
56/// Date-only parse results keep calendar granularity; the evaluation instant always
57/// needs a clock. Promote to UTC midnight at the already-normalized calendar day.
58fn promote_effective_to_instant(mut dt: DateTimeValue) -> DateTimeValue {
59    if matches!(dt.granularity, DateGranularity::DateTime) {
60        return dt;
61    }
62    dt.hour = 0;
63    dt.minute = 0;
64    dt.second = 0;
65    dt.microsecond = 0;
66    dt.timezone = Some(TimezoneValue { offset_minutes: 0 });
67    dt.granularity = DateGranularity::DateTime;
68    dt
69}
70
71/// Repository name reserved for the embedded standard library (`repo lemma`, `spec units`).
72/// User [`Engine::load`] must not target this name via [`SourceType::Dependency`].
73pub const EMBEDDED_STDLIB_REPOSITORY: &str = "lemma";
74
75/// One `uses` target from a spec version's source.
76#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
77pub struct SpecDependency {
78    #[serde(skip_serializing_if = "Option::is_none", default)]
79    pub repository: Option<String>,
80    pub name: String,
81    #[serde(skip_serializing_if = "Option::is_none", default)]
82    pub effective: Option<DateTimeValue>,
83}
84
85impl std::fmt::Display for SpecDependency {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        if let Some(repository) = &self.repository {
88            write!(f, "{repository} ")?;
89        }
90        write!(f, "{}", self.name)?;
91        if let Some(effective) = &self.effective {
92            write!(f, " {effective}")?;
93        }
94        Ok(())
95    }
96}
97
98/// One temporal spec version from [`Engine::specs`].
99#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
100pub struct SpecVersion {
101    pub name: String,
102    #[serde(skip_serializing_if = "Option::is_none", default)]
103    pub effective_from: Option<DateTimeValue>,
104    #[serde(skip_serializing_if = "Option::is_none", default)]
105    pub effective_to: Option<DateTimeValue>,
106    #[serde(skip_serializing_if = "Vec::is_empty", default)]
107    pub dependencies: Vec<SpecDependency>,
108}
109
110/// Half-open window as `lemma specs` / `lemma show` print it, same `lo...hi`
111/// notation as range values. Open start is `...to`; open end is the start date
112/// alone (matches `spec name date`); both absent prints nothing.
113pub(crate) fn write_effective_window(
114    f: &mut impl std::fmt::Write,
115    from: Option<&DateTimeValue>,
116    to: Option<&DateTimeValue>,
117) -> std::fmt::Result {
118    match (from, to) {
119        (Some(from), Some(to)) => write!(f, " {from}...{to}"),
120        (Some(from), None) => write!(f, " {from}"),
121        (None, Some(to)) => write!(f, " ...{to}"),
122        (None, None) => Ok(()),
123    }
124}
125
126impl std::fmt::Display for SpecVersion {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.write_str(&self.name)?;
129        write_effective_window(f, self.effective_from.as_ref(), self.effective_to.as_ref())
130    }
131}
132
133/// Repository group from [`Engine::specs`].
134///
135/// [`Self::repository`] is absent for the default unnamed repository.
136/// [`Self::from_dependency`] is true for lemma_deps and the embedded stdlib;
137/// it is omitted from JSON.
138#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
139pub struct Repository {
140    #[serde(skip_serializing_if = "Option::is_none", default)]
141    pub repository: Option<String>,
142    pub specs: Vec<SpecVersion>,
143    #[serde(skip)]
144    pub from_dependency: bool,
145}
146
147/// Text view of [`Engine::specs`], as `lemma specs` prints it.
148///
149/// `all == false` (`lemma specs`): local repositories only (not lemma_deps / embedded
150/// stdlib). Each temporal version is followed by its `uses` targets, indented.
151/// `all == true` (`lemma specs --all`): every loaded repository; names and windows only.
152///
153/// Version lines are `name` plus ` from...to`, ` from`, ` ...to`, or the name alone,
154/// sorted by name then `effective_from`. The unnamed repository's rows are unindented;
155/// a named repository is a header with indented rows. Repositories without specs are
156/// left out; groups are separated by a blank line.
157#[must_use]
158pub fn format_repositories(repositories: &[Repository], all: bool) -> String {
159    let mut output = String::new();
160    let groups = repositories
161        .iter()
162        .filter(|resolved| !resolved.specs.is_empty() && (all || !resolved.from_dependency));
163    for (index, resolved) in groups.enumerate() {
164        if index > 0 {
165            output.push('\n');
166        }
167        let mut versions: Vec<&SpecVersion> = resolved.specs.iter().collect();
168        versions.sort_by(|a, b| (&a.name, &a.effective_from).cmp(&(&b.name, &b.effective_from)));
169        let indent = match resolved.repository.as_deref() {
170            None => "",
171            Some(repository) => {
172                output.push_str(repository);
173                output.push('\n');
174                "  "
175            }
176        };
177        for version in versions {
178            output.push_str(indent);
179            output.push_str(&version.to_string());
180            output.push('\n');
181            if !all {
182                for dependency in &version.dependencies {
183                    output.push_str(indent);
184                    output.push_str("  ");
185                    output.push_str(&dependency.to_string());
186                    output.push('\n');
187                }
188            }
189        }
190    }
191    output
192}
193
194fn uses_dependencies(spec: &LemmaSpec) -> Vec<SpecDependency> {
195    spec.data
196        .iter()
197        .filter_map(|data| {
198            data.value.uses_spec_ref().map(|spec_ref| SpecDependency {
199                repository: spec_ref.repository.as_ref().map(|q| q.name.clone()),
200                name: spec_ref.name.clone(),
201                effective: spec_ref.effective.clone(),
202            })
203        })
204        .collect()
205}
206
207// ─── Spec store with temporal resolution ──────────────────────────────
208
209/// Ordered store of specs keyed by `(repository, name)` and grouped into
210/// [`LemmaSpecSet`]s.
211///
212/// Specs with the same `(repository, name)` identity are ordered by `effective_from`.
213/// A spec version's temporal end is derived from the successor spec's `effective_from`, or
214/// `+∞`. The repository identity is preserved as `Arc<LemmaRepository>` — never via string
215/// prefixes on the spec name. Repository names include the `@` prefix when present
216/// (e.g. `"@org/repo"`). Dependency isolation is enforced at `insert_spec`: all specs
217/// in a repository must share the same `dependency` provenance ID.
218#[derive(Debug, Serialize, Deserialize)]
219pub struct Context {
220    #[serde(with = "crate::snapshot::shared::arc_key_index_map")]
221    repositories: IndexMap<Arc<LemmaRepository>, IndexMap<String, LemmaSpecSet>>,
222    #[serde(with = "crate::snapshot::shared::arc")]
223    workspace: Arc<LemmaRepository>,
224}
225
226impl Default for Context {
227    fn default() -> Self {
228        Self::new()
229    }
230}
231
232impl Context {
233    /// Empty default repository; specs are inserted via [`Self::insert_spec`].
234    pub fn new() -> Self {
235        let workspace = Arc::new(LemmaRepository::new(None));
236        let mut repositories = IndexMap::new();
237        repositories.insert(Arc::clone(&workspace), IndexMap::new());
238        Self {
239            repositories,
240            workspace,
241        }
242    }
243
244    /// Default unnamed repository. Runtime APIs with `repository: None` use this.
245    /// Stable identity; `name = None`, `dependency = None`.
246    #[must_use]
247    pub fn workspace(&self) -> Arc<LemmaRepository> {
248        Arc::clone(&self.workspace)
249    }
250
251    /// Look up a repository by name without creating a new one.
252    #[must_use]
253    pub fn find_repository(&self, name: &str) -> Option<Arc<LemmaRepository>> {
254        let probe = Arc::new(LemmaRepository::new(Some(name.to_string())));
255        self.repositories
256            .get_key_value(&probe)
257            .map(|(k, _)| Arc::clone(k))
258    }
259
260    /// All spec sets, keyed by `(repository, name)`. Iteration order: repository first
261    /// (insertion order), then spec name ascending.
262    #[must_use]
263    pub fn repositories(&self) -> &IndexMap<Arc<LemmaRepository>, IndexMap<String, LemmaSpecSet>> {
264        &self.repositories
265    }
266
267    /// Flat iterator over every loaded [`LemmaSpec`] across all repositories.
268    ///
269    /// Used by registry resolution to discover missing `@owner/repo` qualifiers.
270    pub fn iter(&self) -> impl Iterator<Item = &LemmaSpec> + '_ {
271        self.repositories
272            .values()
273            .flat_map(|m| m.values())
274            .flat_map(|ss| ss.iter_specs())
275    }
276
277    /// Look up a spec set by `(repository, name)`. Returns `None` if no such spec set
278    /// is loaded.
279    #[must_use]
280    pub fn spec_set(&self, repository: &Arc<LemmaRepository>, name: &str) -> Option<&LemmaSpecSet> {
281        let canonical_name = crate::parsing::ast::ascii_lowercase_logical_name(name.to_string());
282        self.repositories
283            .get(repository)
284            .and_then(|m| m.get(&canonical_name))
285    }
286
287    /// Spec sets belonging to a repository. Panics if the repository is not in the map
288    /// (caller must ensure it was returned by this Context).
289    pub(crate) fn spec_sets_for(
290        &self,
291        repository: &Arc<LemmaRepository>,
292    ) -> impl Iterator<Item = &LemmaSpecSet> + '_ {
293        self.repositories
294            .get(repository)
295            .expect("BUG: repository not in context")
296            .values()
297    }
298
299    fn spec_declaration_source(spec: &LemmaSpec) -> crate::parsing::source::Source {
300        let source_type = spec
301            .source_type
302            .as_ref()
303            .expect("BUG: spec must carry source_type after parse");
304        crate::parsing::source::Source::new(
305            source_type.clone(),
306            crate::parsing::ast::Span {
307                start: 0,
308                end: 0,
309                line: spec.start_line,
310                col: 0,
311            },
312        )
313    }
314
315    fn duplicate_spec_path_line(spec: &LemmaSpec) -> (String, usize) {
316        let source_type = spec
317            .source_type
318            .as_ref()
319            .expect("BUG: spec must carry source_type after parse");
320        (source_type.to_string(), spec.start_line)
321    }
322
323    fn duplicate_spec_errors(name: &str, incoming: &LemmaSpec, existing: &LemmaSpec) -> Vec<Error> {
324        let (incoming_path, incoming_line) = Self::duplicate_spec_path_line(incoming);
325        let (existing_path, existing_line) = Self::duplicate_spec_path_line(existing);
326        vec![
327            Error::validation(
328                format!(
329                    "Duplicate spec '{name}' (also declared in '{existing_path}':{existing_line})"
330                ),
331                Some(Self::spec_declaration_source(incoming)),
332                None::<String>,
333            ),
334            Error::validation(
335                format!(
336                    "Duplicate spec '{name}' (also declared in '{incoming_path}':{incoming_line})"
337                ),
338                Some(Self::spec_declaration_source(existing)),
339                None::<String>,
340            ),
341        ]
342    }
343
344    /// Insert a spec under `repository`. Enforces two invariants:
345    /// 1. Dependency isolation: all specs in a repo must share the same `dependency`
346    ///    provenance. A locally loaded repo cannot be merged with a dependency repo, and
347    ///    two different dependencies cannot contribute to the same repo name.
348    /// 2. No duplicate `(repository, name, effective_from)` triples.
349    pub fn insert_spec(
350        &mut self,
351        repository: Arc<LemmaRepository>,
352        spec: LemmaSpec,
353    ) -> Result<(), Vec<Error>> {
354        if let Some((existing_repo, _)) = self.repositories.get_key_value(&repository) {
355            if existing_repo.dependency != repository.dependency {
356                let repo_subject = match repository.name.as_deref() {
357                    Some(name) => format!("Repository '{name}'"),
358                    None => "The default repository".to_string(),
359                };
360                let existing_owner = match &existing_repo.dependency {
361                    None => "a local source".to_string(),
362                    Some(id) => format!("dependency '{id}'"),
363                };
364                let new_owner = match &repository.dependency {
365                    None => "a local source".to_string(),
366                    Some(id) => format!("dependency '{id}'"),
367                };
368                return Err(vec![Error::validation_with_context(
369                    format!(
370                        "{repo_subject} was introduced by {existing_owner} but {new_owner} also declares it"
371                    ),
372                    None,
373                    Some("Each dependency's repositories must be unique across all loaded sources"),
374                    Some(&spec),
375                    None,
376                )]);
377            }
378        }
379
380        let entry = self
381            .repositories
382            .entry(Arc::clone(&repository))
383            .or_default();
384        if let Some(ss) = entry.get(&spec.name) {
385            if let Some(existing) = ss.get_exact(spec.effective_from()) {
386                return Err(Self::duplicate_spec_errors(&spec.name, &spec, existing));
387            }
388        }
389
390        let name = spec.name.clone();
391        if !entry
392            .entry(name.clone())
393            .or_insert_with(|| LemmaSpecSet::new(repository, name))
394            .insert(spec)
395        {
396            unreachable!("BUG: duplicate effective_from rejected above");
397        }
398        Ok(())
399    }
400
401    pub fn remove_spec(&mut self, repository: &Arc<LemmaRepository>, spec: &LemmaSpec) -> bool {
402        self.remove_spec_by_identity(repository, &spec.name, spec.effective_from())
403    }
404
405    /// Remove by `(repository, name, effective_from)` without needing a live `&LemmaSpec`.
406    pub fn remove_spec_by_identity(
407        &mut self,
408        repository: &Arc<LemmaRepository>,
409        name: &str,
410        effective_from: Option<&DateTimeValue>,
411    ) -> bool {
412        let Some(inner) = self.repositories.get_mut(repository) else {
413            return false;
414        };
415        let Some(ss) = inner.get_mut(name) else {
416            return false;
417        };
418        if !ss.remove(effective_from) {
419            return false;
420        }
421        if ss.is_empty() {
422            inner.shift_remove(name);
423        }
424        true
425    }
426}
427
428// ─── Engine ──────────────────────────────────────────────────────────
429
430/// One mutation in a transactional [`Engine::apply`] batch.
431///
432/// Removes are applied before loads/replaces so identity swaps cannot hit the
433/// duplicate-spec error or a mid-batch missing-dep replan. A batch is only
434/// `Remove`s, only `Load`s, or a single `Replace` — never `Load` mixed with `Replace`.
435enum Mutation {
436    Remove {
437        repository: Option<String>,
438        spec: String,
439        effective_from: EffectiveDate,
440    },
441    Load {
442        source_type: SourceType,
443        code: String,
444    },
445    /// Identity upsert from `code`, then prune other live specs with this
446    /// `source_type` when it is [`SourceType::Path`] or [`SourceType::Dependency`].
447    Replace {
448        repository: Option<String>,
449        source_type: SourceType,
450        code: String,
451    },
452}
453
454type StagedSpec = (SourceType, Arc<LemmaRepository>, LemmaSpec);
455
456/// Engine for evaluating Lemma rules.
457///
458/// Pure Rust implementation that evaluates Lemma specs directly from the AST.
459/// Uses pre-built execution plans that are self-contained and ready for evaluation.
460///
461/// The engine never performs network calls. External `@` references must be
462/// pre-resolved (include dependency sources in the source map, or drive
463/// [`crate::Resolve`] with a host [`crate::HttpTransport`]) before loading.
464#[derive(Serialize, Deserialize)]
465pub struct Engine {
466    pub(crate) context: Context,
467    pub(crate) plans: PlanStore,
468    limits: ResourceLimits,
469}
470
471impl Default for Engine {
472    fn default() -> Self {
473        Self::new()
474    }
475}
476
477impl Engine {
478    pub fn new() -> Self {
479        Self::with_limits(ResourceLimits::default())
480    }
481
482    pub fn with_limits(limits: ResourceLimits) -> Self {
483        let mut engine = Self {
484            context: Context::new(),
485            plans: PlanStore::new(),
486            limits,
487        };
488        engine
489            .apply(
490                vec![Mutation::Load {
491                    source_type: SourceType::Dependency(EMBEDDED_STDLIB_REPOSITORY.to_string()),
492                    code: crate::stdlib::UNITS_LEMMA.to_string(),
493                }],
494                true,
495            )
496            .expect("BUG: embedded stdlib must load");
497        engine
498    }
499
500    /// Resource limits configured for this engine.
501    pub fn limits(&self) -> &ResourceLimits {
502        &self.limits
503    }
504
505    /// Instance-list source row counts and Arc pointers by path for `spec`.
506    ///
507    /// Each entry is `(path, row_count, Arc::as_ptr as usize)`. Diagnostic for
508    /// Load-cost investigation. Not a supported consumer API.
509    #[doc(hidden)]
510    pub fn debug_instance_list_row_counts(
511        &self,
512        repository: Option<&str>,
513        spec: &str,
514    ) -> Vec<(String, usize, usize)> {
515        let Some(plan) = self.plans.get_plan(
516            repository,
517            spec,
518            &EffectiveDate::DateTimeValue(DateTimeValue::now()),
519        ) else {
520            return Vec::new();
521        };
522        let mut rows: Vec<(String, usize, usize)> = plan
523            .instance_lists
524            .iter()
525            .map(|(path, source)| {
526                let label = if path.segments.is_empty() {
527                    path.data.clone()
528                } else {
529                    let mut parts: Vec<&str> = path
530                        .segments
531                        .iter()
532                        .map(|segment| segment.uses.as_str())
533                        .collect();
534                    parts.push(path.data.as_str());
535                    parts.join(".")
536                };
537                (
538                    label,
539                    source.rows.len(),
540                    std::sync::Arc::as_ptr(&source.rows) as usize,
541                )
542            })
543            .collect();
544        rows.sort_by(|left, right| left.0.cmp(&right.0));
545        rows
546    }
547
548    /// Named-`uses` / list row-template Arc pointers for `spec`.
549    ///
550    /// Each entry is `(holder, identity, Arc::as_ptr as usize)`. `holder` is empty
551    /// for templates on `spec`'s plan; otherwise it is the template identity that
552    /// owns the nested entry (one level). Diagnostic for Load-cost investigation.
553    /// Not a supported consumer API.
554    #[doc(hidden)]
555    pub fn debug_row_template_arc_ptrs(
556        &self,
557        repository: Option<&str>,
558        spec: &str,
559    ) -> Vec<(String, String, usize)> {
560        let Some(plan) = self.plans.get_plan(
561            repository,
562            spec,
563            &EffectiveDate::DateTimeValue(DateTimeValue::now()),
564        ) else {
565            return Vec::new();
566        };
567        let mut rows = Vec::new();
568        for (identity, template) in &plan.row_templates {
569            let identity_label = identity.to_string();
570            rows.push((
571                String::new(),
572                identity_label.clone(),
573                std::sync::Arc::as_ptr(template) as usize,
574            ));
575            for (nested_id, nested) in &template.row_templates {
576                rows.push((
577                    identity_label.clone(),
578                    nested_id.to_string(),
579                    std::sync::Arc::as_ptr(nested) as usize,
580                ));
581            }
582        }
583        rows.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
584        rows
585    }
586
587    /// Serialize this engine (parsed specs + execution plans + limits) to bytes.
588    ///
589    /// Format: postcard header (`LEMS` magic + crate version) then a CRC32-protected
590    /// postcard body. Same sources loaded into two engines produce identical bytes.
591    ///
592    /// Typical use: `std::fs::write(path, engine.snapshot()?)` then later
593    /// `Engine::from_snapshot(&std::fs::read(path)?)`.
594    pub fn snapshot(&self) -> Result<Vec<u8>, Error> {
595        crate::snapshot::encode(self)
596    }
597
598    /// Restore an engine from [`Self::snapshot`] bytes.
599    ///
600    /// Rejects wrong magic, engine version mismatch, CRC failure, or corrupt body
601    /// with [`Error`]. Does not re-load the embedded stdlib (it is inside the snapshot).
602    /// After restore, `run` / `show` / `specs` / `update` / `remove` work as on a live engine.
603    pub fn from_snapshot(bytes: &[u8]) -> Result<Self, Error> {
604        crate::snapshot::decode(bytes)
605    }
606
607    /// Load Lemma sources in one planning pass. Pairs are `(source_type, source_text)`.
608    ///
609    /// Provenance is derived solely from [`SourceType`]: [`SourceType::Path`] and
610    /// [`SourceType::Volatile`] are local; [`SourceType::Dependency`] tags
611    /// repositories with that dependency id.
612    pub fn load(
613        &mut self,
614        sources: impl IntoIterator<Item = (SourceType, impl Into<String>)>,
615    ) -> Result<(), Errors> {
616        let mutations = sources
617            .into_iter()
618            .map(|(source_type, code)| Mutation::Load {
619                source_type,
620                code: code.into(),
621            })
622            .collect();
623        self.apply(mutations, false)
624    }
625
626    /// Replace identities present in `code` in a single planning pass.
627    ///
628    /// Parses `code` under `source_type`. Each parsed identity is inserted or
629    /// replaced by exact `(repository, name, effective_from)`. For
630    /// [`SourceType::Path`] and [`SourceType::Dependency`], live specs with the
631    /// same `source_type` that are absent from `code` are removed in the same
632    /// apply — including when `code` has zero specs (remove every live row of
633    /// that source). [`SourceType::Volatile`] never prunes siblings and requires
634    /// at least one spec in `code`.
635    ///
636    /// When `repository` is `Some(name)`, every staged spec's repository name must
637    /// equal `name`.
638    pub fn update(
639        &mut self,
640        repository: Option<&str>,
641        code: String,
642        source_type: SourceType,
643    ) -> Result<(), Errors> {
644        self.apply(
645            vec![Mutation::Replace {
646                repository: repository.map(str::to_string),
647                source_type,
648                code,
649            }],
650            false,
651        )
652    }
653
654    /// Remove a temporal spec slice and replan remaining specs.
655    pub fn remove(
656        &mut self,
657        repository: Option<&str>,
658        spec: &str,
659        effective: Option<&DateTimeValue>,
660    ) -> Result<(), Error> {
661        let resolved = self.get_spec(spec, repository, effective)?;
662        let effective_from = resolved.effective_from.clone();
663        self.apply(
664            vec![Mutation::Remove {
665                repository: repository.map(str::to_string),
666                spec: spec.to_string(),
667                effective_from,
668            }],
669            false,
670        )
671        .map_err(|errs| {
672            errs.errors
673                .into_iter()
674                .next()
675                .expect("BUG: apply Errors must contain at least one error")
676        })
677    }
678
679    /// Every loaded repository in insertion order (default unnamed repository, embedded stdlib [`EMBEDDED_STDLIB_REPOSITORY`], dependencies).
680    ///
681    /// Returns spec version rows (metadata only, no AST, no source text).
682    #[must_use]
683    pub fn specs(&self) -> Vec<Repository> {
684        self.context
685            .repositories()
686            .iter()
687            .map(|(repo, inner)| {
688                let specs = inner
689                    .values()
690                    .flat_map(|spec_set| {
691                        spec_set
692                            .iter_with_ranges()
693                            .map(|(spec, from, to)| SpecVersion {
694                                name: spec.name.clone(),
695                                effective_from: from,
696                                effective_to: to,
697                                dependencies: uses_dependencies(spec),
698                            })
699                    })
700                    .collect();
701                Repository {
702                    repository: repo.name.clone(),
703                    specs,
704                    from_dependency: repo.dependency.is_some(),
705                }
706            })
707            .collect()
708    }
709
710    /// Spec interface and resolved temporal window at `effective`.
711    ///
712    /// `Show.data` lists every declared promptable slot. Empty
713    /// [`ShowData::needed_by_rules`] means offered for reuse (`data x: alias.slot`),
714    /// not needed by this spec's remaining rules after normalize.
715    /// Lemma source text is [`Self::source`].
716    pub fn show(
717        &self,
718        repository: Option<&str>,
719        spec: &str,
720        effective: Option<&DateTimeValue>,
721    ) -> Result<Show, Error> {
722        let effective_dt = self.effective_or_now(effective);
723        let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
724
725        let plan = match self.plans.get_plan(repository, spec, &instant) {
726            Some(plan) => plan,
727            None => {
728                // Preserve attributed not-found errors (repository vs spec) without
729                // paying for SpecSet lookup on the common success path.
730                let repository_arc = self.repository_for_lookup(repository)?;
731                let canonical_name =
732                    crate::parsing::ast::ascii_lowercase_logical_name(spec.to_string());
733                let spec_set = self.context.spec_set(&repository_arc, &canonical_name);
734                return match spec_set.and_then(|ss| ss.spec_at(&instant)) {
735                    None => Err(self.spec_not_found_in_repository_error(
736                        &repository_arc,
737                        spec,
738                        &effective_dt,
739                    )),
740                    Some(_) => Err(Error::request_not_found(
741                        format!(
742                            "No execution plan slice for spec '{spec}' at effective {effective_dt}"
743                        ),
744                        Some("Ensure sources loaded and planning succeeded".to_string()),
745                    )),
746                };
747            }
748        };
749        Ok(self.show_plan(plan, repository))
750    }
751
752    /// Declarations of `spec` at `effective` as loaded from source, including data and
753    /// rules that no other rule needs. Unlike [`Self::show`], nothing is filtered by use.
754    pub fn spec_declaration(
755        &self,
756        repository: Option<&str>,
757        spec: &str,
758        effective: Option<&DateTimeValue>,
759    ) -> Result<&LemmaSpec, Error> {
760        let effective_dt = self.effective_or_now(effective);
761        let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
762        let repository_arc = self.repository_for_lookup(repository)?;
763        let canonical_name = crate::parsing::ast::ascii_lowercase_logical_name(spec.to_string());
764        match self
765            .context
766            .spec_set(&repository_arc, &canonical_name)
767            .and_then(|spec_set| spec_set.spec_at(&instant))
768        {
769            Some(found) => Ok(found),
770            None => {
771                Err(self.spec_not_found_in_repository_error(&repository_arc, spec, &effective_dt))
772            }
773        }
774    }
775
776    /// Interface of the resolved spec `identity` names, e.g. the row spec of a list type.
777    /// Identities come from loaded plans, so the plan exists.
778    pub fn show_spec(&self, identity: &SpecIdentity) -> Show {
779        let plan = self
780            .plans
781            .get_plan(
782                identity.repository.as_deref(),
783                &identity.spec,
784                &identity.effective,
785            )
786            .unwrap_or_else(|| panic!("BUG: no plan for resolved spec '{identity}'"));
787        self.show_plan(plan, identity.repository.as_deref())
788    }
789
790    /// Plan that produced `show` (repo + spec + start line + slice effective).
791    /// `None` when no plans are loaded for that spec (synthetic Show in tests).
792    pub(crate) fn plan_matching_show(
793        &self,
794        show: &crate::planning::execution_plan::Show,
795    ) -> Option<&crate::planning::execution_plan::ExecutionPlan> {
796        let plans = self
797            .plans
798            .get_plans(show.repository.as_deref(), &show.spec)?;
799        let mut matches = plans.values().filter(|plan| {
800            plan.spec_name == show.spec
801                && plan.start_line == show.start_line
802                && plan.effective_from == show.effective_from
803                && plan.effective_to == show.effective_to
804                && plan.effective == show.plan_effective
805        });
806        let first = matches.next()?;
807        if matches.next().is_some() {
808            panic!(
809                "BUG: multiple plans match Show '{}' (start_line {}, effective window, plan_effective)",
810                show.spec, show.start_line
811            );
812        }
813        Some(first)
814    }
815
816    fn show_plan(&self, plan: &ExecutionPlan, repository: Option<&str>) -> Show {
817        let mut type_entries: Vec<(usize, String, crate::planning::semantics::LemmaType)> =
818            Vec::new();
819        let mut data_entries: Vec<(usize, usize, String, ShowData)> = Vec::new();
820
821        for (path, definition) in plan.nested_input_keys() {
822            let Some(lemma_type) = definition.schema_type().cloned() else {
823                continue;
824            };
825            let input_key = path.input_key();
826            if path.segments.is_empty()
827                && matches!(
828                    definition,
829                    DataDefinition::Value { .. }
830                        | DataDefinition::TypeDeclaration { .. }
831                        | DataDefinition::Reference { .. }
832                )
833            {
834                type_entries.push((
835                    definition.source().span.start,
836                    input_key.clone(),
837                    lemma_type.clone(),
838                ));
839            }
840            let rule_positions = plan.data_needed_by(&path);
841            if rule_positions.is_empty() {
842                continue;
843            }
844            let used_by: Vec<String> = rule_positions
845                .iter()
846                .map(|&rule_position| {
847                    plan.rules
848                        .get_index(rule_position as usize)
849                        .expect("BUG: needed_by_rules position out of plan.rules range")
850                        .1
851                        .path
852                        .input_key()
853                })
854                .collect();
855            let display = plan.data_display.get(&path);
856            data_entries.push((
857                path.segments.len(),
858                definition.source().span.start,
859                input_key.clone(),
860                ShowData {
861                    lemma_type: lemma_type.clone(),
862                    path: path.segments.clone(),
863                    value: match definition {
864                        DataDefinition::InstanceList { .. } => {
865                            crate::planning::execution_plan::show_instance_list_value(plan, &path)
866                        }
867                        _ => {
868                            if let Some(target) = plan.import_overlay_reference_target(&path) {
869                                // Document the wire; not a literal default (OpenAPI skips
870                                // default when reference is set) and not `required`.
871                                Some(crate::result_value::RuleResultValue {
872                                    reference: Some(target),
873                                    ..Default::default()
874                                })
875                            } else {
876                                display.and_then(|d| {
877                                    d.value.as_ref().map(|bound| {
878                                        crate::planning::execution_plan::show_bound_result_value(
879                                            bound,
880                                            &lemma_type,
881                                            &input_key,
882                                            crate::planning::execution_plan::ShowBoundRole::Value,
883                                        )
884                                    })
885                                })
886                            }
887                        }
888                    },
889                    suggestion: display.and_then(|d| {
890                        d.suggestion.as_ref().map(|bound| {
891                            crate::planning::execution_plan::show_bound_result_value(
892                                bound,
893                                &lemma_type,
894                                &input_key,
895                                crate::planning::execution_plan::ShowBoundRole::Suggestion,
896                            )
897                        })
898                    }),
899                    needed_by_rules: used_by,
900                },
901            ));
902        }
903        type_entries.sort_by_key(|(pos, _, _)| *pos);
904        data_entries.sort_by_key(|(depth, pos, _, _)| (*depth, *pos));
905
906        let rule_entries: Vec<(String, crate::planning::show_expression::ShowRule)> = plan
907            .show_rules
908            .iter()
909            .map(|(path, rule)| (path.input_key(), rule.clone()))
910            .collect();
911
912        let show_repository = match repository {
913            None => None,
914            Some(q) => {
915                let repo = self
916                    .context
917                    .find_repository(q)
918                    .expect("BUG: get_plan succeeded so that repository must be loaded");
919                Some(
920                    repo.name
921                        .clone()
922                        .expect("BUG: named repository cannot have name None"),
923                )
924            }
925        };
926
927        Show {
928            repository: show_repository,
929            spec: plan.spec_name.clone(),
930            commentary: plan.commentary.clone(),
931            effective_from: plan.effective_from.clone(),
932            effective_to: plan.effective_to.clone(),
933            plan_effective: plan.effective.clone(),
934            start_line: plan.start_line,
935            source_type: plan.source_type.clone(),
936            types: type_entries
937                .into_iter()
938                .map(|(_, name, lemma_type)| (name, lemma_type))
939                .collect(),
940            data: data_entries
941                .into_iter()
942                .map(|(_, _, name, entry)| (name, entry))
943                .collect(),
944            rules: rule_entries.into_iter().collect(),
945            meta: plan.meta.clone(),
946        }
947    }
948
949    /// Formatted canonical Lemma source for a repository or one spec slice.
950    ///
951    /// When `spec` is `None`, returns all specs in the repository sorted by name/effective.
952    /// When `spec` is `Some`, `effective` selects the temporal slice (default: now).
953    pub fn source(
954        &self,
955        repository: Option<&str>,
956        spec: Option<&str>,
957        effective: Option<&DateTimeValue>,
958    ) -> Result<String, Error> {
959        match spec {
960            None => self.format_repository_source(repository),
961            Some(spec_name) => {
962                let effective_dt = self.effective_or_now(effective);
963                let resolved_spec = self.get_spec(spec_name, repository, Some(&effective_dt))?;
964                Ok(crate::formatting::format_spec_refs(&[resolved_spec]))
965            }
966        }
967    }
968
969    /// Evaluate a spec.
970    pub fn evaluate(
971        &self,
972        repository: Option<&str>,
973        spec: &str,
974        effective: Option<&DateTimeValue>,
975        data: IndexMap<String, EvaluateDataValue>,
976        rules: Option<&[String]>,
977        explain: bool,
978    ) -> Result<Response, Error> {
979        let effective = self.effective_or_now(effective);
980        let instant = EffectiveDate::DateTimeValue(effective.clone());
981
982        let plan = self
983            .plans
984            .get_plan(repository, spec, &instant)
985            .ok_or_else(|| {
986                Error::request_not_found(
987                    format!("No execution plan for spec '{spec}' at effective {effective}"),
988                    Some("Ensure sources loaded and planning succeeded".to_string()),
989                )
990            })?;
991
992        let response_rules = plan.validated_response_rule_paths(rules)?;
993        let evaluate_data = EvaluateData::resolve(plan, data, &self.limits)?;
994        let now_semantic = crate::planning::semantics::date_time_to_semantic(&effective);
995        let now_literal = crate::planning::semantics::LiteralValue::date(now_semantic);
996        let evaluator = Evaluator;
997        let mut response =
998            evaluator.evaluate(plan, &evaluate_data, now_literal, &response_rules, explain);
999
1000        response.spec_effective_from = plan.effective_from.clone();
1001        response.spec_effective_to = plan.effective_to.clone();
1002
1003        Ok(response)
1004    }
1005
1006    /// Deprecated alias of [`Engine::evaluate`].
1007    #[deprecated(note = "use evaluate")]
1008    pub fn run(
1009        &self,
1010        repository: Option<&str>,
1011        spec: &str,
1012        effective: Option<&DateTimeValue>,
1013        data: IndexMap<String, EvaluateDataValue>,
1014        rules: Option<&[String]>,
1015        explain: bool,
1016    ) -> Result<Response, Error> {
1017        self.evaluate(repository, spec, effective, data, rules, explain)
1018    }
1019
1020    fn format_repository_source(&self, repository: Option<&str>) -> Result<String, Error> {
1021        let repo_arc = self.resolve_repository(repository)?;
1022        let mut all_specs: Vec<&LemmaSpec> = self
1023            .context
1024            .spec_sets_for(&repo_arc)
1025            .flat_map(|ss| ss.iter_specs())
1026            .collect();
1027        all_specs.sort_by(|a, b| {
1028            a.name
1029                .cmp(&b.name)
1030                .then_with(|| a.effective_from.cmp(&b.effective_from))
1031        });
1032        let body = crate::formatting::format_spec_refs(&all_specs);
1033        let mut source_text = String::new();
1034        if let Some(name) = repo_arc.name.as_deref() {
1035            source_text.push_str("repo ");
1036            source_text.push_str(name);
1037            source_text.push_str("\n\n");
1038        }
1039        source_text.push_str(&body);
1040        Ok(source_text)
1041    }
1042
1043    fn resolve_repository(&self, repository: Option<&str>) -> Result<Arc<LemmaRepository>, Error> {
1044        match repository {
1045            None => Ok(self.context.workspace()),
1046            Some(qualifier) => {
1047                let q = qualifier.trim();
1048                if q.is_empty() {
1049                    return Err(Error::request(
1050                        "Repository qualifier cannot be empty",
1051                        None::<String>,
1052                    ));
1053                }
1054                self.context.find_repository(q).ok_or_else(|| {
1055                    Error::request_not_found(
1056                        format!("Repository '{qualifier}' not loaded"),
1057                        Some(format!(
1058                            "List repositories with `{}` after loading your workspace",
1059                            "lemma specs"
1060                        )),
1061                    )
1062                })
1063            }
1064        }
1065    }
1066
1067    fn spec_not_found_in_repository_error(
1068        &self,
1069        repository: &LemmaRepository,
1070        spec_name: &str,
1071        effective: &DateTimeValue,
1072    ) -> Error {
1073        let message = match &repository.name {
1074            Some(n) => {
1075                format!("Spec '{spec_name}' not found in repository {n} at effective {effective}")
1076            }
1077            None => format!("Spec '{spec_name}' not found at effective {effective}"),
1078        };
1079        Error::request_not_found(message, Some("Try `lemma specs`"))
1080    }
1081
1082    fn repository_for_lookup(
1083        &self,
1084        repository: Option<&str>,
1085    ) -> Result<Arc<LemmaRepository>, Error> {
1086        match repository {
1087            Some(q) => self.context.find_repository(q).ok_or_else(|| {
1088                Error::request_not_found(
1089                    format!("Repository '{q}' not loaded"),
1090                    Some("List repositories with `lemma specs` after loading your workspace"),
1091                )
1092            }),
1093            None => Ok(self.context.workspace()),
1094        }
1095    }
1096
1097    /// Effective datetime for a request: `explicit` or now.
1098    #[must_use]
1099    fn effective_or_now(&self, effective: Option<&DateTimeValue>) -> DateTimeValue {
1100        effective.cloned().unwrap_or_else(DateTimeValue::now)
1101    }
1102
1103    fn reserved_stdlib_error(source: Option<crate::parsing::source::Source>) -> Error {
1104        Error::validation(
1105            format!(
1106                "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"
1107            ),
1108            source,
1109            Some(
1110                "Load registry dependencies with @owner/repo qualifiers, not the reserved 'lemma' stdlib repository"
1111                    .to_string(),
1112            ),
1113        )
1114    }
1115
1116    fn resource_limit_errors(
1117        name: &str,
1118        limit: impl ToString,
1119        actual: impl ToString,
1120        hint: &str,
1121        sources: IndexMap<SourceType, String>,
1122    ) -> Errors {
1123        Errors {
1124            errors: vec![Error::resource_limit_exceeded(
1125                name,
1126                limit.to_string(),
1127                actual.to_string(),
1128                hint,
1129                None::<crate::parsing::source::Source>,
1130                None,
1131                None,
1132            )],
1133            sources: sources.into_iter().collect(),
1134        }
1135    }
1136
1137    /// Apply removes then loads/replaces in one planning pass. Rolls back the whole batch on failure.
1138    ///
1139    /// Load sources are order-preserving so multi-source parse errors are reported in
1140    /// submission order rather than scrambled by hash iteration.
1141    fn apply(&mut self, mutations: Vec<Mutation>, embedded_stdlib: bool) -> Result<(), Errors> {
1142        let mut sources: IndexMap<SourceType, String> = IndexMap::new();
1143        let mut to_restore: Vec<(Arc<LemmaRepository>, LemmaSpec)> = Vec::new();
1144        let mut replace: Option<(Option<String>, SourceType, String)> = None;
1145        let mut saw_load = false;
1146        let mut saw_remove = false;
1147
1148        for mutation in mutations {
1149            match mutation {
1150                Mutation::Remove {
1151                    repository,
1152                    spec,
1153                    effective_from,
1154                } => {
1155                    let repo_ref = repository.as_deref();
1156                    let repository_arc = self.resolve_repository(repo_ref).unwrap_or_else(|e| {
1157                        panic!(
1158                            "BUG: Mutation::Remove repository must resolve after public remove validated it: {e}"
1159                        )
1160                    });
1161                    let spec_to_remove = self
1162                        .context
1163                        .spec_set(&repository_arc, &spec)
1164                        .and_then(|ss| ss.get_exact(effective_from.as_ref()))
1165                        .unwrap_or_else(|| {
1166                            panic!(
1167                                "BUG: Mutation::Remove target '{spec}' must exist after public remove validated it"
1168                            )
1169                        });
1170                    saw_remove = true;
1171                    to_restore.push((Arc::clone(&repository_arc), spec_to_remove.clone()));
1172                }
1173                Mutation::Load { source_type, code } => {
1174                    saw_load = true;
1175                    if replace.is_some() {
1176                        panic!("BUG: Load and Replace in one apply");
1177                    }
1178                    if sources.insert(source_type.clone(), code).is_some() {
1179                        return Err(Errors {
1180                            errors: vec![Error::request(
1181                                format!("Duplicate source key: {source_type}"),
1182                                None::<String>,
1183                            )],
1184                            sources: sources.into_iter().collect(),
1185                        });
1186                    }
1187                }
1188                Mutation::Replace {
1189                    repository,
1190                    source_type,
1191                    code,
1192                } => {
1193                    if saw_load || !sources.is_empty() {
1194                        panic!("BUG: Load and Replace in one apply");
1195                    }
1196                    if replace.is_some() {
1197                        panic!("BUG: multiple Replace mutations in one apply");
1198                    }
1199                    replace = Some((repository, source_type, code));
1200                }
1201            }
1202        }
1203
1204        if saw_remove && (saw_load || replace.is_some()) {
1205            panic!("BUG: Remove mixed with Load or Replace in one apply");
1206        }
1207
1208        if let Some((repo_constraint, source_type, code)) = replace {
1209            return self.apply_replace(
1210                repo_constraint.as_deref(),
1211                source_type,
1212                code,
1213                embedded_stdlib,
1214            );
1215        }
1216
1217        let sources_map: HashMap<SourceType, String> = sources.clone().into_iter().collect();
1218        for st in sources.keys() {
1219            if let Err(e) = Self::validate_source_type_key(st, embedded_stdlib) {
1220                return Err(Errors {
1221                    errors: vec![e],
1222                    sources: sources_map,
1223                });
1224            }
1225        }
1226        self.check_batch_limits(&sources, embedded_stdlib)?;
1227
1228        let parse_limits = if embedded_stdlib {
1229            &ResourceLimits::default()
1230        } else {
1231            &self.limits
1232        };
1233        let mut staged: Vec<StagedSpec> = Vec::new();
1234        let mut errors: Vec<Error> = Vec::new();
1235
1236        for (source_id, code) in &sources {
1237            match self.stage_parsed_source(source_id, code, parse_limits, embedded_stdlib) {
1238                Ok(chunk) => staged.extend(chunk),
1239                Err(es) => errors.extend(es),
1240            }
1241        }
1242
1243        if !errors.is_empty() {
1244            return Err(Errors {
1245                errors,
1246                sources: sources.into_iter().collect(),
1247            });
1248        }
1249
1250        self.commit_staged(to_restore, staged, sources.into_iter().collect())
1251    }
1252
1253    fn validate_source_type_key(
1254        source_type: &SourceType,
1255        embedded_stdlib: bool,
1256    ) -> Result<(), Error> {
1257        match source_type {
1258            SourceType::Path(p) if p.as_os_str().to_string_lossy().trim().is_empty() => Err(
1259                Error::request("Source path must be non-empty", None::<String>),
1260            ),
1261            SourceType::Dependency(id) if id.is_empty() => Err(Error::request(
1262                "Dependency source identifier must be non-empty",
1263                None::<String>,
1264            )),
1265            SourceType::Dependency(id) if !embedded_stdlib && id == EMBEDDED_STDLIB_REPOSITORY => {
1266                Err(Self::reserved_stdlib_error(None))
1267            }
1268            _ => Ok(()),
1269        }
1270    }
1271
1272    fn check_batch_limits(
1273        &self,
1274        sources: &IndexMap<SourceType, String>,
1275        embedded_stdlib: bool,
1276    ) -> Result<(), Errors> {
1277        if embedded_stdlib || sources.is_empty() {
1278            return Ok(());
1279        }
1280        let limits = &self.limits;
1281        if sources.len() > limits.max_sources {
1282            return Err(Self::resource_limit_errors(
1283                "max_sources",
1284                limits.max_sources,
1285                sources.len(),
1286                "Reduce the number of paths or sources in one load",
1287                sources.clone(),
1288            ));
1289        }
1290        let total_loaded_bytes: usize = sources.values().map(|s| s.len()).sum();
1291        if total_loaded_bytes > limits.max_loaded_bytes {
1292            return Err(Self::resource_limit_errors(
1293                "max_loaded_bytes",
1294                limits.max_loaded_bytes,
1295                total_loaded_bytes,
1296                "Load fewer or smaller sources",
1297                sources.clone(),
1298            ));
1299        }
1300        if let Some(code) = sources
1301            .values()
1302            .find(|code| code.len() > limits.max_source_size_bytes)
1303        {
1304            return Err(Self::resource_limit_errors(
1305                "max_source_size_bytes",
1306                limits.max_source_size_bytes,
1307                code.len(),
1308                "Use a smaller source text or increase limit",
1309                sources.clone(),
1310            ));
1311        }
1312        Ok(())
1313    }
1314
1315    fn stage_parsed_source(
1316        &self,
1317        source_id: &SourceType,
1318        code: &str,
1319        parse_limits: &ResourceLimits,
1320        embedded_stdlib: bool,
1321    ) -> Result<Vec<StagedSpec>, Vec<Error>> {
1322        let dependency = match source_id {
1323            SourceType::Dependency(id) => Some(id.as_str()),
1324            _ => None,
1325        };
1326        let result = parse(code, source_id.clone(), parse_limits).map_err(|e| vec![e])?;
1327        if result.repositories.is_empty() {
1328            return Ok(Vec::new());
1329        }
1330
1331        let mut staged = Vec::new();
1332        let mut errors = Vec::new();
1333        for (parsed_repo, specs) in result.repositories {
1334            let repository_arc = if let Some(dep_id) = dependency {
1335                let repo_name = parsed_repo
1336                    .name
1337                    .clone()
1338                    .or_else(|| Some(dep_id.to_string()));
1339                Arc::new(
1340                    LemmaRepository::new(repo_name)
1341                        .with_dependency(dep_id)
1342                        .with_start_line(parsed_repo.start_line),
1343                )
1344            } else {
1345                parsed_repo
1346            };
1347            if !embedded_stdlib
1348                && repository_arc.name.as_deref() == Some(EMBEDDED_STDLIB_REPOSITORY)
1349            {
1350                let source = crate::parsing::source::Source::new(
1351                    source_id.clone(),
1352                    crate::parsing::ast::Span {
1353                        start: 0,
1354                        end: 0,
1355                        line: repository_arc.start_line,
1356                        col: 0,
1357                    },
1358                );
1359                errors.push(Self::reserved_stdlib_error(Some(source)));
1360                continue;
1361            }
1362            for spec in specs {
1363                staged.push((source_id.clone(), Arc::clone(&repository_arc), spec));
1364            }
1365        }
1366        if !errors.is_empty() {
1367            return Err(errors);
1368        }
1369        Ok(staged)
1370    }
1371
1372    fn apply_replace(
1373        &mut self,
1374        repository_constraint: Option<&str>,
1375        source_type: SourceType,
1376        code: String,
1377        embedded_stdlib: bool,
1378    ) -> Result<(), Errors> {
1379        let mut sources: IndexMap<SourceType, String> = IndexMap::new();
1380        sources.insert(source_type.clone(), code.clone());
1381
1382        if let Err(e) = Self::validate_source_type_key(&source_type, embedded_stdlib) {
1383            return Err(Errors {
1384                errors: vec![e],
1385                sources: sources.into_iter().collect(),
1386            });
1387        }
1388
1389        self.check_batch_limits(&sources, embedded_stdlib)?;
1390
1391        let parse_limits = if embedded_stdlib {
1392            &ResourceLimits::default()
1393        } else {
1394            &self.limits
1395        };
1396
1397        let staged =
1398            match self.stage_parsed_source(&source_type, &code, parse_limits, embedded_stdlib) {
1399                Ok(s) => s,
1400                Err(errors) => {
1401                    return Err(Errors {
1402                        errors,
1403                        sources: sources.into_iter().collect(),
1404                    });
1405                }
1406            };
1407
1408        let prune = matches!(source_type, SourceType::Path(_) | SourceType::Dependency(_));
1409        if staged.is_empty() && !prune {
1410            return Err(Errors {
1411                errors: vec![Error::request(
1412                    "update requires at least one spec",
1413                    None::<String>,
1414                )],
1415                sources: sources.into_iter().collect(),
1416            });
1417        }
1418
1419        if let Some(required) = repository_constraint {
1420            let required_canonical =
1421                crate::parsing::ast::ascii_lowercase_logical_name(required.to_string());
1422            for (_, repository_arc, _) in &staged {
1423                if repository_arc.name.as_deref() != Some(required_canonical.as_str()) {
1424                    return Err(Errors {
1425                        errors: vec![Error::request(
1426                            match repository_arc.name.as_deref() {
1427                                Some(name) => format!(
1428                                    "update repository '{required}' does not match staged repository '{name}'"
1429                                ),
1430                                None => format!(
1431                                    "update repository '{required}' does not match the default repository"
1432                                ),
1433                            },
1434                            None::<String>,
1435                        )],
1436                        sources: sources.into_iter().collect(),
1437                    });
1438                }
1439            }
1440        }
1441
1442        let mut staged_keys: std::collections::HashSet<(Option<String>, String, EffectiveDate)> =
1443            std::collections::HashSet::new();
1444        for (_, repository_arc, spec) in &staged {
1445            staged_keys.insert((
1446                repository_arc.name.clone(),
1447                spec.name.clone(),
1448                spec.effective_from.clone(),
1449            ));
1450        }
1451
1452        let mut to_restore: Vec<(Arc<LemmaRepository>, LemmaSpec)> = Vec::new();
1453        for (repository, by_name) in self.context.repositories() {
1454            for spec_set in by_name.values() {
1455                for spec in spec_set.iter_specs() {
1456                    if spec.source_type.as_ref() != Some(&source_type) {
1457                        continue;
1458                    }
1459                    let key = (
1460                        repository.name.clone(),
1461                        spec.name.clone(),
1462                        spec.effective_from.clone(),
1463                    );
1464                    if prune || staged_keys.contains(&key) {
1465                        to_restore.push((Arc::clone(repository), spec.clone()));
1466                    }
1467                }
1468            }
1469        }
1470
1471        let staged_for_commit: Vec<StagedSpec> = staged
1472            .into_iter()
1473            .map(|(_, repo, spec)| (source_type.clone(), repo, spec))
1474            .collect();
1475
1476        self.commit_staged(to_restore, staged_for_commit, sources.into_iter().collect())
1477    }
1478
1479    fn commit_staged(
1480        &mut self,
1481        to_restore: Vec<(Arc<LemmaRepository>, LemmaSpec)>,
1482        staged: Vec<StagedSpec>,
1483        sources: HashMap<SourceType, String>,
1484    ) -> Result<(), Errors> {
1485        for (repo, spec) in &to_restore {
1486            if !self.context.remove_spec(repo, spec) {
1487                panic!("BUG: to_restore row must be live in context");
1488            }
1489        }
1490
1491        let mut inserted: Vec<(Arc<LemmaRepository>, String, EffectiveDate)> = Vec::new();
1492        let mut errors: Vec<Error> = Vec::new();
1493        for (_, repository_arc, spec) in staged {
1494            let name = spec.name.clone();
1495            let effective_from = spec.effective_from.clone();
1496            match self.context.insert_spec(Arc::clone(&repository_arc), spec) {
1497                Ok(()) => inserted.push((repository_arc, name, effective_from)),
1498                Err(es) => errors.extend(es),
1499            }
1500        }
1501        if !errors.is_empty() {
1502            self.rollback_apply(&inserted, &to_restore);
1503            return Err(Errors { errors, sources });
1504        }
1505
1506        let result = crate::planning::plan(&self.context, &self.limits, &self.plans);
1507        if !result.errors.is_empty() {
1508            self.rollback_apply(&inserted, &to_restore);
1509            return Err(Errors {
1510                errors: result.errors,
1511                sources,
1512            });
1513        }
1514
1515        self.plans = result.plans;
1516        Ok(())
1517    }
1518
1519    fn rollback_apply(
1520        &mut self,
1521        inserted: &[(Arc<LemmaRepository>, String, EffectiveDate)],
1522        removed: &[(Arc<LemmaRepository>, LemmaSpec)],
1523    ) {
1524        for (repo, inserted_name, inserted_effective) in inserted.iter().rev() {
1525            if !self.context.remove_spec_by_identity(
1526                repo,
1527                inserted_name,
1528                inserted_effective.as_ref(),
1529            ) {
1530                panic!("BUG: inserted spec must be removable on rollback");
1531            }
1532        }
1533        for (repo, spec) in removed.iter().rev() {
1534            self.context
1535                .insert_spec(Arc::clone(repo), spec.clone())
1536                .expect("BUG: restore removed spec for rollback");
1537        }
1538    }
1539
1540    /// Active [`LemmaSpec`] slice for `name` at the resolved effective instant in `repository`.
1541    ///
1542    /// When `repository` is `None`, uses the default unnamed repository. When `effective` is `None`, uses now.
1543    pub(crate) fn get_spec(
1544        &self,
1545        name: &str,
1546        repository: Option<&str>,
1547        effective: Option<&DateTimeValue>,
1548    ) -> Result<&LemmaSpec, Error> {
1549        let effective_dt = self.effective_or_now(effective);
1550        let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
1551        let repository_arc = match repository {
1552            Some(q) => self.context.find_repository(q).ok_or_else(|| {
1553                Error::request_not_found(
1554                    format!("Repository '{q}' not loaded"),
1555                    Some("List repositories with `lemma specs` after loading your workspace"),
1556                )
1557            })?,
1558            None => self.context.workspace(),
1559        };
1560        let spec_set = self
1561            .context
1562            .spec_set(&repository_arc, name)
1563            .ok_or_else(|| {
1564                self.spec_not_found_in_repository_error(&repository_arc, name, &effective_dt)
1565            })?;
1566        spec_set.spec_at(&instant).ok_or_else(|| {
1567            self.spec_not_found_in_repository_error(&repository_arc, name, &effective_dt)
1568        })
1569    }
1570}
1571#[cfg(test)]
1572mod tests {
1573    use super::*;
1574
1575    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
1576        DateTimeValue {
1577            year,
1578            month,
1579            day,
1580            hour: 0,
1581            minute: 0,
1582            second: 0,
1583            microsecond: 0,
1584            timezone: None,
1585            granularity: crate::literals::DateGranularity::Full,
1586        }
1587    }
1588
1589    #[test]
1590    fn format_repositories_prints_each_temporal_version() {
1591        let text = format_repositories(
1592            &[
1593                Repository {
1594                    repository: None,
1595                    from_dependency: false,
1596                    specs: vec![
1597                        SpecVersion {
1598                            name: "legacy".into(),
1599                            effective_from: None,
1600                            effective_to: Some(date(2026, 1, 1)),
1601                            dependencies: Vec::new(),
1602                        },
1603                        SpecVersion {
1604                            name: "taxes".into(),
1605                            effective_from: None,
1606                            effective_to: None,
1607                            dependencies: Vec::new(),
1608                        },
1609                        SpecVersion {
1610                            name: "pricing".into(),
1611                            effective_from: Some(date(2026, 1, 1)),
1612                            effective_to: None,
1613                            dependencies: Vec::new(),
1614                        },
1615                        SpecVersion {
1616                            name: "pricing".into(),
1617                            effective_from: Some(date(2025, 1, 1)),
1618                            effective_to: Some(date(2026, 1, 1)),
1619                            dependencies: Vec::new(),
1620                        },
1621                    ],
1622                },
1623                Repository {
1624                    repository: Some("@iso/countries".into()),
1625                    from_dependency: true,
1626                    specs: vec![SpecVersion {
1627                        name: "alpha2".into(),
1628                        effective_from: Some(date(2011, 8, 9)),
1629                        effective_to: None,
1630                        dependencies: Vec::new(),
1631                    }],
1632                },
1633            ],
1634            true,
1635        );
1636        assert_eq!(
1637            text,
1638            "legacy ...2026-01-01\npricing 2025-01-01...2026-01-01\npricing 2026-01-01\ntaxes\n\n@iso/countries\n  alpha2 2011-08-09\n"
1639        );
1640    }
1641
1642    #[test]
1643    fn spec_version_display_omits_arrow_without_an_end() {
1644        assert_eq!(
1645            SpecVersion {
1646                name: "pricing".into(),
1647                effective_from: Some(date(2025, 1, 1)),
1648                effective_to: Some(date(2026, 1, 1)),
1649                dependencies: Vec::new(),
1650            }
1651            .to_string(),
1652            "pricing 2025-01-01...2026-01-01"
1653        );
1654        assert_eq!(
1655            SpecVersion {
1656                name: "pricing".into(),
1657                effective_from: Some(date(2026, 1, 1)),
1658                effective_to: None,
1659                dependencies: Vec::new(),
1660            }
1661            .to_string(),
1662            "pricing 2026-01-01"
1663        );
1664        assert_eq!(
1665            SpecVersion {
1666                name: "pricing".into(),
1667                effective_from: None,
1668                effective_to: Some(date(2026, 1, 1)),
1669                dependencies: Vec::new(),
1670            }
1671            .to_string(),
1672            "pricing ...2026-01-01"
1673        );
1674        assert_eq!(
1675            SpecVersion {
1676                name: "taxes".into(),
1677                effective_from: None,
1678                effective_to: None,
1679                dependencies: Vec::new(),
1680            }
1681            .to_string(),
1682            "taxes"
1683        );
1684    }
1685
1686    #[test]
1687    fn format_local_specs_prints_uses_and_omits_dependency_repositories() {
1688        let text = format_repositories(
1689            &[
1690                Repository {
1691                    repository: None,
1692                    from_dependency: false,
1693                    specs: vec![SpecVersion {
1694                        name: "outer".into(),
1695                        effective_from: None,
1696                        effective_to: None,
1697                        dependencies: vec![
1698                            SpecDependency {
1699                                repository: None,
1700                                name: "inner".into(),
1701                                effective: None,
1702                            },
1703                            SpecDependency {
1704                                repository: Some("lemma".into()),
1705                                name: "units".into(),
1706                                effective: None,
1707                            },
1708                        ],
1709                    }],
1710                },
1711                Repository {
1712                    repository: Some("lemma".into()),
1713                    from_dependency: true,
1714                    specs: vec![SpecVersion {
1715                        name: "units".into(),
1716                        effective_from: None,
1717                        effective_to: None,
1718                        dependencies: Vec::new(),
1719                    }],
1720                },
1721            ],
1722            false,
1723        );
1724        assert_eq!(text, "outer\n  inner\n  lemma units\n");
1725    }
1726
1727    #[test]
1728    fn specs_rows_carry_uses_targets() {
1729        let mut engine = Engine::new();
1730        engine
1731            .load([(
1732                SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1733                r#"
1734spec inner 2024-01-01
1735data x: 1
1736
1737spec outer
1738uses inner 2024-01-01
1739uses u: lemma units
1740rule r: inner.x
1741"#
1742                .to_string(),
1743            )])
1744            .expect("load");
1745        let workspace = engine
1746            .specs()
1747            .into_iter()
1748            .find(|repo| repo.repository.is_none())
1749            .expect("workspace");
1750        let outer = workspace
1751            .specs
1752            .iter()
1753            .find(|version| version.name == "outer")
1754            .expect("outer");
1755        assert_eq!(
1756            outer.dependencies,
1757            vec![
1758                SpecDependency {
1759                    repository: None,
1760                    name: "inner".into(),
1761                    effective: Some(date(2024, 1, 1)),
1762                },
1763                SpecDependency {
1764                    repository: Some("lemma".into()),
1765                    name: "units".into(),
1766                    effective: None,
1767                },
1768            ]
1769        );
1770        assert!(engine
1771            .specs()
1772            .iter()
1773            .any(|repo| repo.repository.as_deref() == Some("lemma") && repo.from_dependency));
1774    }
1775
1776    fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
1777        let mut spec = LemmaSpec::new(name.to_string());
1778        spec.effective_from = crate::parsing::ast::EffectiveDate::from_option(effective_from);
1779        spec
1780    }
1781
1782    /// Spec-set temporal order is (name, effective_from) ascending.
1783    /// Same-name specs appear in temporal order; definition order in the source is irrelevant.
1784    #[test]
1785    fn list_order_is_name_then_effective_from_ascending() {
1786        let mut ctx = Context::new();
1787        let repository = ctx.workspace();
1788        let s_2026 = make_spec_with_range("mortgage", Some(date(2026, 1, 1)));
1789        let s_2025 = make_spec_with_range("mortgage", Some(date(2025, 1, 1)));
1790        ctx.insert_spec(Arc::clone(&repository), s_2026).unwrap();
1791        ctx.insert_spec(Arc::clone(&repository), s_2025).unwrap();
1792        let listed: Vec<_> = ctx
1793            .spec_set(&repository, "mortgage")
1794            .expect("mortgage set")
1795            .iter_specs()
1796            .collect();
1797        assert_eq!(listed.len(), 2);
1798        assert_eq!(listed[0].effective_from(), Some(&date(2025, 1, 1)));
1799        assert_eq!(listed[1].effective_from(), Some(&date(2026, 1, 1)));
1800    }
1801
1802    #[test]
1803    fn get_spec_resolves_temporal_version_by_effective() {
1804        let mut engine = Engine::new();
1805        engine
1806            .load([(
1807                SourceType::Path(Arc::new(std::path::PathBuf::from("a.lemma"))),
1808                r#"
1809        spec pricing 2025-01-01
1810        data x: 1
1811        rule r: x
1812    "#
1813                .to_string(),
1814            )])
1815            .unwrap();
1816        engine
1817            .load([(
1818                SourceType::Path(Arc::new(std::path::PathBuf::from("b.lemma"))),
1819                r#"
1820        spec pricing 2025-06-01
1821        data x: 2
1822        rule r: x
1823    "#
1824                .to_string(),
1825            )])
1826            .unwrap();
1827
1828        let jan = DateTimeValue {
1829            year: 2025,
1830            month: 1,
1831            day: 15,
1832            hour: 0,
1833            minute: 0,
1834            second: 0,
1835            microsecond: 0,
1836            timezone: None,
1837            granularity: crate::literals::DateGranularity::Full,
1838        };
1839        let jul = DateTimeValue {
1840            year: 2025,
1841            month: 7,
1842            day: 1,
1843            hour: 0,
1844            minute: 0,
1845            second: 0,
1846            microsecond: 0,
1847            timezone: None,
1848            granularity: crate::literals::DateGranularity::Full,
1849        };
1850
1851        let v1 = DateTimeValue {
1852            year: 2025,
1853            month: 1,
1854            day: 1,
1855            hour: 0,
1856            minute: 0,
1857            second: 0,
1858            microsecond: 0,
1859            timezone: None,
1860            granularity: crate::literals::DateGranularity::Full,
1861        };
1862        let v2 = DateTimeValue {
1863            year: 2025,
1864            month: 6,
1865            day: 1,
1866            hour: 0,
1867            minute: 0,
1868            second: 0,
1869            microsecond: 0,
1870            timezone: None,
1871            granularity: crate::literals::DateGranularity::Full,
1872        };
1873
1874        let s_jan = engine
1875            .get_spec("pricing", None, Some(&jan))
1876            .expect("jan spec");
1877        let s_jul = engine
1878            .get_spec("pricing", None, Some(&jul))
1879            .expect("jul spec");
1880        assert_eq!(s_jan.effective_from(), Some(&v1));
1881        assert_eq!(s_jul.effective_from(), Some(&v2));
1882    }
1883
1884    /// Every temporal row for a workspace spec name exposes half-open
1885    /// `[effective_from, effective_to)` via [`LemmaSpecSet::iter_with_ranges`]. The latest row's
1886    /// `effective_to` is `None` (no successor); earlier rows' `effective_to`
1887    /// equals the next row's `effective_from`.
1888    #[test]
1889    fn list_returns_half_open_ranges_per_temporal_version() {
1890        let mut engine = Engine::new();
1891        engine
1892            .load([(
1893                SourceType::Path(Arc::new(std::path::PathBuf::from("a.lemma"))),
1894                r#"
1895        spec pricing 2025-01-01
1896        data x: 1
1897        rule r: x
1898    "#
1899                .to_string(),
1900            )])
1901            .unwrap();
1902        engine
1903            .load([(
1904                SourceType::Path(Arc::new(std::path::PathBuf::from("b.lemma"))),
1905                r#"
1906        spec pricing 2025-06-01
1907        data x: 2
1908        rule r: x
1909    "#
1910                .to_string(),
1911            )])
1912            .unwrap();
1913
1914        let january = date(2025, 1, 1);
1915        let june = date(2025, 6, 1);
1916
1917        let workspace = engine
1918            .specs()
1919            .into_iter()
1920            .find(|r| r.repository.is_none())
1921            .expect("workspace");
1922        let mut pricing_rows: Vec<_> = workspace
1923            .specs
1924            .iter()
1925            .filter(|ls| ls.name == "pricing")
1926            .map(|ls| (ls.effective_from.clone(), ls.effective_to.clone()))
1927            .collect();
1928        pricing_rows.sort_by(|a, b| match (&a.0, &b.0) {
1929            (Some(x), Some(y)) => x.cmp(y),
1930            (None, Some(_)) => std::cmp::Ordering::Less,
1931            (Some(_), None) => std::cmp::Ordering::Greater,
1932            (None, None) => std::cmp::Ordering::Equal,
1933        });
1934        assert_eq!(pricing_rows.len(), 2);
1935        assert_eq!(
1936            pricing_rows[0],
1937            (Some(january.clone()), Some(june.clone())),
1938            "earlier row ends at the next row's effective_from"
1939        );
1940        assert_eq!(
1941            pricing_rows[1],
1942            (Some(june.clone()), None),
1943            "latest row has no successor; effective_to is None"
1944        );
1945
1946        assert!(
1947            !engine
1948                .specs()
1949                .into_iter()
1950                .find(|r| r.repository.is_none())
1951                .expect("workspace")
1952                .specs
1953                .iter()
1954                .any(|ls| ls.name == "unknown"),
1955            "no rows for unknown spec"
1956        );
1957    }
1958
1959    /// `Engine::specs()` provides spec sets grouped by repository.
1960    /// Each spec version row exposes half-open `[effective_from, effective_to)` ranges.
1961    #[test]
1962    fn get_workspace_specs_with_half_open_ranges() {
1963        let mut engine = Engine::new();
1964        engine
1965            .load([(
1966                SourceType::Path(Arc::new(std::path::PathBuf::from("pricing_v1.lemma"))),
1967                r#"
1968        spec pricing 2025-01-01
1969        data x: 1
1970        rule r: x
1971    "#
1972                .to_string(),
1973            )])
1974            .unwrap();
1975        engine
1976            .load([(
1977                SourceType::Path(Arc::new(std::path::PathBuf::from("pricing_v2.lemma"))),
1978                r#"
1979        spec pricing 2026-01-01
1980        data x: 2
1981        rule r: x
1982    "#
1983                .to_string(),
1984            )])
1985            .unwrap();
1986        engine
1987            .load([(
1988                SourceType::Path(Arc::new(std::path::PathBuf::from("taxes.lemma"))),
1989                r#"
1990        spec taxes
1991        data rate: 0.21
1992        rule amount: rate
1993    "#
1994                .to_string(),
1995            )])
1996            .unwrap();
1997
1998        let workspace = engine
1999            .specs()
2000            .into_iter()
2001            .find(|r| r.repository.is_none())
2002            .expect("workspace");
2003        let unique_names: std::collections::BTreeSet<&str> =
2004            workspace.specs.iter().map(|ls| ls.name.as_str()).collect();
2005        assert_eq!(
2006            unique_names.len(),
2007            2,
2008            "two unique spec names: pricing and taxes"
2009        );
2010
2011        let pricing_rows: Vec<_> = workspace
2012            .specs
2013            .iter()
2014            .filter(|ls| ls.name == "pricing")
2015            .collect();
2016        assert_eq!(pricing_rows.len(), 2);
2017        assert_eq!(pricing_rows[0].effective_from, Some(date(2025, 1, 1)));
2018        assert_eq!(
2019            pricing_rows[0].effective_to,
2020            Some(date(2026, 1, 1)),
2021            "earlier pricing row ends at the next pricing row's effective_from"
2022        );
2023        assert_eq!(pricing_rows[1].effective_from, Some(date(2026, 1, 1)));
2024        assert_eq!(
2025            pricing_rows[1].effective_to, None,
2026            "latest pricing row has no successor; effective_to is None"
2027        );
2028
2029        let tax_rows: Vec<_> = workspace
2030            .specs
2031            .iter()
2032            .filter(|ls| ls.name == "taxes")
2033            .collect();
2034        assert_eq!(tax_rows.len(), 1);
2035        assert_eq!(
2036            tax_rows[0].effective_from, None,
2037            "unversioned spec has no declared effective_from"
2038        );
2039        assert_eq!(
2040            tax_rows[0].effective_to, None,
2041            "unversioned spec has no successor; effective_to is None"
2042        );
2043    }
2044
2045    #[test]
2046    fn test_evaluate_spec_all_rules() {
2047        let mut engine = Engine::new();
2048        engine
2049            .load([(
2050                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
2051                r#"
2052        spec test
2053        data x: 10
2054        data y: 5
2055        rule added: x + y
2056        rule multiplied: x * y
2057    "#
2058                .to_string(),
2059            )])
2060            .unwrap();
2061
2062        let now = DateTimeValue::now();
2063        let response = engine
2064            .evaluate(None, "test", Some(&now), IndexMap::new(), None, false)
2065            .unwrap();
2066        assert_eq!(response.results.len(), 2);
2067
2068        let sum_result = response
2069            .results
2070            .values()
2071            .find(|r| r.rule.name == "added")
2072            .unwrap();
2073        assert_eq!(sum_result.result().expect("result").to_string(), "15");
2074
2075        let product_result = response
2076            .results
2077            .values()
2078            .find(|r| r.rule.name == "multiplied")
2079            .unwrap();
2080        assert_eq!(product_result.result().expect("result").to_string(), "50");
2081    }
2082
2083    #[test]
2084    fn test_evaluate_empty_data() {
2085        let mut engine = Engine::new();
2086        engine
2087            .load([(
2088                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
2089                r#"
2090        spec test
2091        data price: 100
2092        rule total: price * 2
2093    "#
2094                .to_string(),
2095            )])
2096            .unwrap();
2097
2098        let now = DateTimeValue::now();
2099        let response = engine
2100            .evaluate(None, "test", Some(&now), IndexMap::new(), None, false)
2101            .unwrap();
2102        assert_eq!(response.results.len(), 1);
2103        assert_eq!(
2104            response
2105                .results
2106                .values()
2107                .next()
2108                .unwrap()
2109                .result()
2110                .expect("result"),
2111            "200"
2112        );
2113    }
2114
2115    #[test]
2116    fn test_evaluate_boolean_rule() {
2117        let mut engine = Engine::new();
2118        engine
2119            .load([(
2120                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
2121                r#"
2122        spec test
2123        data age: 25
2124        rule is_adult: age >= 18
2125    "#
2126                .to_string(),
2127            )])
2128            .unwrap();
2129
2130        let now = DateTimeValue::now();
2131        let response = engine
2132            .evaluate(None, "test", Some(&now), IndexMap::new(), None, false)
2133            .unwrap();
2134        assert_eq!(
2135            response
2136                .results
2137                .values()
2138                .next()
2139                .unwrap()
2140                .result
2141                .as_ref()
2142                .unwrap()
2143                .boolean,
2144            Some(true)
2145        );
2146    }
2147
2148    #[test]
2149    fn test_evaluate_with_unless_clause() {
2150        let mut engine = Engine::new();
2151        engine
2152            .load([(
2153                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
2154                r#"
2155        spec test
2156        data quantity: 15
2157        rule discount: 0
2158          unless quantity >= 10 then 10
2159    "#
2160                .to_string(),
2161            )])
2162            .unwrap();
2163
2164        let now = DateTimeValue::now();
2165        let response = engine
2166            .evaluate(None, "test", Some(&now), IndexMap::new(), None, false)
2167            .unwrap();
2168        assert_eq!(
2169            response
2170                .results
2171                .values()
2172                .next()
2173                .unwrap()
2174                .result()
2175                .expect("result"),
2176            "10"
2177        );
2178    }
2179
2180    #[test]
2181    fn test_spec_not_found() {
2182        let engine = Engine::new();
2183        let now = DateTimeValue::now();
2184        let result = engine.evaluate(
2185            None,
2186            "nonexistent",
2187            Some(&now),
2188            IndexMap::new(),
2189            None,
2190            false,
2191        );
2192        assert!(result.is_err());
2193        let msg = result.unwrap_err().to_string();
2194        assert!(
2195            msg.contains("No execution plan") && msg.contains("nonexistent"),
2196            "missing spec must report no plan, got: {msg}"
2197        );
2198    }
2199
2200    #[test]
2201    fn test_multiple_specs() {
2202        let mut engine = Engine::new();
2203        engine
2204            .load([(
2205                SourceType::Path(Arc::new(std::path::PathBuf::from("spec 1.lemma"))),
2206                r#"
2207        spec spec1
2208        data x: 10
2209        rule result: x * 2
2210    "#
2211                .to_string(),
2212            )])
2213            .unwrap();
2214
2215        engine
2216            .load([(
2217                SourceType::Path(Arc::new(std::path::PathBuf::from("spec 2.lemma"))),
2218                r#"
2219        spec spec2
2220        data y: 5
2221        rule result: y * 3
2222    "#
2223                .to_string(),
2224            )])
2225            .unwrap();
2226
2227        let now = DateTimeValue::now();
2228        let response1 = engine
2229            .evaluate(None, "spec1", Some(&now), IndexMap::new(), None, false)
2230            .unwrap();
2231        assert_eq!(
2232            response1.results[0].result().expect("result").to_string(),
2233            "20"
2234        );
2235        let response2 = engine
2236            .evaluate(None, "spec2", Some(&now), IndexMap::new(), None, false)
2237            .unwrap();
2238        assert_eq!(
2239            response2.results[0].result().expect("result").to_string(),
2240            "15"
2241        );
2242    }
2243
2244    #[test]
2245    fn test_runtime_error_mapping() {
2246        let mut engine = Engine::new();
2247        engine
2248            .load([(
2249                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
2250                r#"
2251        spec test
2252        data numerator: 10
2253        data denominator: 0
2254        rule division: numerator / denominator
2255    "#
2256                .to_string(),
2257            )])
2258            .unwrap();
2259
2260        let now = DateTimeValue::now();
2261        let result = engine.evaluate(None, "test", Some(&now), IndexMap::new(), None, false);
2262        // Division by zero returns a Veto (not an error)
2263        assert!(result.is_ok(), "Evaluation should succeed");
2264        let response = result.unwrap();
2265        let division_result = response
2266            .results
2267            .values()
2268            .find(|r| r.rule.name == "division");
2269        assert!(
2270            division_result.is_some(),
2271            "Should have division rule result"
2272        );
2273        let division = division_result.unwrap();
2274        assert!(division.vetoed);
2275        assert!(
2276            division
2277                .veto_reason
2278                .as_deref()
2279                .unwrap()
2280                .contains("Division by zero"),
2281            "Veto message should mention division by zero: {:?}",
2282            division.veto_reason
2283        );
2284    }
2285
2286    #[test]
2287    fn test_rules_sorted_by_source_order() {
2288        let mut engine = Engine::new();
2289        engine
2290            .load([(
2291                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
2292                r#"
2293        spec test
2294        data a: 1
2295        data b: 2
2296        rule z: a + b
2297        rule y: a * b
2298        rule x: a - b
2299    "#
2300                .to_string(),
2301            )])
2302            .unwrap();
2303
2304        let now = DateTimeValue::now();
2305        let response = engine
2306            .evaluate(None, "test", Some(&now), IndexMap::new(), None, false)
2307            .unwrap();
2308        assert_eq!(response.results.len(), 3);
2309
2310        // Verify source positions increase (z < y < x)
2311        let z_pos = response
2312            .results
2313            .values()
2314            .find(|r| r.rule.name == "z")
2315            .unwrap()
2316            .rule
2317            .source_location
2318            .span
2319            .start;
2320        let y_pos = response
2321            .results
2322            .values()
2323            .find(|r| r.rule.name == "y")
2324            .unwrap()
2325            .rule
2326            .source_location
2327            .span
2328            .start;
2329        let x_pos = response
2330            .results
2331            .values()
2332            .find(|r| r.rule.name == "x")
2333            .unwrap()
2334            .rule
2335            .source_location
2336            .span
2337            .start;
2338
2339        assert!(z_pos < y_pos);
2340        assert!(y_pos < x_pos);
2341    }
2342
2343    #[test]
2344    fn test_rule_filtering_evaluates_dependencies() {
2345        let mut engine = Engine::new();
2346        engine
2347            .load([(
2348                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
2349                r#"
2350        spec test
2351        data base: 100
2352        rule subtotal: base * 2
2353        rule tax: subtotal * 10%
2354        rule total: subtotal + tax
2355    "#
2356                .to_string(),
2357            )])
2358            .unwrap();
2359
2360        let now = DateTimeValue::now();
2361        let response = engine
2362            .evaluate(
2363                None,
2364                "test",
2365                Some(&now),
2366                IndexMap::new(),
2367                Some(&["total".to_string()]),
2368                false,
2369            )
2370            .unwrap();
2371
2372        assert_eq!(response.results.len(), 1);
2373        assert_eq!(response.results.keys().next().unwrap(), "total");
2374
2375        // But the value should be correct (dependencies were computed)
2376        let total = response.results.values().next().unwrap();
2377        assert_eq!(total.result().expect("result").to_string(), "220");
2378    }
2379
2380    // -------------------------------------------------------------------
2381    // Pre-resolved dependency tests (Engine never fetches from registry)
2382    // -------------------------------------------------------------------
2383
2384    use crate::parsing::ast::DateTimeValue;
2385
2386    #[test]
2387    fn pre_resolved_deps_in_file_map_evaluates_external_spec() {
2388        let mut engine = Engine::new();
2389
2390        engine
2391            .load([(
2392                SourceType::Dependency("@org/project".to_string()),
2393                "repo @org/project\nspec helper\ndata quantity: 42".to_string(),
2394            )])
2395            .expect("should load dependency files");
2396
2397        engine
2398            .load([(
2399                SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
2400                r#"spec main_spec
2401uses external: @org/project helper
2402rule value: external.quantity"#
2403                    .to_string(),
2404            )])
2405            .expect("should succeed with pre-resolved deps");
2406
2407        let now = DateTimeValue::now();
2408        let response = engine
2409            .evaluate(None, "main_spec", Some(&now), IndexMap::new(), None, false)
2410            .expect("evaluate should succeed");
2411
2412        let value_result = response
2413            .results
2414            .get("value")
2415            .expect("rule 'value' should exist");
2416        assert_eq!(value_result.result().expect("result").to_string(), "42");
2417    }
2418
2419    #[test]
2420    fn show_with_repo_resolves_registry_spec() {
2421        let mut engine = Engine::new();
2422        engine
2423            .load([(
2424                SourceType::Dependency("@org/project".to_string()),
2425                "repo @org/project\nspec helper\ndata quantity: 42\nrule expose: quantity"
2426                    .to_string(),
2427            )])
2428            .expect("registry bundle loads");
2429
2430        engine
2431            .load([(
2432                SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
2433                r#"spec main_spec
2434data x: 1"#
2435                    .to_string(),
2436            )])
2437            .expect("main loads");
2438
2439        let now = DateTimeValue::now();
2440        let view = engine
2441            .show(Some("@org/project"), "helper", Some(&now))
2442            .expect("show for registry spec");
2443        assert!(view.data.contains_key("quantity"));
2444    }
2445
2446    #[test]
2447    fn load_no_external_refs_works() {
2448        let mut engine = Engine::new();
2449
2450        engine
2451            .load([(
2452                SourceType::Path(Arc::new(std::path::PathBuf::from("local.lemma"))),
2453                r#"spec local_only
2454data price: 100
2455rule doubled: price * 2"#
2456                    .to_string(),
2457            )])
2458            .expect("should succeed when there are no @... references");
2459
2460        let now = DateTimeValue::now();
2461        let response = engine
2462            .evaluate(None, "local_only", Some(&now), IndexMap::new(), None, false)
2463            .expect("evaluate should succeed");
2464
2465        let doubled = response.results.get("doubled").expect("doubled rule");
2466        assert_eq!(doubled.result().expect("result").to_string(), "200");
2467    }
2468
2469    #[test]
2470    fn unresolved_external_ref_without_deps_fails() {
2471        let mut engine = Engine::new();
2472
2473        let result = engine.load([(
2474            SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
2475            r#"spec main_spec
2476uses external: @org/project missing
2477rule value: external.quantity"#
2478                .to_string(),
2479        )]);
2480
2481        let errs = result.expect_err("Should fail when registry dep is not loaded");
2482        assert!(
2483            errs.iter()
2484                .any(|e| e.kind() == crate::ErrorKind::MissingRepository),
2485            "expected MissingRepository, got: {:?}",
2486            errs.iter().map(|e| e.kind()).collect::<Vec<_>>()
2487        );
2488    }
2489
2490    #[test]
2491    fn pre_resolved_deps_with_spec_and_type_refs() {
2492        let mut engine = Engine::new();
2493
2494        engine
2495            .load([(
2496                SourceType::Dependency("@org/example".to_string()),
2497                "repo @org/example\nspec helper\ndata value: 42".to_string(),
2498            )])
2499            .expect("should load helper file");
2500
2501        engine
2502        .load([(
2503                SourceType::Dependency("@iso/countries".to_string()),
2504                "repo @iso/countries\nspec alpha2\ndata code: text\n -> option \"NL\"\n -> option \"BE\"".to_string(),
2505            )])
2506            .expect("should load alpha2 file");
2507
2508        engine
2509            .load([(
2510                SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
2511                r#"spec registry_demo
2512uses @iso/countries alpha2
2513data country: alpha2.code
2514data unit_count: 5
2515uses @org/example helper
2516rule helper_value: helper.value
2517rule line_total: unit_count * 2
2518rule formatted: helper_value + 0"#
2519                    .to_string(),
2520            )])
2521            .expect("should succeed with pre-resolved spec and type deps");
2522
2523        let now = DateTimeValue::now();
2524        let response = engine
2525            .evaluate(
2526                None,
2527                "registry_demo",
2528                Some(&now),
2529                IndexMap::new(),
2530                None,
2531                false,
2532            )
2533            .expect("evaluate should succeed");
2534
2535        assert_eq!(
2536            response
2537                .results
2538                .get("helper_value")
2539                .expect("helper_value")
2540                .result()
2541                .expect("result"),
2542            "42"
2543        );
2544        let line = response
2545            .results
2546            .get("line_total")
2547            .expect("line_total")
2548            .result()
2549            .expect("result");
2550        assert_eq!(line, "10");
2551        assert_eq!(
2552            response
2553                .results
2554                .get("formatted")
2555                .expect("formatted")
2556                .result()
2557                .expect("result"),
2558            "42"
2559        );
2560    }
2561
2562    #[test]
2563    fn load_empty_labeled_source_is_error() {
2564        let mut engine = Engine::new();
2565        let err = engine
2566            .load([(
2567                SourceType::Path(Arc::new(std::path::PathBuf::from("  "))),
2568                "spec x\ndata a: 1".to_string(),
2569            )])
2570            .unwrap_err();
2571        assert!(err.errors.iter().any(|e| e.message().contains("non-empty")));
2572    }
2573
2574    #[test]
2575    fn add_dependency_files_accepts_registry_bundle_specs() {
2576        let mut engine = Engine::new();
2577        engine
2578            .load([(
2579                SourceType::Dependency("@org/my".to_string()),
2580                "repo @org/my\nspec helper\ndata x: 1".to_string(),
2581            )])
2582            .expect("dependency bundle specs should be accepted");
2583    }
2584
2585    #[test]
2586    fn user_load_rejects_reserved_embedded_stdlib_repository() {
2587        let mut engine = Engine::new();
2588        let batch = engine.load([(
2589            SourceType::Dependency(EMBEDDED_STDLIB_REPOSITORY.to_string()),
2590            "spec finance\ndata money: ratio -> decimals 2".to_string(),
2591        )]);
2592        assert!(
2593            batch.is_err(),
2594            "load must not write reserved lemma stdlib repo"
2595        );
2596        let msg = batch
2597            .unwrap_err()
2598            .errors
2599            .iter()
2600            .map(ToString::to_string)
2601            .collect::<Vec<_>>()
2602            .join("\n");
2603        assert!(
2604            msg.contains(EMBEDDED_STDLIB_REPOSITORY) && msg.contains("reserved"),
2605            "expected reserved-repo error, got: {msg}"
2606        );
2607
2608        let workspace = engine.load([(
2609            SourceType::Volatile,
2610            "repo lemma\nspec x\ndata a: 1".to_string(),
2611        )]);
2612        assert!(workspace.is_err(), "workspace repo lemma must be rejected");
2613        let msg = workspace
2614            .unwrap_err()
2615            .errors
2616            .iter()
2617            .map(ToString::to_string)
2618            .collect::<Vec<_>>()
2619            .join("\n");
2620        assert!(
2621            msg.contains(EMBEDDED_STDLIB_REPOSITORY) && msg.contains("reserved"),
2622            "expected reserved-repo error, got: {msg}"
2623        );
2624    }
2625
2626    #[test]
2627    fn load_returns_all_errors_not_just_first() {
2628        let mut engine = Engine::new();
2629
2630        let result = engine.load([(
2631            SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
2632            r#"spec demo
2633uses type_src: nonexistent_type_source
2634  -> with amount: 10
2635uses helper: nonexistent_spec
2636data price: 10
2637rule total: helper.value + price"#
2638                .to_string(),
2639        )]);
2640
2641        assert!(result.is_err(), "Should fail with multiple errors");
2642        let load_err = result.unwrap_err();
2643        assert!(
2644            load_err.errors.len() >= 2,
2645            "expected at least 2 errors (type + spec ref), got {}",
2646            load_err.errors.len()
2647        );
2648        let error_message = load_err
2649            .errors
2650            .iter()
2651            .map(ToString::to_string)
2652            .collect::<Vec<_>>()
2653            .join("; ");
2654
2655        assert!(
2656            error_message.contains("nonexistent_type_source"),
2657            "Should mention data import source spec. Got:\n{}",
2658            error_message
2659        );
2660        assert!(
2661            error_message.contains("nonexistent_spec"),
2662            "Should mention spec reference error about 'nonexistent_spec'. Got:\n{}",
2663            error_message
2664        );
2665    }
2666
2667    // ── Suggestion value type validation ────────────────────────────────
2668    // Planning must reject suggestion values that don't match the type.
2669    // These tests cover both primitives and named types (which the parser
2670    // can't validate because it doesn't resolve type names).
2671
2672    #[test]
2673    fn planning_rejects_invalid_number_default() {
2674        let mut engine = Engine::new();
2675        let result = engine.load([(
2676            SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
2677            "spec t\ndata x: number -> suggest \"10 $$\"]\nrule r: x".to_string(),
2678        )]);
2679        assert!(
2680            result.is_err(),
2681            "must reject non-numeric suggestion on number type"
2682        );
2683    }
2684
2685    #[test]
2686    fn planning_rejects_text_literal_as_number_default() {
2687        // `suggest "10"` produces a typed `CommandArg::Literal(Value::Text("10"))`.
2688        // Planning matches on the literal's variant — a `Text` literal is rejected
2689        // where a `Number` literal is required, even though `"10"` would parse as
2690        // a valid `Decimal` if coerced.
2691        let mut engine = Engine::new();
2692        let result = engine.load([(
2693            SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
2694            "spec t\ndata x: number -> suggest \"10\"]\nrule r: x".to_string(),
2695        )]);
2696        assert!(
2697            result.is_err(),
2698            "must reject text literal \"10\" as suggestion for number type"
2699        );
2700    }
2701
2702    #[test]
2703    fn planning_rejects_invalid_boolean_default() {
2704        let mut engine = Engine::new();
2705        let result = engine.load([(
2706            SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
2707            "spec t\ndata x: [boolean -> suggest \"maybe\"]\nrule r: x".to_string(),
2708        )]);
2709        assert!(
2710            result.is_err(),
2711            "must reject non-boolean suggestion on boolean type"
2712        );
2713    }
2714
2715    #[test]
2716    fn planning_rejects_invalid_named_type_default() {
2717        // Named type: the parser can't validate this, only planning can.
2718        let mut engine = Engine::new();
2719        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())]);
2720        assert!(
2721            result.is_err(),
2722            "must reject non-numeric suggestion on named number type"
2723        );
2724    }
2725
2726    #[test]
2727    fn context_merges_cross_file_repo_identities() {
2728        let mut engine = Engine::new();
2729
2730        // Load two files with the same named repo, but different spec names.
2731        engine
2732            .load([(
2733                SourceType::Path(Arc::new(std::path::PathBuf::from("file1.lemma"))),
2734                "repo shared\nspec a\ndata x: 1".to_string(),
2735            )])
2736            .expect("first file should load");
2737
2738        engine
2739            .load([(
2740                SourceType::Path(Arc::new(std::path::PathBuf::from("file2.lemma"))),
2741                "repo shared\nspec b\ndata y: 2".to_string(),
2742            )])
2743            .expect("second file should load");
2744
2745        // Both specs should land under the same repo entry.
2746        // Workspace, embedded stdlib (`lemma`), plus the "shared" repo.
2747        assert_eq!(
2748            engine.context.repositories().len(),
2749            3,
2750            "should have workspace, stdlib repository, and one named user repository"
2751        );
2752
2753        let shared_repo = engine
2754            .context
2755            .find_repository("shared")
2756            .expect("shared repo should exist");
2757        let shared_specs = engine.context.repositories().get(&shared_repo).unwrap();
2758        assert_eq!(
2759            shared_specs.len(),
2760            2,
2761            "shared repo should contain both specs"
2762        );
2763        assert!(shared_specs.contains_key("a"));
2764        assert!(shared_specs.contains_key("b"));
2765
2766        // Loading a dependency with the same repo name should be rejected.
2767        let dependency_result = engine.load([(
2768            SourceType::Dependency("@some/dep".to_string()),
2769            "repo shared\nspec c\ndata z: 3".to_string(),
2770        )]);
2771        assert!(
2772            dependency_result.is_err(),
2773            "dependency with an existing repo name must be rejected"
2774        );
2775
2776        let result = engine.load([(
2777            SourceType::Path(Arc::new(std::path::PathBuf::from("file2.lemma"))),
2778            "repo shared\nspec a\ndata y: 2".to_string(),
2779        )]);
2780
2781        assert!(
2782            result.is_err(),
2783            "should reject duplicate spec name in same repo"
2784        );
2785        let load_err = result.unwrap_err();
2786        assert_eq!(
2787            load_err.errors.len(),
2788            2,
2789            "duplicate spec must error on both declaring sources, got: {:?}",
2790            load_err.errors
2791        );
2792        let joined = load_err
2793            .errors
2794            .iter()
2795            .map(|e| e.to_string())
2796            .collect::<Vec<_>>()
2797            .join("\n");
2798        assert!(
2799            joined.contains("Duplicate spec 'a'"),
2800            "error should mention duplicate spec, got: {joined}"
2801        );
2802        let paths: Vec<String> = load_err
2803            .errors
2804            .iter()
2805            .map(|err| {
2806                err.location()
2807                    .expect("duplicate errors must have source")
2808                    .source_type
2809                    .to_string()
2810            })
2811            .collect();
2812        assert!(
2813            paths.iter().any(|p| p == "file1.lemma"),
2814            "first declaring file must get conflict diagnostic, got paths: {paths:?}"
2815        );
2816        assert!(
2817            paths.iter().any(|p| p == "file2.lemma"),
2818            "incoming file must get conflict diagnostic, got paths: {paths:?}"
2819        );
2820    }
2821
2822    #[test]
2823    fn test_list_structure() {
2824        let mut engine = Engine::new();
2825        engine
2826            .load([(
2827                SourceType::Path(Arc::new(std::path::PathBuf::from("file1.lemma"))),
2828                "repo shared\nspec a\ndata x: 1\nrule r: x".to_string(),
2829            )])
2830            .expect("file should load");
2831
2832        let repos = engine.specs();
2833        let shared_repo = repos
2834            .iter()
2835            .find(|r| r.repository.as_deref() == Some("shared"))
2836            .expect("shared repo in list");
2837        assert_eq!(shared_repo.specs.len(), 1);
2838        assert_eq!(shared_repo.specs[0].name, "a");
2839    }
2840
2841    fn path_st(name: &str) -> SourceType {
2842        SourceType::Path(Arc::new(std::path::PathBuf::from(name)))
2843    }
2844
2845    #[test]
2846    fn remove_none_removes_version_active_at_now() {
2847        let mut engine = Engine::new();
2848        engine
2849            .load([(
2850                path_st("t.lemma"),
2851                "spec t\ndata v: 1\nrule r: v\n\nspec t 2099-01-01\ndata v: 2\nrule r: v\n"
2852                    .to_string(),
2853            )])
2854            .expect("load");
2855        engine
2856            .remove(None, "t", None)
2857            .expect("remove active-at now (origin while before 2099)");
2858        let workspace = engine
2859            .specs()
2860            .into_iter()
2861            .find(|r| r.repository.is_none())
2862            .expect("workspace");
2863        assert_eq!(workspace.specs.iter().filter(|s| s.name == "t").count(), 1);
2864        assert!(
2865            workspace
2866                .specs
2867                .iter()
2868                .any(|s| s.name == "t" && s.effective_from.is_some()),
2869            "dated version must remain"
2870        );
2871    }
2872
2873    #[test]
2874    fn remove_none_errors_when_no_version_active_at_now() {
2875        let mut engine = Engine::new();
2876        engine
2877            .load([(
2878                path_st("t.lemma"),
2879                "spec t 2099-01-01\ndata v: 1\nrule r: v\n".to_string(),
2880            )])
2881            .expect("load");
2882        let err = engine
2883            .remove(None, "t", None)
2884            .expect_err("no version active at now");
2885        assert_eq!(err.kind(), crate::ErrorKind::Request);
2886        assert!(
2887            engine.show(None, "t", Some(&date(2099, 1, 1))).is_ok(),
2888            "future version must remain"
2889        );
2890    }
2891
2892    #[test]
2893    fn update_origin_code_keeps_later_version() {
2894        let mut engine = Engine::new();
2895        let st = path_st("t.lemma");
2896        engine
2897            .load([(
2898                st.clone(),
2899                "spec t\ndata v: 1\nrule r: v\n\nspec t 2099-01-01\ndata v: 2\nrule r: v\n"
2900                    .to_string(),
2901            )])
2902            .expect("load");
2903        engine
2904            .update(
2905                None,
2906                "spec t\ndata v: 9\nrule r: v\n\nspec t 2099-01-01\ndata v: 2\nrule r: v\n"
2907                    .to_string(),
2908                st,
2909            )
2910            .expect("update origin body while keeping later version in buffer");
2911        let workspace = engine
2912            .specs()
2913            .into_iter()
2914            .find(|r| r.repository.is_none())
2915            .expect("workspace");
2916        assert_eq!(workspace.specs.iter().filter(|s| s.name == "t").count(), 2);
2917        let now = DateTimeValue::now();
2918        let response = engine
2919            .evaluate(None, "t", Some(&now), IndexMap::new(), None, false)
2920            .expect("evaluate origin");
2921        assert_eq!(
2922            response.results.get("r").and_then(|r| r.result()),
2923            Some("9")
2924        );
2925    }
2926
2927    #[test]
2928    fn update_path_prunes_dropped_version() {
2929        let mut engine = Engine::new();
2930        let st = path_st("t.lemma");
2931        engine
2932            .load([(
2933                st.clone(),
2934                "spec t\ndata v: 1\nrule r: v\n\nspec t 2025-06-01\ndata v: 2\nrule r: v\n"
2935                    .to_string(),
2936            )])
2937            .expect("load");
2938        engine
2939            .update(None, "spec t\ndata v: 1\nrule r: v\n".to_string(), st)
2940            .expect("prune second version");
2941        let workspace = engine
2942            .specs()
2943            .into_iter()
2944            .find(|r| r.repository.is_none())
2945            .expect("workspace");
2946        assert_eq!(workspace.specs.iter().filter(|s| s.name == "t").count(), 1);
2947        assert!(workspace
2948            .specs
2949            .iter()
2950            .any(|s| s.name == "t" && s.effective_from.is_none()));
2951    }
2952
2953    #[test]
2954    fn update_volatile_does_not_prune_sibling() {
2955        let mut engine = Engine::new();
2956        engine
2957            .load([(
2958                SourceType::Volatile,
2959                "spec a\ndata v: 1\nrule r: v\n\nspec b\ndata v: 2\nrule r: v\n".to_string(),
2960            )])
2961            .expect("load");
2962        engine
2963            .update(
2964                None,
2965                "spec a\ndata v: 3\nrule r: v\n".to_string(),
2966                SourceType::Volatile,
2967            )
2968            .expect("update a");
2969        let workspace = engine
2970            .specs()
2971            .into_iter()
2972            .find(|r| r.repository.is_none())
2973            .expect("workspace");
2974        assert!(workspace.specs.iter().any(|s| s.name == "a"));
2975        assert!(workspace.specs.iter().any(|s| s.name == "b"));
2976    }
2977
2978    #[test]
2979    fn update_upserts_new_identity() {
2980        let mut engine = Engine::new();
2981        let st = path_st("t.lemma");
2982        engine
2983            .load([(st.clone(), "spec a\ndata v: 1\nrule r: v\n".to_string())])
2984            .expect("load");
2985        engine
2986            .update(
2987                None,
2988                "spec a\ndata v: 1\nrule r: v\n\nspec b\ndata v: 2\nrule r: v\n".to_string(),
2989                st,
2990            )
2991            .expect("upsert b");
2992        let workspace = engine
2993            .specs()
2994            .into_iter()
2995            .find(|r| r.repository.is_none())
2996            .expect("workspace");
2997        assert!(workspace.specs.iter().any(|s| s.name == "a"));
2998        assert!(workspace.specs.iter().any(|s| s.name == "b"));
2999    }
3000
3001    #[test]
3002    fn update_cross_path_identity_is_error() {
3003        let mut engine = Engine::new();
3004        engine
3005            .load([(
3006                path_st("a.lemma"),
3007                "spec conflict\ndata v: 1\nrule r: v\n".to_string(),
3008            )])
3009            .expect("load a");
3010        let err = engine
3011            .update(
3012                None,
3013                "spec conflict\ndata v: 2\nrule r: v\n".to_string(),
3014                path_st("b.lemma"),
3015            )
3016            .expect_err("cross-path identity");
3017        let joined: String = err
3018            .errors
3019            .iter()
3020            .map(|e| e.to_string())
3021            .collect::<Vec<_>>()
3022            .join("\n");
3023        assert!(
3024            joined.contains("Duplicate spec") && joined.contains("also declared"),
3025            "expected duplicate across paths, got: {joined}"
3026        );
3027        assert!(
3028            engine.show(None, "conflict", None).is_ok(),
3029            "failed cross-path update must leave the original identity loaded"
3030        );
3031    }
3032
3033    #[test]
3034    fn update_empty_dependency_prunes_all_specs_of_source() {
3035        let mut engine = Engine::new();
3036        let st = SourceType::Dependency("@org/dep".to_string());
3037        engine
3038            .load([(
3039                st.clone(),
3040                "repo @org/dep\n\nspec a\ndata v: 1\nrule r: v\n\nspec b\ndata v: 2\nrule r: v\n"
3041                    .to_string(),
3042            )])
3043            .expect("load");
3044        engine
3045            .update(None, "   \n".to_string(), st)
3046            .expect("empty dependency update prunes");
3047        let listed = engine.specs();
3048        assert!(
3049            !listed.iter().any(|r| {
3050                r.repository.as_deref() == Some("@org/dep")
3051                    && r.specs.iter().any(|s| s.name == "a" || s.name == "b")
3052            }),
3053            "empty dependency update must remove every live row of that source"
3054        );
3055    }
3056
3057    #[test]
3058    fn failed_apply_leaves_list_source_and_run_unchanged() {
3059        let mut engine = Engine::new();
3060        engine
3061            .load([(
3062                SourceType::Volatile,
3063                "spec price\ndata amount: number\nrule total: amount\n".to_string(),
3064            )])
3065            .expect("load");
3066        let before_repositories = engine.specs();
3067        let before_source = engine.source(None, Some("price"), None).expect("source");
3068        let now = DateTimeValue::now();
3069        let before = engine
3070            .evaluate(
3071                None,
3072                "price",
3073                Some(&now),
3074                [("amount".to_string(), EvaluateDataValue::from("4"))]
3075                    .into_iter()
3076                    .collect(),
3077                None,
3078                false,
3079            )
3080            .expect("evaluate");
3081        let failed = engine.load([(
3082            SourceType::Volatile,
3083            "spec price\ndata amount: number\nrule total: amount +\n".to_string(),
3084        )]);
3085        assert!(failed.is_err(), "invalid spec must fail planning");
3086        assert_eq!(engine.specs(), before_repositories);
3087        assert_eq!(
3088            engine.source(None, Some("price"), None).expect("source"),
3089            before_source
3090        );
3091        let after = engine
3092            .evaluate(
3093                None,
3094                "price",
3095                Some(&now),
3096                [("amount".to_string(), EvaluateDataValue::from("4"))]
3097                    .into_iter()
3098                    .collect(),
3099                None,
3100                false,
3101            )
3102            .expect("evaluate after failed load");
3103        assert_eq!(
3104            after.results.get("total").expect("total").result,
3105            before.results.get("total").expect("total").result
3106        );
3107    }
3108
3109    #[test]
3110    fn update_empty_path_prunes_all_specs_of_source() {
3111        let mut engine = Engine::new();
3112        let st = path_st("t.lemma");
3113        engine
3114            .load([(
3115                st.clone(),
3116                "spec a\ndata v: 1\nrule r: v\n\nspec b\ndata v: 2\nrule r: v\n".to_string(),
3117            )])
3118            .expect("load");
3119        engine
3120            .update(None, "   \n".to_string(), st)
3121            .expect("empty path update prunes");
3122        let workspace = engine
3123            .specs()
3124            .into_iter()
3125            .find(|r| r.repository.is_none())
3126            .expect("workspace");
3127        assert!(
3128            !workspace
3129                .specs
3130                .iter()
3131                .any(|s| s.name == "a" || s.name == "b"),
3132            "empty path update must remove every live row of that source"
3133        );
3134    }
3135
3136    #[test]
3137    fn update_empty_volatile_is_error() {
3138        let mut engine = Engine::new();
3139        engine
3140            .load([(
3141                SourceType::Volatile,
3142                "spec a\ndata v: 1\nrule r: v\n".to_string(),
3143            )])
3144            .expect("load");
3145        let err = engine
3146            .update(None, "   \n".to_string(), SourceType::Volatile)
3147            .expect_err("empty volatile update");
3148        assert!(
3149            err.errors
3150                .iter()
3151                .any(|e| e.to_string().contains("at least one spec")),
3152            "got: {:?}",
3153            err.errors
3154        );
3155        assert!(
3156            engine.show(None, "a", None).is_ok(),
3157            "failed empty volatile update must leave the original identity loaded"
3158        );
3159    }
3160
3161    #[test]
3162    fn update_repository_param_mismatch_is_error() {
3163        let mut engine = Engine::new();
3164        let st = path_st("t.lemma");
3165        engine
3166            .load([(
3167                st.clone(),
3168                "repo other\nspec a\ndata v: 1\nrule r: v\n".to_string(),
3169            )])
3170            .expect("load");
3171        let err = engine
3172            .update(
3173                Some("expected"),
3174                "repo other\nspec a\ndata v: 2\nrule r: v\n".to_string(),
3175                st,
3176            )
3177            .expect_err("mismatch");
3178        assert!(
3179            err.errors
3180                .iter()
3181                .any(|e| e.to_string().contains("does not match")),
3182            "got: {:?}",
3183            err.errors
3184        );
3185    }
3186
3187    #[test]
3188    fn update_rollback_restores_pruned_and_replaced() {
3189        let mut engine = Engine::new();
3190        let dep = path_st("dep.lemma");
3191        let consumer = path_st("consumer.lemma");
3192        engine
3193            .load([
3194                (
3195                    dep.clone(),
3196                    "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: 2\nrule r: v\n"
3197                        .to_string(),
3198                ),
3199                (
3200                    consumer,
3201                    "spec consumer\nuses d: dep\nrule out: d.r\n".to_string(),
3202                ),
3203            ])
3204            .expect("load");
3205        let before = engine.specs();
3206        let err = engine
3207            .update(
3208                None,
3209                "spec dep\ndata other: 5\nrule unrelated: other\n".to_string(),
3210                dep,
3211            )
3212            .expect_err("consumer must break");
3213        assert!(!err.errors.is_empty());
3214        let after = engine.specs();
3215        assert_eq!(after.len(), before.len(), "repository count must match");
3216        for (before_repo, after_repo) in before.iter().zip(after.iter()) {
3217            assert_eq!(before_repo.repository, after_repo.repository);
3218            let mut before_specs = before_repo.specs.clone();
3219            let mut after_specs = after_repo.specs.clone();
3220            before_specs
3221                .sort_by(|a, b| (&a.name, &a.effective_from).cmp(&(&b.name, &b.effective_from)));
3222            after_specs
3223                .sort_by(|a, b| (&a.name, &a.effective_from).cmp(&(&b.name, &b.effective_from)));
3224            assert_eq!(
3225                before_specs, after_specs,
3226                "failed update must restore spec versions for {:?}",
3227                before_repo.repository
3228            );
3229        }
3230        let now = DateTimeValue::now();
3231        engine
3232            .evaluate(None, "consumer", Some(&now), IndexMap::new(), None, false)
3233            .expect("consumer still runs after rollback");
3234        assert!(
3235            engine.show(None, "dep", Some(&date(2025, 6, 1))).is_ok(),
3236            "pruned later dep version must be restored"
3237        );
3238    }
3239
3240    fn finance_result(engine: &Engine) -> String {
3241        let now = DateTimeValue::now();
3242        engine
3243            .evaluate(None, "finance", Some(&now), IndexMap::new(), None, false)
3244            .expect("finance evaluates")
3245            .results
3246            .values()
3247            .next()
3248            .expect("one result")
3249            .result()
3250            .expect("result")
3251            .to_string()
3252    }
3253
3254    #[test]
3255    fn update_pasted_duplicate_identity_is_error() {
3256        let mut engine = Engine::new();
3257        let st = path_st("finance.lemma");
3258        engine
3259            .load([(
3260                st.clone(),
3261                "spec finance\ndata v: 1\nrule r: 1\n".to_string(),
3262            )])
3263            .expect("load");
3264        let err = engine
3265            .update(
3266                None,
3267                "spec finance\ndata v: 1\nrule r: 1\n\nspec finance\ndata v: 2\nrule r: 2\n"
3268                    .to_string(),
3269                st,
3270            )
3271            .expect_err("pasted duplicate identity must be rejected");
3272        let joined: String = err
3273            .errors
3274            .iter()
3275            .map(ToString::to_string)
3276            .collect::<Vec<_>>()
3277            .join("\n");
3278        assert!(
3279            joined.contains("Duplicate spec 'finance'"),
3280            "expected duplicate error, got: {joined}"
3281        );
3282        assert_eq!(
3283            finance_result(&engine),
3284            "1",
3285            "original spec must stay active"
3286        );
3287    }
3288
3289    #[test]
3290    fn update_duplicate_identity_both_changed_is_error_not_panic() {
3291        let mut engine = Engine::new();
3292        let st = path_st("finance.lemma");
3293        engine
3294            .load([(
3295                st.clone(),
3296                "spec finance\ndata v: 1\nrule r: 1\n".to_string(),
3297            )])
3298            .expect("load");
3299        let err = engine
3300            .update(
3301                None,
3302                "spec finance\ndata v: 2\nrule r: 2\n\nspec finance\ndata v: 3\nrule r: 3\n"
3303                    .to_string(),
3304                st,
3305            )
3306            .expect_err("duplicate identity must be rejected");
3307        let joined: String = err
3308            .errors
3309            .iter()
3310            .map(ToString::to_string)
3311            .collect::<Vec<_>>()
3312            .join("\n");
3313        assert!(
3314            joined.contains("Duplicate spec 'finance'"),
3315            "expected duplicate error, got: {joined}"
3316        );
3317        assert_eq!(
3318            finance_result(&engine),
3319            "1",
3320            "original spec must stay active"
3321        );
3322    }
3323
3324    #[test]
3325    fn update_reports_every_duplicate_identity() {
3326        let mut engine = Engine::new();
3327        let err = engine
3328            .update(
3329                None,
3330                "spec finance\ndata v: 1\nrule r: 1\n\nspec finance\ndata v: 2\nrule r: 2\n\nspec policies\ndata v: 1\nrule r: 1\n\nspec policies\ndata v: 2\nrule r: 2\n"
3331                    .to_string(),
3332                path_st("policies.lemma"),
3333            )
3334            .expect_err("duplicate identities must be rejected");
3335        let joined: String = err
3336            .errors
3337            .iter()
3338            .map(ToString::to_string)
3339            .collect::<Vec<_>>()
3340            .join("\n");
3341        assert!(
3342            joined.contains("Duplicate spec 'finance'"),
3343            "expected finance duplicate, got: {joined}"
3344        );
3345        assert!(
3346            joined.contains("Duplicate spec 'policies'"),
3347            "expected policies duplicate, got: {joined}"
3348        );
3349    }
3350
3351    #[test]
3352    fn update_identical_bytes_succeeds() {
3353        let mut engine = Engine::new();
3354        let st = path_st("t.lemma");
3355        let code = "spec t\ndata v: 1\nrule r: v\n".to_string();
3356        engine.load([(st.clone(), code.clone())]).expect("load");
3357        let before = engine.show(None, "t", None).expect("show before");
3358        engine.update(None, code, st).expect("identical update");
3359        let after = engine.show(None, "t", None).expect("show after");
3360        assert_eq!(before.meta, after.meta);
3361        assert_eq!(
3362            before.rules.keys().collect::<Vec<_>>(),
3363            after.rules.keys().collect::<Vec<_>>()
3364        );
3365    }
3366
3367    /// Body-only edit of one temporal version must not spuriously reject a consumer
3368    /// whose window overlaps only the non-dirty sibling version.
3369    #[test]
3370    fn update_slice_mode_keeps_consumer_overlapping_sibling_version() {
3371        let mut engine = Engine::new();
3372        let dep = path_st("dep.lemma");
3373        let consumer = path_st("consumer.lemma");
3374        engine
3375            .load([
3376                (
3377                    dep.clone(),
3378                    "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: 2\nrule r: v\n"
3379                        .to_string(),
3380                ),
3381                (
3382                    consumer,
3383                    "spec consumer\nuses d: dep\nrule out: d.r\n\nspec consumer 2025-06-01\nuses d: dep\nrule out: d.r\n"
3384                        .to_string(),
3385                ),
3386            ])
3387            .expect("load");
3388        engine
3389            .update(
3390                None,
3391                "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: 9\nrule r: v\n"
3392                    .to_string(),
3393                dep,
3394            )
3395            .expect("body-only edit of later dep version must keep consumers valid");
3396        let before_breakpoint = date(2025, 1, 15);
3397        let response = engine
3398            .evaluate(
3399                None,
3400                "consumer",
3401                Some(&before_breakpoint),
3402                IndexMap::new(),
3403                None,
3404                false,
3405            )
3406            .expect("origin consumer still runs against non-dirty dep version");
3407        assert_eq!(
3408            response.results.get("out").and_then(|r| r.result()),
3409            Some("1")
3410        );
3411        let after_breakpoint = date(2025, 7, 1);
3412        let response = engine
3413            .evaluate(
3414                None,
3415                "consumer",
3416                Some(&after_breakpoint),
3417                IndexMap::new(),
3418                None,
3419                false,
3420            )
3421            .expect("later consumer runs against dirty dep version");
3422        assert_eq!(
3423            response.results.get("out").and_then(|r| r.result()),
3424            Some("9")
3425        );
3426    }
3427
3428    /// Body-only edit that introduces interface drift vs a sibling version must fail
3429    /// the same way a cold load of the resulting files would.
3430    #[test]
3431    fn update_slice_mode_rejects_interface_drift_vs_sibling_version() {
3432        let mut engine = Engine::new();
3433        let dep = path_st("dep.lemma");
3434        let consumer = path_st("consumer.lemma");
3435        engine
3436            .load([
3437                (
3438                    dep.clone(),
3439                    "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: 2\nrule r: v\n"
3440                        .to_string(),
3441                ),
3442                (
3443                    consumer.clone(),
3444                    "spec consumer\nuses d: dep\nrule out: d.r\n".to_string(),
3445                ),
3446            ])
3447            .expect("load");
3448        let err = engine
3449            .update(
3450                None,
3451                "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: \"x\"\nrule r: v\n"
3452                    .to_string(),
3453                dep.clone(),
3454            )
3455            .expect_err("drift between dep versions must fail incremental update");
3456        assert!(
3457            err.errors.iter().any(|e| {
3458                let msg = e.to_string();
3459                msg.contains("interface") || msg.contains("changed")
3460            }),
3461            "expected interface-drift error, got: {:?}",
3462            err.errors
3463        );
3464
3465        let mut cold = Engine::new();
3466        let cold_err = cold
3467            .load([
3468                (
3469                    dep,
3470                    "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: \"x\"\nrule r: v\n"
3471                        .to_string(),
3472                ),
3473                (
3474                    consumer,
3475                    "spec consumer\nuses d: dep\nrule out: d.r\n".to_string(),
3476                ),
3477            ])
3478            .expect_err("cold load must also reject the drift");
3479        assert!(
3480            cold_err.errors.iter().any(|e| {
3481                let msg = e.to_string();
3482                msg.contains("interface") || msg.contains("changed")
3483            }),
3484            "expected interface-drift error on cold load, got: {:?}",
3485            cold_err.errors
3486        );
3487    }
3488
3489    /// Body-only edit of one temporal version that fails to plan must Error, not panic,
3490    /// when a healthy sibling version remains and a consumer depends on the set.
3491    #[test]
3492    fn update_slice_mode_planning_error_on_dirty_version_is_error_not_panic() {
3493        let mut engine = Engine::new();
3494        let dep = path_st("dep.lemma");
3495        let consumer = path_st("consumer.lemma");
3496        engine
3497            .load([
3498                (
3499                    dep.clone(),
3500                    "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: 2\nrule r: v\n"
3501                        .to_string(),
3502                ),
3503                (
3504                    consumer,
3505                    "spec consumer\nuses d: dep\nrule out: d.r\n".to_string(),
3506                ),
3507            ])
3508            .expect("load");
3509        let err = engine
3510            .update(
3511                None,
3512                "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: 2\nrule r: v + nope\n"
3513                    .to_string(),
3514                dep,
3515            )
3516            .expect_err("planning error on dirty version must be Err, not panic");
3517        assert!(!err.errors.is_empty());
3518        let now = DateTimeValue::now();
3519        engine
3520            .evaluate(None, "consumer", Some(&now), IndexMap::new(), None, false)
3521            .expect("failed update must leave previous engine state intact");
3522    }
3523}