Skip to main content

lemma/
engine.rs

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