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