Skip to main content

lemma/
engine.rs

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