Skip to main content

lemma/
engine.rs

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