Skip to main content

lemma/
engine.rs

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