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 std::collections::HashMap;
12use std::sync::Arc;
13
14/// Load failure: errors plus the source texts we attempted to load.
15#[derive(Debug, Clone)]
16pub struct Errors {
17    pub errors: Vec<Error>,
18    pub sources: HashMap<SourceType, String>,
19}
20
21impl Errors {
22    /// Iterate over the errors.
23    pub fn iter(&self) -> std::slice::Iter<'_, Error> {
24        self.errors.iter()
25    }
26}
27
28/// Resolve an optional effective datetime string for planning or evaluation.
29///
30/// `None` or whitespace-only input resolves to [`DateTimeValue::now`].
31/// Non-empty invalid strings return a request [`Error`].
32pub fn resolve_effective(raw: Option<&str>) -> Result<DateTimeValue, Error> {
33    match raw {
34        Some(s) if !s.trim().is_empty() => s.trim().parse::<DateTimeValue>().map_err(|_| {
35            Error::request(
36                format!(
37                    "Invalid effective value '{}'. Expected: YYYY, YYYY-MM, YYYY-MM-DD, or ISO 8601 datetime",
38                    s.trim()
39                ),
40                None::<String>,
41            )
42        }),
43        _ => Ok(DateTimeValue::now()),
44    }
45}
46
47/// Repository name reserved for the embedded standard library (`repo lemma`, `spec units`).
48/// User [`Engine::load`] must not target this name via [`SourceType::Dependency`].
49pub const EMBEDDED_STDLIB_REPOSITORY: &str = "lemma";
50
51/// Listed spec row from [`Engine::list`].
52#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
53pub struct ListedSpec {
54    pub name: String,
55    #[serde(skip_serializing_if = "Option::is_none", default)]
56    pub effective_from: Option<DateTimeValue>,
57    #[serde(skip_serializing_if = "Option::is_none", default)]
58    pub effective_to: Option<DateTimeValue>,
59}
60
61/// Repository group from [`Engine::list`].
62#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
63pub struct ResolvedRepository {
64    #[serde(skip_serializing_if = "Option::is_none", default)]
65    pub repository: Option<String>,
66    pub specs: Vec<ListedSpec>,
67}
68
69// ─── Spec store with temporal resolution ──────────────────────────────
70
71/// Ordered store of specs keyed by `(repository, name)` and grouped into
72/// [`LemmaSpecSet`]s.
73///
74/// Specs with the same `(repository, name)` identity are ordered by `effective_from`.
75/// A spec version's temporal end is derived from the successor spec's `effective_from`, or
76/// `+∞`. The repository identity is preserved as `Arc<LemmaRepository>` — never via string
77/// prefixes on the spec name. Repository names include the `@` prefix when present
78/// (e.g. `"@org/repo"`). Dependency isolation is enforced at `insert_spec`: all specs
79/// in a repository must share the same `dependency` provenance ID.
80#[derive(Debug)]
81pub struct Context {
82    repositories: IndexMap<Arc<LemmaRepository>, IndexMap<String, LemmaSpecSet>>,
83    workspace: Arc<LemmaRepository>,
84}
85
86impl Default for Context {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92impl Context {
93    /// Empty workspace repository; specs are inserted via [`Self::insert_spec`].
94    pub fn new() -> Self {
95        let workspace = Arc::new(LemmaRepository::new(None));
96        let mut repositories = IndexMap::new();
97        repositories.insert(Arc::clone(&workspace), IndexMap::new());
98        Self {
99            repositories,
100            workspace,
101        }
102    }
103
104    /// Workspace-global grouping for every locally loaded spec. The single
105    /// namespace runtime APIs operate on (entry-point specs live here).
106    /// Stable identity across calls; `name = None`, `dependency = None`.
107    #[must_use]
108    pub fn workspace(&self) -> Arc<LemmaRepository> {
109        Arc::clone(&self.workspace)
110    }
111
112    /// Look up a repository by name without creating a new one.
113    #[must_use]
114    pub fn find_repository(&self, name: &str) -> Option<Arc<LemmaRepository>> {
115        let probe = Arc::new(LemmaRepository::new(Some(name.to_string())));
116        self.repositories
117            .get_key_value(&probe)
118            .map(|(k, _)| Arc::clone(k))
119    }
120
121    /// All spec sets, keyed by `(repository, name)`. Iteration order: repository first
122    /// (insertion order), then spec name ascending.
123    #[must_use]
124    pub fn repositories(&self) -> &IndexMap<Arc<LemmaRepository>, IndexMap<String, LemmaSpecSet>> {
125        &self.repositories
126    }
127
128    /// Flat iterator over every loaded [`LemmaSpec`] across all repositories.
129    ///
130    /// Used by registry resolution to discover missing `@owner/repo` qualifiers.
131    /// Gated with the same `registry` + non-wasm cfg as that caller — without those
132    /// features the method has no production use.
133    #[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
134    pub fn iter(&self) -> impl Iterator<Item = &LemmaSpec> + '_ {
135        self.repositories
136            .values()
137            .flat_map(|m| m.values())
138            .flat_map(|ss| ss.iter_specs())
139    }
140
141    /// Look up a spec set by `(repository, name)`. Returns `None` if no such spec set
142    /// is loaded.
143    #[must_use]
144    pub fn spec_set(&self, repository: &Arc<LemmaRepository>, name: &str) -> Option<&LemmaSpecSet> {
145        let canonical_name = crate::parsing::ast::ascii_lowercase_logical_name(name.to_string());
146        self.repositories
147            .get(repository)
148            .and_then(|m| m.get(&canonical_name))
149    }
150
151    /// Spec sets belonging to a repository. Panics if the repository is not in the map
152    /// (caller must ensure it was returned by this Context).
153    pub(crate) fn spec_sets_for(
154        &self,
155        repository: &Arc<LemmaRepository>,
156    ) -> impl Iterator<Item = &LemmaSpecSet> + '_ {
157        self.repositories
158            .get(repository)
159            .expect("BUG: repository not in context")
160            .values()
161    }
162
163    fn spec_declaration_source(spec: &LemmaSpec) -> crate::parsing::source::Source {
164        let source_type = spec
165            .source_type
166            .as_ref()
167            .expect("BUG: spec must carry source_type after parse");
168        crate::parsing::source::Source::new(
169            source_type.clone(),
170            crate::parsing::ast::Span {
171                start: 0,
172                end: 0,
173                line: spec.start_line,
174                col: 0,
175            },
176        )
177    }
178
179    fn duplicate_spec_path_line(spec: &LemmaSpec) -> (String, usize) {
180        let source_type = spec
181            .source_type
182            .as_ref()
183            .expect("BUG: spec must carry source_type after parse");
184        (source_type.to_string(), spec.start_line)
185    }
186
187    fn duplicate_spec_errors(name: &str, incoming: &LemmaSpec, existing: &LemmaSpec) -> Vec<Error> {
188        let (incoming_path, incoming_line) = Self::duplicate_spec_path_line(incoming);
189        let (existing_path, existing_line) = Self::duplicate_spec_path_line(existing);
190        vec![
191            Error::validation(
192                format!(
193                    "Duplicate spec '{name}' (also declared in '{existing_path}':{existing_line})"
194                ),
195                Some(Self::spec_declaration_source(incoming)),
196                None::<String>,
197            ),
198            Error::validation(
199                format!(
200                    "Duplicate spec '{name}' (also declared in '{incoming_path}':{incoming_line})"
201                ),
202                Some(Self::spec_declaration_source(existing)),
203                None::<String>,
204            ),
205        ]
206    }
207
208    /// Insert a spec under `repository`. Enforces two invariants:
209    /// 1. Dependency isolation: all specs in a repo must share the same `dependency`
210    ///    provenance. A workspace repo cannot be merged with a dependency repo, and
211    ///    two different dependencies cannot contribute to the same repo name.
212    /// 2. No duplicate `(repository, name, effective_from)` triples.
213    pub fn insert_spec(
214        &mut self,
215        repository: Arc<LemmaRepository>,
216        spec: LemmaSpec,
217    ) -> Result<(), Vec<Error>> {
218        if let Some((existing_repo, _)) = self.repositories.get_key_value(&repository) {
219            if existing_repo.dependency != repository.dependency {
220                let repo_display = repository.name.as_deref().unwrap_or("(main)");
221                let existing_owner = match &existing_repo.dependency {
222                    None => "the workspace".to_string(),
223                    Some(id) => format!("dependency '{id}'"),
224                };
225                let new_owner = match &repository.dependency {
226                    None => "the workspace".to_string(),
227                    Some(id) => format!("dependency '{id}'"),
228                };
229                return Err(vec![Error::validation_with_context(
230                    format!(
231                        "Repository '{repo_display}' was introduced by {existing_owner} but {new_owner} also declares it"
232                    ),
233                    None,
234                    Some("Each dependency's repositories must be unique across all loaded sources"),
235                    Some(&spec),
236                    None,
237                )]);
238            }
239        }
240
241        let entry = self
242            .repositories
243            .entry(Arc::clone(&repository))
244            .or_default();
245        if let Some(ss) = entry.get(&spec.name) {
246            if let Some(existing) = ss.get_exact(spec.effective_from()) {
247                return Err(Self::duplicate_spec_errors(&spec.name, &spec, existing));
248            }
249        }
250
251        let name = spec.name.clone();
252        if !entry
253            .entry(name.clone())
254            .or_insert_with(|| LemmaSpecSet::new(repository, name))
255            .insert(spec)
256        {
257            unreachable!("BUG: duplicate effective_from rejected above");
258        }
259        Ok(())
260    }
261
262    pub fn remove_spec(&mut self, repository: &Arc<LemmaRepository>, spec: &LemmaSpec) -> bool {
263        self.remove_spec_by_identity(repository, &spec.name, spec.effective_from())
264    }
265
266    /// Remove by `(repository, name, effective_from)` without needing a live `&LemmaSpec`.
267    pub fn remove_spec_by_identity(
268        &mut self,
269        repository: &Arc<LemmaRepository>,
270        name: &str,
271        effective_from: Option<&DateTimeValue>,
272    ) -> bool {
273        let Some(inner) = self.repositories.get_mut(repository) else {
274            return false;
275        };
276        let Some(ss) = inner.get_mut(name) else {
277            return false;
278        };
279        if !ss.remove(effective_from) {
280            return false;
281        }
282        if ss.is_empty() {
283            inner.shift_remove(name);
284        }
285        true
286    }
287}
288
289// ─── Engine ──────────────────────────────────────────────────────────
290
291/// One mutation in a transactional [`Engine::apply`] batch.
292///
293/// Removes are applied before loads so replace = `[Remove, Load]` for the same
294/// identity cannot hit the duplicate-spec error or a mid-batch missing-dep replan.
295enum Mutation {
296    Remove {
297        repository: Option<String>,
298        spec: String,
299        effective: Option<DateTimeValue>,
300    },
301    Load {
302        source_type: SourceType,
303        code: String,
304    },
305}
306
307/// Engine for evaluating Lemma rules.
308///
309/// Pure Rust implementation that evaluates Lemma specs directly from the AST.
310/// Uses pre-built execution plans that are self-contained and ready for evaluation.
311///
312/// The engine never performs network calls. External `@...` references must be
313/// pre-resolved before loading — either by including dependency sources
314/// in the source map or by calling `resolve_registry_references` separately
315/// (e.g. in a `lemma install` command).
316pub struct Engine {
317    pub(crate) context: Context,
318    pub(crate) plans: PlanStore,
319    limits: ResourceLimits,
320}
321
322impl Default for Engine {
323    fn default() -> Self {
324        Self::new()
325    }
326}
327
328impl Engine {
329    pub fn new() -> Self {
330        Self::with_limits(ResourceLimits::default())
331    }
332
333    pub fn with_limits(limits: ResourceLimits) -> Self {
334        let mut engine = Self {
335            context: Context::new(),
336            plans: PlanStore::new(),
337            limits,
338        };
339        engine
340            .apply(
341                vec![Mutation::Load {
342                    source_type: SourceType::Dependency(EMBEDDED_STDLIB_REPOSITORY.to_string()),
343                    code: crate::stdlib::UNITS_LEMMA.to_string(),
344                }],
345                true,
346            )
347            .expect("BUG: embedded stdlib must load");
348        engine
349    }
350
351    /// Resource limits configured for this engine.
352    pub fn limits(&self) -> &ResourceLimits {
353        &self.limits
354    }
355
356    /// Load Lemma sources in one planning pass. Pairs are `(source_type, source_text)`.
357    ///
358    /// Provenance is derived solely from [`SourceType`]: [`SourceType::Path`] and
359    /// [`SourceType::Volatile`] are workspace-local; [`SourceType::Dependency`] tags
360    /// repositories with that dependency id.
361    pub fn load(
362        &mut self,
363        sources: impl IntoIterator<Item = (SourceType, impl Into<String>)>,
364    ) -> Result<(), Errors> {
365        let mutations = sources
366            .into_iter()
367            .map(|(source_type, code)| Mutation::Load {
368                source_type,
369                code: code.into(),
370            })
371            .collect();
372        self.apply(mutations, false)
373    }
374
375    /// Replace one temporal spec slice with new source in a single planning pass.
376    ///
377    /// Equivalent to remove then load, but atomic: dependents of `spec` stay valid
378    /// across the swap when the new source still satisfies them.
379    pub fn update(
380        &mut self,
381        repository: Option<&str>,
382        spec: &str,
383        effective: Option<&DateTimeValue>,
384        source_type: SourceType,
385        code: String,
386    ) -> Result<(), Errors> {
387        self.apply(
388            vec![
389                Mutation::Remove {
390                    repository: repository.map(str::to_string),
391                    spec: spec.to_string(),
392                    effective: effective.cloned(),
393                },
394                Mutation::Load { source_type, code },
395            ],
396            false,
397        )
398    }
399
400    /// Remove a temporal spec slice and replan remaining specs.
401    pub fn remove(
402        &mut self,
403        repository: Option<&str>,
404        spec: &str,
405        effective: Option<&DateTimeValue>,
406    ) -> Result<(), Error> {
407        self.apply(
408            vec![Mutation::Remove {
409                repository: repository.map(str::to_string),
410                spec: spec.to_string(),
411                effective: effective.cloned(),
412            }],
413            false,
414        )
415        .map_err(|errs| {
416            errs.errors
417                .into_iter()
418                .next()
419                .expect("BUG: apply Errors must contain at least one error")
420        })
421    }
422
423    /// Every loaded repository in insertion order (workspace, embedded stdlib [`EMBEDDED_STDLIB_REPOSITORY`], dependencies).
424    ///
425    /// Returns listed spec rows (metadata only, no AST, no source text).
426    #[must_use]
427    pub fn list(&self) -> Vec<ResolvedRepository> {
428        self.context
429            .repositories()
430            .iter()
431            .map(|(repo, inner)| {
432                let specs = inner
433                    .values()
434                    .flat_map(|spec_set| {
435                        spec_set
436                            .iter_with_ranges()
437                            .map(|(spec, from, to)| ListedSpec {
438                                name: spec.name.clone(),
439                                effective_from: from,
440                                effective_to: to,
441                            })
442                    })
443                    .collect();
444                ResolvedRepository {
445                    repository: repo.name.clone(),
446                    specs,
447                }
448            })
449            .collect()
450    }
451
452    /// Spec interface and resolved temporal window at `effective`.
453    ///
454    /// `Show.data` lists only data used by the spec's rules.
455    /// Lemma source text is [`Self::source`].
456    pub fn show(
457        &self,
458        repository: Option<&str>,
459        spec: &str,
460        effective: Option<&DateTimeValue>,
461    ) -> Result<Show, Error> {
462        let effective_dt = self.effective_or_now(effective);
463        let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
464
465        let plan = match self.plans.get_plan(repository, spec, &instant) {
466            Some(plan) => plan,
467            None => {
468                // Preserve attributed not-found errors (repository vs spec) without
469                // paying for SpecSet lookup on the common success path.
470                let repository_arc = match repository {
471                    Some(q) => self.context.find_repository(q).ok_or_else(|| {
472                        Error::request_not_found(
473                            format!("Repository '{q}' not loaded"),
474                            Some(
475                                "List repositories with `lemma list` after loading your workspace",
476                            ),
477                        )
478                    })?,
479                    None => self.context.workspace(),
480                };
481                let canonical_name =
482                    crate::parsing::ast::ascii_lowercase_logical_name(spec.to_string());
483                let spec_set = self.context.spec_set(&repository_arc, &canonical_name);
484                return match spec_set.and_then(|ss| ss.spec_at(&instant)) {
485                    None => Err(self.spec_not_found_in_repository_error(
486                        &repository_arc,
487                        spec,
488                        &effective_dt,
489                    )),
490                    Some(_) => Err(Error::request_not_found(
491                        format!(
492                            "No execution plan slice for spec '{spec}' at effective {effective_dt}"
493                        ),
494                        Some("Ensure sources loaded and planning succeeded".to_string()),
495                    )),
496                };
497            }
498        };
499
500        let needed_by_rules = &plan.needed_by_rules;
501        let mut data_entries: Vec<(usize, usize, String, ShowData)> = plan
502            .data
503            .iter()
504            .filter(|(_, data)| {
505                data.schema_type().is_some() && !matches!(data, DataDefinition::Reference { .. })
506            })
507            .filter_map(|(path, data)| {
508                let input_key = path.input_key();
509                let used_by = needed_by_rules.get(&input_key).cloned().unwrap_or_default();
510                if used_by.is_empty() {
511                    return None;
512                }
513                let lemma_type = data
514                    .schema_type()
515                    .expect("BUG: filter above ensured lemma_type is Some")
516                    .clone();
517                let display = plan.data_display.get(path);
518                Some((
519                    path.segments.len(),
520                    data.source().span.start,
521                    input_key,
522                    ShowData {
523                        lemma_type,
524                        prefilled: display.and_then(|d| d.prefilled.clone()),
525                        suggestion: display.and_then(|d| d.suggestion.clone()),
526                        needed_by_rules: used_by,
527                    },
528                ))
529            })
530            .collect();
531        data_entries.sort_by_key(|(depth, pos, _, _)| (*depth, *pos));
532
533        let rule_entries: Vec<(String, crate::planning::semantics::LemmaType)> = plan
534            .rules
535            .values()
536            .filter(|rule| rule.path.segments.is_empty())
537            .map(|rule| (rule.name().to_string(), (*rule.rule_type).clone()))
538            .collect();
539
540        Ok(Show {
541            spec: plan.spec_name.clone(),
542            commentary: plan.commentary.clone(),
543            effective_from: plan.effective_from.clone(),
544            effective_to: plan.effective_to.clone(),
545            versions: plan.versions.clone(),
546            start_line: plan.start_line,
547            source_type: plan.source_type.clone(),
548            data: data_entries
549                .into_iter()
550                .map(|(_, _, name, entry)| (name, entry))
551                .collect(),
552            rules: rule_entries.into_iter().collect(),
553            meta: plan.meta.clone(),
554        })
555    }
556
557    /// Formatted canonical Lemma source for a repository or one spec slice.
558    ///
559    /// When `spec` is `None`, returns all specs in the repository sorted by name/effective.
560    /// When `spec` is `Some`, `effective` selects the temporal slice (default: now).
561    pub fn source(
562        &self,
563        repository: Option<&str>,
564        spec: Option<&str>,
565        effective: Option<&DateTimeValue>,
566    ) -> Result<String, Error> {
567        match spec {
568            None => self.format_repository_source(repository),
569            Some(spec_name) => {
570                let effective_dt = self.effective_or_now(effective);
571                let resolved_spec = self.get_spec(spec_name, repository, Some(&effective_dt))?;
572                Ok(crate::formatting::format_spec_refs(&[resolved_spec]))
573            }
574        }
575    }
576
577    /// Evaluate a spec.
578    pub fn run(
579        &self,
580        repository: Option<&str>,
581        spec: &str,
582        effective: Option<&DateTimeValue>,
583        data: HashMap<String, String>,
584        rules: Option<&[String]>,
585        explain: bool,
586    ) -> Result<Response, Error> {
587        let effective = self.effective_or_now(effective);
588        let instant = EffectiveDate::DateTimeValue(effective.clone());
589
590        let plan = self
591            .plans
592            .get_plan(repository, spec, &instant)
593            .ok_or_else(|| {
594                Error::request_not_found(
595                    format!("No execution plan for spec '{spec}' at effective {effective}"),
596                    Some("Ensure sources loaded and planning succeeded".to_string()),
597                )
598            })?;
599
600        let response_rules = plan.validated_response_rule_names(rules)?;
601        let data_values: HashMap<String, RunDataValue> = data
602            .into_iter()
603            .map(|(key, value)| (key, RunDataValue::string(value)))
604            .collect();
605        let run_data = RunData::resolve(plan, data_values, &self.limits)?;
606        let now_semantic = crate::planning::semantics::date_time_to_semantic(&effective);
607        let now_literal = crate::planning::semantics::LiteralValue {
608            value: crate::planning::semantics::ValueKind::Date(now_semantic),
609            lemma_type: crate::planning::semantics::primitive_date_arc().clone(),
610        };
611        let evaluator = Evaluator;
612        let mut response =
613            evaluator.evaluate(plan, &run_data, now_literal, &response_rules, explain);
614
615        response.spec_effective_from = plan.effective_from.clone();
616        response.spec_effective_to = plan.effective_to.clone();
617
618        Ok(response)
619    }
620
621    fn format_repository_source(&self, repository: Option<&str>) -> Result<String, Error> {
622        let repo_arc = self.resolve_repository(repository)?;
623        let mut all_specs: Vec<&LemmaSpec> = self
624            .context
625            .spec_sets_for(&repo_arc)
626            .flat_map(|ss| ss.iter_specs())
627            .collect();
628        all_specs.sort_by(|a, b| {
629            a.name
630                .cmp(&b.name)
631                .then_with(|| a.effective_from.cmp(&b.effective_from))
632        });
633        let body = crate::formatting::format_spec_refs(&all_specs);
634        let mut source_text = String::new();
635        if let Some(name) = repo_arc.name.as_deref() {
636            source_text.push_str("repo ");
637            source_text.push_str(name);
638            source_text.push_str("\n\n");
639        }
640        source_text.push_str(&body);
641        Ok(source_text)
642    }
643
644    fn resolve_repository(&self, repository: Option<&str>) -> Result<Arc<LemmaRepository>, Error> {
645        match repository {
646            None => Ok(self.context.workspace()),
647            Some(qualifier) => {
648                let q = qualifier.trim();
649                if q.is_empty() {
650                    return Err(Error::request(
651                        "Repository qualifier cannot be empty",
652                        None::<String>,
653                    ));
654                }
655                self.context.find_repository(q).ok_or_else(|| {
656                    Error::request_not_found(
657                        format!("Repository '{qualifier}' not loaded"),
658                        Some(format!(
659                            "List repositories with `{}` after loading your workspace",
660                            "lemma list"
661                        )),
662                    )
663                })
664            }
665        }
666    }
667
668    fn spec_not_found_in_repository_error(
669        &self,
670        repository: &LemmaRepository,
671        spec_name: &str,
672        effective: &DateTimeValue,
673    ) -> Error {
674        let repo_label = match &repository.name {
675            Some(n) => n.clone(),
676            None => "(workspace)".to_string(),
677        };
678        Error::request_not_found(
679            format!(
680                "Spec '{spec_name}' not found in repository {repo_label} at effective {effective}",
681            ),
682            Some("Try `lemma list`"),
683        )
684    }
685
686    /// Effective datetime for a request: `explicit` or now.
687    #[must_use]
688    fn effective_or_now(&self, effective: Option<&DateTimeValue>) -> DateTimeValue {
689        effective.cloned().unwrap_or_else(DateTimeValue::now)
690    }
691
692    fn reserved_stdlib_error(source: Option<crate::parsing::source::Source>) -> Error {
693        Error::validation(
694            format!(
695                "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"
696            ),
697            source,
698            Some(
699                "Load registry dependencies with @owner/repo qualifiers, not the reserved 'lemma' stdlib repository"
700                    .to_string(),
701            ),
702        )
703    }
704
705    fn resource_limit_errors(
706        name: &str,
707        limit: impl ToString,
708        actual: impl ToString,
709        hint: &str,
710        sources: IndexMap<SourceType, String>,
711    ) -> Errors {
712        Errors {
713            errors: vec![Error::resource_limit_exceeded(
714                name,
715                limit.to_string(),
716                actual.to_string(),
717                hint,
718                None::<crate::parsing::source::Source>,
719                None,
720                None,
721            )],
722            sources: sources.into_iter().collect(),
723        }
724    }
725
726    /// Apply removes then loads in one planning pass. Rolls back the whole batch on failure.
727    ///
728    /// Load sources are order-preserving so multi-source parse errors are reported in
729    /// submission order rather than scrambled by hash iteration.
730    fn apply(&mut self, mutations: Vec<Mutation>, embedded_stdlib: bool) -> Result<(), Errors> {
731        let mut sources: IndexMap<SourceType, String> = IndexMap::new();
732        let mut errors: Vec<Error> = Vec::new();
733        let mut to_restore: Vec<(Arc<LemmaRepository>, LemmaSpec)> = Vec::new();
734
735        for mutation in mutations {
736            match mutation {
737                Mutation::Remove {
738                    repository,
739                    spec,
740                    effective,
741                } => {
742                    let repo_ref = repository.as_deref();
743                    let effective_dt = self.effective_or_now(effective.as_ref());
744                    match self.get_spec(&spec, repo_ref, Some(&effective_dt)) {
745                        Ok(spec_to_remove) => {
746                            let repository_arc = self
747                                .resolve_repository(repo_ref)
748                                .expect("BUG: get_spec succeeded so repository exists");
749                            to_restore.push((repository_arc, spec_to_remove.clone()));
750                        }
751                        Err(e) => errors.push(e),
752                    }
753                }
754                Mutation::Load { source_type, code } => {
755                    if sources.insert(source_type.clone(), code).is_some() {
756                        return Err(Errors {
757                            errors: vec![Error::request(
758                                format!("Duplicate source key: {source_type}"),
759                                None::<String>,
760                            )],
761                            sources: sources.into_iter().collect(),
762                        });
763                    }
764                }
765            }
766        }
767
768        if !errors.is_empty() {
769            return Err(Errors {
770                errors,
771                sources: sources.into_iter().collect(),
772            });
773        }
774
775        for st in sources.keys() {
776            match st {
777                SourceType::Path(p) if p.as_os_str().to_string_lossy().trim().is_empty() => {
778                    return Err(Errors {
779                        errors: vec![Error::request(
780                            "Source path must be non-empty",
781                            None::<String>,
782                        )],
783                        sources: HashMap::new(),
784                    });
785                }
786                SourceType::Dependency(id) if id.is_empty() => {
787                    return Err(Errors {
788                        errors: vec![Error::request(
789                            "Dependency source identifier must be non-empty",
790                            None::<String>,
791                        )],
792                        sources: HashMap::new(),
793                    });
794                }
795                SourceType::Dependency(id)
796                    if !embedded_stdlib && id == EMBEDDED_STDLIB_REPOSITORY =>
797                {
798                    return Err(Errors {
799                        errors: vec![Self::reserved_stdlib_error(None)],
800                        sources: HashMap::new(),
801                    });
802                }
803                _ => {}
804            }
805        }
806        if !embedded_stdlib && !sources.is_empty() {
807            let limits = &self.limits;
808            if sources.len() > limits.max_sources {
809                return Err(Self::resource_limit_errors(
810                    "max_sources",
811                    limits.max_sources,
812                    sources.len(),
813                    "Reduce the number of paths or sources in one load",
814                    sources,
815                ));
816            }
817            let total_loaded_bytes: usize = sources.values().map(|s| s.len()).sum();
818            if total_loaded_bytes > limits.max_loaded_bytes {
819                return Err(Self::resource_limit_errors(
820                    "max_loaded_bytes",
821                    limits.max_loaded_bytes,
822                    total_loaded_bytes,
823                    "Load fewer or smaller sources",
824                    sources,
825                ));
826            }
827            if let Some(code) = sources
828                .values()
829                .find(|code| code.len() > limits.max_source_size_bytes)
830            {
831                return Err(Self::resource_limit_errors(
832                    "max_source_size_bytes",
833                    limits.max_source_size_bytes,
834                    code.len(),
835                    "Use a smaller source text or increase limit",
836                    sources,
837                ));
838            }
839        }
840
841        let parse_limits = if embedded_stdlib {
842            &ResourceLimits::default()
843        } else {
844            &self.limits
845        };
846        let mut staged: Vec<(SourceType, Arc<LemmaRepository>, LemmaSpec)> = Vec::new();
847
848        for (source_id, code) in &sources {
849            let dependency = match source_id {
850                SourceType::Dependency(id) => Some(id.as_str()),
851                _ => None,
852            };
853            match parse(code, source_id.clone(), parse_limits) {
854                Ok(result) => {
855                    if result.repositories.is_empty() {
856                        continue;
857                    }
858
859                    for (parsed_repo, specs) in result.repositories {
860                        let repository_arc = if let Some(dep_id) = dependency {
861                            let repo_name = parsed_repo
862                                .name
863                                .clone()
864                                // Use the dependency id as the repository name for the dependency's workspace specs
865                                .or_else(|| Some(dep_id.to_string()));
866                            Arc::new(
867                                LemmaRepository::new(repo_name)
868                                    .with_dependency(dep_id)
869                                    .with_start_line(parsed_repo.start_line),
870                            )
871                        } else {
872                            parsed_repo
873                        };
874                        if !embedded_stdlib
875                            && repository_arc.name.as_deref() == Some(EMBEDDED_STDLIB_REPOSITORY)
876                        {
877                            let source = crate::parsing::source::Source::new(
878                                source_id.clone(),
879                                crate::parsing::ast::Span {
880                                    start: 0,
881                                    end: 0,
882                                    line: repository_arc.start_line,
883                                    col: 0,
884                                },
885                            );
886                            errors.push(Self::reserved_stdlib_error(Some(source)));
887                            continue;
888                        }
889                        for spec in specs {
890                            staged.push((source_id.clone(), Arc::clone(&repository_arc), spec));
891                        }
892                    }
893                }
894                Err(e) => errors.push(e),
895            }
896        }
897
898        if !errors.is_empty() {
899            return Err(Errors {
900                errors,
901                sources: sources.into_iter().collect(),
902            });
903        }
904
905        for (repo, spec) in &to_restore {
906            self.context.remove_spec(repo, spec);
907        }
908
909        let mut inserted: Vec<(Arc<LemmaRepository>, String, EffectiveDate)> = Vec::new();
910        for (_, repository_arc, spec) in staged {
911            let name = spec.name.clone();
912            let effective_from = spec.effective_from.clone();
913            match self.context.insert_spec(Arc::clone(&repository_arc), spec) {
914                Ok(()) => inserted.push((repository_arc, name, effective_from)),
915                Err(es) => {
916                    errors.extend(es);
917                    self.rollback_apply(&inserted, &to_restore);
918                    return Err(Errors {
919                        errors,
920                        sources: sources.into_iter().collect(),
921                    });
922                }
923            }
924        }
925
926        let result = crate::planning::plan(&self.context, &self.limits);
927        if !result.errors.is_empty() {
928            self.rollback_apply(&inserted, &to_restore);
929            return Err(Errors {
930                errors: result.errors,
931                sources: sources.into_iter().collect(),
932            });
933        }
934
935        self.plans.replace(result.plans);
936        Ok(())
937    }
938
939    fn rollback_apply(
940        &mut self,
941        inserted: &[(Arc<LemmaRepository>, String, EffectiveDate)],
942        removed: &[(Arc<LemmaRepository>, LemmaSpec)],
943    ) {
944        for (repo, inserted_name, inserted_effective) in inserted.iter().rev() {
945            self.context
946                .remove_spec_by_identity(repo, inserted_name, inserted_effective.as_ref());
947        }
948        for (repo, spec) in removed.iter().rev() {
949            self.context
950                .insert_spec(Arc::clone(repo), spec.clone())
951                .expect("BUG: restore removed spec for rollback");
952        }
953    }
954
955    /// Active [`LemmaSpec`] slice for `name` at the resolved effective instant in `repository`.
956    ///
957    /// When `repository` is `None`, uses the workspace. When `effective` is `None`, uses now.
958    pub(crate) fn get_spec(
959        &self,
960        name: &str,
961        repository: Option<&str>,
962        effective: Option<&DateTimeValue>,
963    ) -> Result<&LemmaSpec, Error> {
964        let effective_dt = self.effective_or_now(effective);
965        let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
966        let repository_arc = match repository {
967            Some(q) => self.context.find_repository(q).ok_or_else(|| {
968                Error::request_not_found(
969                    format!("Repository '{q}' not loaded"),
970                    Some("List repositories with `lemma list` after loading your workspace"),
971                )
972            })?,
973            None => self.context.workspace(),
974        };
975        let spec_set = self
976            .context
977            .spec_set(&repository_arc, name)
978            .ok_or_else(|| {
979                self.spec_not_found_in_repository_error(&repository_arc, name, &effective_dt)
980            })?;
981        spec_set.spec_at(&instant).ok_or_else(|| {
982            self.spec_not_found_in_repository_error(&repository_arc, name, &effective_dt)
983        })
984    }
985}
986#[cfg(test)]
987mod tests {
988    use super::*;
989
990    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
991        DateTimeValue {
992            year,
993            month,
994            day,
995            hour: 0,
996            minute: 0,
997            second: 0,
998            microsecond: 0,
999            timezone: None,
1000            granularity: crate::literals::DateGranularity::Full,
1001        }
1002    }
1003
1004    fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
1005        let mut spec = LemmaSpec::new(name.to_string());
1006        spec.effective_from = crate::parsing::ast::EffectiveDate::from_option(effective_from);
1007        spec
1008    }
1009
1010    /// Spec-set temporal order is (name, effective_from) ascending.
1011    /// Same-name specs appear in temporal order; definition order in the source is irrelevant.
1012    #[test]
1013    fn list_order_is_name_then_effective_from_ascending() {
1014        let mut ctx = Context::new();
1015        let repository = ctx.workspace();
1016        let s_2026 = make_spec_with_range("mortgage", Some(date(2026, 1, 1)));
1017        let s_2025 = make_spec_with_range("mortgage", Some(date(2025, 1, 1)));
1018        ctx.insert_spec(Arc::clone(&repository), s_2026).unwrap();
1019        ctx.insert_spec(Arc::clone(&repository), s_2025).unwrap();
1020        let listed: Vec<_> = ctx
1021            .spec_set(&repository, "mortgage")
1022            .expect("mortgage set")
1023            .iter_specs()
1024            .collect();
1025        assert_eq!(listed.len(), 2);
1026        assert_eq!(listed[0].effective_from(), Some(&date(2025, 1, 1)));
1027        assert_eq!(listed[1].effective_from(), Some(&date(2026, 1, 1)));
1028    }
1029
1030    #[test]
1031    fn get_spec_resolves_temporal_version_by_effective() {
1032        let mut engine = Engine::new();
1033        engine
1034            .load([(
1035                SourceType::Path(Arc::new(std::path::PathBuf::from("a.lemma"))),
1036                r#"
1037        spec pricing 2025-01-01
1038        data x: 1
1039        rule r: x
1040    "#
1041                .to_string(),
1042            )])
1043            .unwrap();
1044        engine
1045            .load([(
1046                SourceType::Path(Arc::new(std::path::PathBuf::from("b.lemma"))),
1047                r#"
1048        spec pricing 2025-06-01
1049        data x: 2
1050        rule r: x
1051    "#
1052                .to_string(),
1053            )])
1054            .unwrap();
1055
1056        let jan = DateTimeValue {
1057            year: 2025,
1058            month: 1,
1059            day: 15,
1060            hour: 0,
1061            minute: 0,
1062            second: 0,
1063            microsecond: 0,
1064            timezone: None,
1065            granularity: crate::literals::DateGranularity::Full,
1066        };
1067        let jul = DateTimeValue {
1068            year: 2025,
1069            month: 7,
1070            day: 1,
1071            hour: 0,
1072            minute: 0,
1073            second: 0,
1074            microsecond: 0,
1075            timezone: None,
1076            granularity: crate::literals::DateGranularity::Full,
1077        };
1078
1079        let v1 = DateTimeValue {
1080            year: 2025,
1081            month: 1,
1082            day: 1,
1083            hour: 0,
1084            minute: 0,
1085            second: 0,
1086            microsecond: 0,
1087            timezone: None,
1088            granularity: crate::literals::DateGranularity::Full,
1089        };
1090        let v2 = DateTimeValue {
1091            year: 2025,
1092            month: 6,
1093            day: 1,
1094            hour: 0,
1095            minute: 0,
1096            second: 0,
1097            microsecond: 0,
1098            timezone: None,
1099            granularity: crate::literals::DateGranularity::Full,
1100        };
1101
1102        let s_jan = engine
1103            .get_spec("pricing", None, Some(&jan))
1104            .expect("jan spec");
1105        let s_jul = engine
1106            .get_spec("pricing", None, Some(&jul))
1107            .expect("jul spec");
1108        assert_eq!(s_jan.effective_from(), Some(&v1));
1109        assert_eq!(s_jul.effective_from(), Some(&v2));
1110    }
1111
1112    /// Every temporal row for a workspace spec name exposes half-open
1113    /// `[effective_from, effective_to)` via [`LemmaSpecSet::iter_with_ranges`]. The latest row's
1114    /// `effective_to` is `None` (no successor); earlier rows' `effective_to`
1115    /// equals the next row's `effective_from`.
1116    #[test]
1117    fn list_returns_half_open_ranges_per_temporal_version() {
1118        let mut engine = Engine::new();
1119        engine
1120            .load([(
1121                SourceType::Path(Arc::new(std::path::PathBuf::from("a.lemma"))),
1122                r#"
1123        spec pricing 2025-01-01
1124        data x: 1
1125        rule r: x
1126    "#
1127                .to_string(),
1128            )])
1129            .unwrap();
1130        engine
1131            .load([(
1132                SourceType::Path(Arc::new(std::path::PathBuf::from("b.lemma"))),
1133                r#"
1134        spec pricing 2025-06-01
1135        data x: 2
1136        rule r: x
1137    "#
1138                .to_string(),
1139            )])
1140            .unwrap();
1141
1142        let january = date(2025, 1, 1);
1143        let june = date(2025, 6, 1);
1144
1145        let workspace = engine
1146            .list()
1147            .into_iter()
1148            .find(|r| r.repository.is_none())
1149            .expect("workspace");
1150        let mut pricing_rows: Vec<_> = workspace
1151            .specs
1152            .iter()
1153            .filter(|ls| ls.name == "pricing")
1154            .map(|ls| (ls.effective_from.clone(), ls.effective_to.clone()))
1155            .collect();
1156        pricing_rows.sort_by(|a, b| match (&a.0, &b.0) {
1157            (Some(x), Some(y)) => x.cmp(y),
1158            (None, Some(_)) => std::cmp::Ordering::Less,
1159            (Some(_), None) => std::cmp::Ordering::Greater,
1160            (None, None) => std::cmp::Ordering::Equal,
1161        });
1162        assert_eq!(pricing_rows.len(), 2);
1163        assert_eq!(
1164            pricing_rows[0],
1165            (Some(january.clone()), Some(june.clone())),
1166            "earlier row ends at the next row's effective_from"
1167        );
1168        assert_eq!(
1169            pricing_rows[1],
1170            (Some(june.clone()), None),
1171            "latest row has no successor; effective_to is None"
1172        );
1173
1174        assert!(
1175            !engine
1176                .list()
1177                .into_iter()
1178                .find(|r| r.repository.is_none())
1179                .expect("workspace")
1180                .specs
1181                .iter()
1182                .any(|ls| ls.name == "unknown"),
1183            "no rows for unknown spec"
1184        );
1185    }
1186
1187    /// `Engine::list()` provides spec sets grouped by repository.
1188    /// Each listed row exposes half-open `[effective_from, effective_to)` ranges.
1189    #[test]
1190    fn get_workspace_specs_with_half_open_ranges() {
1191        let mut engine = Engine::new();
1192        engine
1193            .load([(
1194                SourceType::Path(Arc::new(std::path::PathBuf::from("pricing_v1.lemma"))),
1195                r#"
1196        spec pricing 2025-01-01
1197        data x: 1
1198        rule r: x
1199    "#
1200                .to_string(),
1201            )])
1202            .unwrap();
1203        engine
1204            .load([(
1205                SourceType::Path(Arc::new(std::path::PathBuf::from("pricing_v2.lemma"))),
1206                r#"
1207        spec pricing 2026-01-01
1208        data x: 2
1209        rule r: x
1210    "#
1211                .to_string(),
1212            )])
1213            .unwrap();
1214        engine
1215            .load([(
1216                SourceType::Path(Arc::new(std::path::PathBuf::from("taxes.lemma"))),
1217                r#"
1218        spec taxes
1219        data rate: 0.21
1220        rule amount: rate
1221    "#
1222                .to_string(),
1223            )])
1224            .unwrap();
1225
1226        let workspace = engine
1227            .list()
1228            .into_iter()
1229            .find(|r| r.repository.is_none())
1230            .expect("workspace");
1231        let unique_names: std::collections::BTreeSet<&str> =
1232            workspace.specs.iter().map(|ls| ls.name.as_str()).collect();
1233        assert_eq!(
1234            unique_names.len(),
1235            2,
1236            "two unique spec names: pricing and taxes"
1237        );
1238
1239        let pricing_rows: Vec<_> = workspace
1240            .specs
1241            .iter()
1242            .filter(|ls| ls.name == "pricing")
1243            .collect();
1244        assert_eq!(pricing_rows.len(), 2);
1245        assert_eq!(pricing_rows[0].effective_from, Some(date(2025, 1, 1)));
1246        assert_eq!(
1247            pricing_rows[0].effective_to,
1248            Some(date(2026, 1, 1)),
1249            "earlier pricing row ends at the next pricing row's effective_from"
1250        );
1251        assert_eq!(pricing_rows[1].effective_from, Some(date(2026, 1, 1)));
1252        assert_eq!(
1253            pricing_rows[1].effective_to, None,
1254            "latest pricing row has no successor; effective_to is None"
1255        );
1256
1257        let tax_rows: Vec<_> = workspace
1258            .specs
1259            .iter()
1260            .filter(|ls| ls.name == "taxes")
1261            .collect();
1262        assert_eq!(tax_rows.len(), 1);
1263        assert_eq!(
1264            tax_rows[0].effective_from, None,
1265            "unversioned spec has no declared effective_from"
1266        );
1267        assert_eq!(
1268            tax_rows[0].effective_to, None,
1269            "unversioned spec has no successor; effective_to is None"
1270        );
1271    }
1272
1273    #[test]
1274    fn test_evaluate_spec_all_rules() {
1275        let mut engine = Engine::new();
1276        engine
1277            .load([(
1278                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1279                r#"
1280        spec test
1281        data x: 10
1282        data y: 5
1283        rule sum: x + y
1284        rule product: x * y
1285    "#
1286                .to_string(),
1287            )])
1288            .unwrap();
1289
1290        let now = DateTimeValue::now();
1291        let response = engine
1292            .run(None, "test", Some(&now), HashMap::new(), None, false)
1293            .unwrap();
1294        assert_eq!(response.results.len(), 2);
1295
1296        let sum_result = response
1297            .results
1298            .values()
1299            .find(|r| r.rule.name == "sum")
1300            .unwrap();
1301        assert_eq!(sum_result.display().expect("display").to_string(), "15");
1302
1303        let product_result = response
1304            .results
1305            .values()
1306            .find(|r| r.rule.name == "product")
1307            .unwrap();
1308        assert_eq!(product_result.display().expect("display").to_string(), "50");
1309    }
1310
1311    #[test]
1312    fn test_evaluate_empty_data() {
1313        let mut engine = Engine::new();
1314        engine
1315            .load([(
1316                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1317                r#"
1318        spec test
1319        data price: 100
1320        rule total: price * 2
1321    "#
1322                .to_string(),
1323            )])
1324            .unwrap();
1325
1326        let now = DateTimeValue::now();
1327        let response = engine
1328            .run(None, "test", Some(&now), HashMap::new(), None, false)
1329            .unwrap();
1330        assert_eq!(response.results.len(), 1);
1331        assert_eq!(
1332            response
1333                .results
1334                .values()
1335                .next()
1336                .unwrap()
1337                .display()
1338                .expect("display"),
1339            "200"
1340        );
1341    }
1342
1343    #[test]
1344    fn test_evaluate_boolean_rule() {
1345        let mut engine = Engine::new();
1346        engine
1347            .load([(
1348                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1349                r#"
1350        spec test
1351        data age: 25
1352        rule is_adult: age >= 18
1353    "#
1354                .to_string(),
1355            )])
1356            .unwrap();
1357
1358        let now = DateTimeValue::now();
1359        let response = engine
1360            .run(None, "test", Some(&now), HashMap::new(), None, false)
1361            .unwrap();
1362        assert_eq!(
1363            response
1364                .results
1365                .values()
1366                .next()
1367                .unwrap()
1368                .value
1369                .as_ref()
1370                .unwrap()
1371                .boolean,
1372            Some(true)
1373        );
1374    }
1375
1376    #[test]
1377    fn test_evaluate_with_unless_clause() {
1378        let mut engine = Engine::new();
1379        engine
1380            .load([(
1381                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1382                r#"
1383        spec test
1384        data quantity: 15
1385        rule discount: 0
1386          unless quantity >= 10 then 10
1387    "#
1388                .to_string(),
1389            )])
1390            .unwrap();
1391
1392        let now = DateTimeValue::now();
1393        let response = engine
1394            .run(None, "test", Some(&now), HashMap::new(), None, false)
1395            .unwrap();
1396        assert_eq!(
1397            response
1398                .results
1399                .values()
1400                .next()
1401                .unwrap()
1402                .display()
1403                .expect("display"),
1404            "10"
1405        );
1406    }
1407
1408    #[test]
1409    fn test_spec_not_found() {
1410        let engine = Engine::new();
1411        let now = DateTimeValue::now();
1412        let result = engine.run(None, "nonexistent", Some(&now), HashMap::new(), None, false);
1413        assert!(result.is_err());
1414        let msg = result.unwrap_err().to_string();
1415        assert!(
1416            msg.contains("No execution plan") && msg.contains("nonexistent"),
1417            "missing spec must report no plan, got: {msg}"
1418        );
1419    }
1420
1421    #[test]
1422    fn test_multiple_specs() {
1423        let mut engine = Engine::new();
1424        engine
1425            .load([(
1426                SourceType::Path(Arc::new(std::path::PathBuf::from("spec 1.lemma"))),
1427                r#"
1428        spec spec1
1429        data x: 10
1430        rule result: x * 2
1431    "#
1432                .to_string(),
1433            )])
1434            .unwrap();
1435
1436        engine
1437            .load([(
1438                SourceType::Path(Arc::new(std::path::PathBuf::from("spec 2.lemma"))),
1439                r#"
1440        spec spec2
1441        data y: 5
1442        rule result: y * 3
1443    "#
1444                .to_string(),
1445            )])
1446            .unwrap();
1447
1448        let now = DateTimeValue::now();
1449        let response1 = engine
1450            .run(None, "spec1", Some(&now), HashMap::new(), None, false)
1451            .unwrap();
1452        assert_eq!(
1453            response1.results[0].display().expect("display").to_string(),
1454            "20"
1455        );
1456        let response2 = engine
1457            .run(None, "spec2", Some(&now), HashMap::new(), None, false)
1458            .unwrap();
1459        assert_eq!(
1460            response2.results[0].display().expect("display").to_string(),
1461            "15"
1462        );
1463    }
1464
1465    #[test]
1466    fn test_runtime_error_mapping() {
1467        let mut engine = Engine::new();
1468        engine
1469            .load([(
1470                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1471                r#"
1472        spec test
1473        data numerator: 10
1474        data denominator: 0
1475        rule division: numerator / denominator
1476    "#
1477                .to_string(),
1478            )])
1479            .unwrap();
1480
1481        let now = DateTimeValue::now();
1482        let result = engine.run(None, "test", Some(&now), HashMap::new(), None, false);
1483        // Division by zero returns a Veto (not an error)
1484        assert!(result.is_ok(), "Evaluation should succeed");
1485        let response = result.unwrap();
1486        let division_result = response
1487            .results
1488            .values()
1489            .find(|r| r.rule.name == "division");
1490        assert!(
1491            division_result.is_some(),
1492            "Should have division rule result"
1493        );
1494        let division = division_result.unwrap();
1495        assert!(division.vetoed);
1496        assert!(
1497            division
1498                .veto_reason
1499                .as_deref()
1500                .unwrap()
1501                .contains("Division by zero"),
1502            "Veto message should mention division by zero: {:?}",
1503            division.veto_reason
1504        );
1505    }
1506
1507    #[test]
1508    fn test_rules_sorted_by_source_order() {
1509        let mut engine = Engine::new();
1510        engine
1511            .load([(
1512                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1513                r#"
1514        spec test
1515        data a: 1
1516        data b: 2
1517        rule z: a + b
1518        rule y: a * b
1519        rule x: a - b
1520    "#
1521                .to_string(),
1522            )])
1523            .unwrap();
1524
1525        let now = DateTimeValue::now();
1526        let response = engine
1527            .run(None, "test", Some(&now), HashMap::new(), None, false)
1528            .unwrap();
1529        assert_eq!(response.results.len(), 3);
1530
1531        // Verify source positions increase (z < y < x)
1532        let z_pos = response
1533            .results
1534            .values()
1535            .find(|r| r.rule.name == "z")
1536            .unwrap()
1537            .rule
1538            .source_location
1539            .span
1540            .start;
1541        let y_pos = response
1542            .results
1543            .values()
1544            .find(|r| r.rule.name == "y")
1545            .unwrap()
1546            .rule
1547            .source_location
1548            .span
1549            .start;
1550        let x_pos = response
1551            .results
1552            .values()
1553            .find(|r| r.rule.name == "x")
1554            .unwrap()
1555            .rule
1556            .source_location
1557            .span
1558            .start;
1559
1560        assert!(z_pos < y_pos);
1561        assert!(y_pos < x_pos);
1562    }
1563
1564    #[test]
1565    fn test_rule_filtering_evaluates_dependencies() {
1566        let mut engine = Engine::new();
1567        engine
1568            .load([(
1569                SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1570                r#"
1571        spec test
1572        data base: 100
1573        rule subtotal: base * 2
1574        rule tax: subtotal * 10%
1575        rule total: subtotal + tax
1576    "#
1577                .to_string(),
1578            )])
1579            .unwrap();
1580
1581        let now = DateTimeValue::now();
1582        let response = engine
1583            .run(
1584                None,
1585                "test",
1586                Some(&now),
1587                HashMap::new(),
1588                Some(&["total".to_string()]),
1589                false,
1590            )
1591            .unwrap();
1592
1593        assert_eq!(response.results.len(), 1);
1594        assert_eq!(response.results.keys().next().unwrap(), "total");
1595
1596        // But the value should be correct (dependencies were computed)
1597        let total = response.results.values().next().unwrap();
1598        assert_eq!(total.display().expect("display").to_string(), "220");
1599    }
1600
1601    // -------------------------------------------------------------------
1602    // Pre-resolved dependency tests (Engine never fetches from registry)
1603    // -------------------------------------------------------------------
1604
1605    use crate::parsing::ast::DateTimeValue;
1606
1607    #[test]
1608    fn pre_resolved_deps_in_file_map_evaluates_external_spec() {
1609        let mut engine = Engine::new();
1610
1611        engine
1612            .load([(
1613                SourceType::Dependency("@org/project".to_string()),
1614                "repo @org/project\nspec helper\ndata quantity: 42".to_string(),
1615            )])
1616            .expect("should load dependency files");
1617
1618        engine
1619            .load([(
1620                SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1621                r#"spec main_spec
1622uses external: @org/project helper
1623rule value: external.quantity"#
1624                    .to_string(),
1625            )])
1626            .expect("should succeed with pre-resolved deps");
1627
1628        let now = DateTimeValue::now();
1629        let response = engine
1630            .run(None, "main_spec", Some(&now), HashMap::new(), None, false)
1631            .expect("evaluate should succeed");
1632
1633        let value_result = response
1634            .results
1635            .get("value")
1636            .expect("rule 'value' should exist");
1637        assert_eq!(value_result.display().expect("display").to_string(), "42");
1638    }
1639
1640    #[test]
1641    fn show_with_repo_resolves_registry_spec() {
1642        let mut engine = Engine::new();
1643        engine
1644            .load([(
1645                SourceType::Dependency("@org/project".to_string()),
1646                "repo @org/project\nspec helper\ndata quantity: 42\nrule expose: quantity"
1647                    .to_string(),
1648            )])
1649            .expect("registry bundle loads");
1650
1651        engine
1652            .load([(
1653                SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1654                r#"spec main_spec
1655data x: 1"#
1656                    .to_string(),
1657            )])
1658            .expect("main loads");
1659
1660        let now = DateTimeValue::now();
1661        let view = engine
1662            .show(Some("@org/project"), "helper", Some(&now))
1663            .expect("show for registry spec");
1664        assert!(view.data.contains_key("quantity"));
1665    }
1666
1667    #[test]
1668    fn load_no_external_refs_works() {
1669        let mut engine = Engine::new();
1670
1671        engine
1672            .load([(
1673                SourceType::Path(Arc::new(std::path::PathBuf::from("local.lemma"))),
1674                r#"spec local_only
1675data price: 100
1676rule doubled: price * 2"#
1677                    .to_string(),
1678            )])
1679            .expect("should succeed when there are no @... references");
1680
1681        let now = DateTimeValue::now();
1682        let response = engine
1683            .run(None, "local_only", Some(&now), HashMap::new(), None, false)
1684            .expect("evaluate should succeed");
1685
1686        let doubled = response.results.get("doubled").expect("doubled rule");
1687        assert_eq!(doubled.display().expect("display").to_string(), "200");
1688    }
1689
1690    #[test]
1691    fn unresolved_external_ref_without_deps_fails() {
1692        let mut engine = Engine::new();
1693
1694        let result = engine.load([(
1695            SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1696            r#"spec main_spec
1697uses external: @org/project missing
1698rule value: external.quantity"#
1699                .to_string(),
1700        )]);
1701
1702        let errs = result.expect_err("Should fail when registry dep is not loaded");
1703        assert!(
1704            errs.iter()
1705                .any(|e| e.kind() == crate::ErrorKind::MissingRepository),
1706            "expected MissingRepository, got: {:?}",
1707            errs.iter().map(|e| e.kind()).collect::<Vec<_>>()
1708        );
1709    }
1710
1711    #[test]
1712    fn pre_resolved_deps_with_spec_and_type_refs() {
1713        let mut engine = Engine::new();
1714
1715        engine
1716            .load([(
1717                SourceType::Dependency("@org/example".to_string()),
1718                "repo @org/example\nspec helper\ndata value: 42".to_string(),
1719            )])
1720            .expect("should load helper file");
1721
1722        engine
1723        .load([(
1724                SourceType::Dependency("@iso/countries".to_string()),
1725                "repo @iso/countries\nspec alpha2\ndata code: text\n -> option \"NL\"\n -> option \"BE\"".to_string(),
1726            )])
1727            .expect("should load alpha2 file");
1728
1729        engine
1730            .load([(
1731                SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1732                r#"spec registry_demo
1733uses @iso/countries alpha2
1734data country: alpha2.code
1735data unit_count: 5
1736uses @org/example helper
1737rule helper_value: helper.value
1738rule line_total: unit_count * 2
1739rule formatted: helper_value + 0"#
1740                    .to_string(),
1741            )])
1742            .expect("should succeed with pre-resolved spec and type deps");
1743
1744        let now = DateTimeValue::now();
1745        let response = engine
1746            .run(
1747                None,
1748                "registry_demo",
1749                Some(&now),
1750                HashMap::new(),
1751                None,
1752                false,
1753            )
1754            .expect("evaluate should succeed");
1755
1756        assert_eq!(
1757            response
1758                .results
1759                .get("helper_value")
1760                .expect("helper_value")
1761                .display()
1762                .expect("display"),
1763            "42"
1764        );
1765        let line = response
1766            .results
1767            .get("line_total")
1768            .expect("line_total")
1769            .display()
1770            .expect("display");
1771        assert_eq!(line, "10");
1772        assert_eq!(
1773            response
1774                .results
1775                .get("formatted")
1776                .expect("formatted")
1777                .display()
1778                .expect("display"),
1779            "42"
1780        );
1781    }
1782
1783    #[test]
1784    fn load_empty_labeled_source_is_error() {
1785        let mut engine = Engine::new();
1786        let err = engine
1787            .load([(
1788                SourceType::Path(Arc::new(std::path::PathBuf::from("  "))),
1789                "spec x\ndata a: 1".to_string(),
1790            )])
1791            .unwrap_err();
1792        assert!(err.errors.iter().any(|e| e.message().contains("non-empty")));
1793    }
1794
1795    #[test]
1796    fn add_dependency_files_accepts_registry_bundle_specs() {
1797        let mut engine = Engine::new();
1798        engine
1799            .load([(
1800                SourceType::Dependency("@org/my".to_string()),
1801                "repo @org/my\nspec helper\ndata x: 1".to_string(),
1802            )])
1803            .expect("dependency bundle specs should be accepted");
1804    }
1805
1806    #[test]
1807    fn user_load_rejects_reserved_embedded_stdlib_repository() {
1808        let mut engine = Engine::new();
1809        let batch = engine.load([(
1810            SourceType::Dependency(EMBEDDED_STDLIB_REPOSITORY.to_string()),
1811            "spec finance\ndata money: ratio -> decimals 2".to_string(),
1812        )]);
1813        assert!(
1814            batch.is_err(),
1815            "load must not write reserved lemma stdlib repo"
1816        );
1817        let msg = batch
1818            .unwrap_err()
1819            .errors
1820            .iter()
1821            .map(ToString::to_string)
1822            .collect::<Vec<_>>()
1823            .join("\n");
1824        assert!(
1825            msg.contains(EMBEDDED_STDLIB_REPOSITORY) && msg.contains("reserved"),
1826            "expected reserved-repo error, got: {msg}"
1827        );
1828
1829        let workspace = engine.load([(
1830            SourceType::Volatile,
1831            "repo lemma\nspec x\ndata a: 1".to_string(),
1832        )]);
1833        assert!(workspace.is_err(), "workspace repo lemma must be rejected");
1834        let msg = workspace
1835            .unwrap_err()
1836            .errors
1837            .iter()
1838            .map(ToString::to_string)
1839            .collect::<Vec<_>>()
1840            .join("\n");
1841        assert!(
1842            msg.contains(EMBEDDED_STDLIB_REPOSITORY) && msg.contains("reserved"),
1843            "expected reserved-repo error, got: {msg}"
1844        );
1845    }
1846
1847    #[test]
1848    fn load_returns_all_errors_not_just_first() {
1849        let mut engine = Engine::new();
1850
1851        let result = engine.load([(
1852            SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1853            r#"spec demo
1854uses type_src: nonexistent_type_source
1855  -> with amount: 10
1856uses helper: nonexistent_spec
1857data price: 10
1858rule total: helper.value + price"#
1859                .to_string(),
1860        )]);
1861
1862        assert!(result.is_err(), "Should fail with multiple errors");
1863        let load_err = result.unwrap_err();
1864        assert!(
1865            load_err.errors.len() >= 2,
1866            "expected at least 2 errors (type + spec ref), got {}",
1867            load_err.errors.len()
1868        );
1869        let error_message = load_err
1870            .errors
1871            .iter()
1872            .map(ToString::to_string)
1873            .collect::<Vec<_>>()
1874            .join("; ");
1875
1876        assert!(
1877            error_message.contains("nonexistent_type_source"),
1878            "Should mention data import source spec. Got:\n{}",
1879            error_message
1880        );
1881        assert!(
1882            error_message.contains("nonexistent_spec"),
1883            "Should mention spec reference error about 'nonexistent_spec'. Got:\n{}",
1884            error_message
1885        );
1886    }
1887
1888    // ── Suggestion value type validation ────────────────────────────────
1889    // Planning must reject suggestion values that don't match the type.
1890    // These tests cover both primitives and named types (which the parser
1891    // can't validate because it doesn't resolve type names).
1892
1893    #[test]
1894    fn planning_rejects_invalid_number_default() {
1895        let mut engine = Engine::new();
1896        let result = engine.load([(
1897            SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
1898            "spec t\ndata x: number -> suggest \"10 $$\"]\nrule r: x".to_string(),
1899        )]);
1900        assert!(
1901            result.is_err(),
1902            "must reject non-numeric suggestion on number type"
1903        );
1904    }
1905
1906    #[test]
1907    fn planning_rejects_text_literal_as_number_default() {
1908        // `suggest "10"` produces a typed `CommandArg::Literal(Value::Text("10"))`.
1909        // Planning matches on the literal's variant — a `Text` literal is rejected
1910        // where a `Number` literal is required, even though `"10"` would parse as
1911        // a valid `Decimal` if coerced.
1912        let mut engine = Engine::new();
1913        let result = engine.load([(
1914            SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
1915            "spec t\ndata x: number -> suggest \"10\"]\nrule r: x".to_string(),
1916        )]);
1917        assert!(
1918            result.is_err(),
1919            "must reject text literal \"10\" as suggestion for number type"
1920        );
1921    }
1922
1923    #[test]
1924    fn planning_rejects_invalid_boolean_default() {
1925        let mut engine = Engine::new();
1926        let result = engine.load([(
1927            SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
1928            "spec t\ndata x: [boolean -> suggest \"maybe\"]\nrule r: x".to_string(),
1929        )]);
1930        assert!(
1931            result.is_err(),
1932            "must reject non-boolean suggestion on boolean type"
1933        );
1934    }
1935
1936    #[test]
1937    fn planning_rejects_invalid_named_type_default() {
1938        // Named type: the parser can't validate this, only planning can.
1939        let mut engine = Engine::new();
1940        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())]);
1941        assert!(
1942            result.is_err(),
1943            "must reject non-numeric suggestion on named number type"
1944        );
1945    }
1946
1947    #[test]
1948    fn context_merges_cross_file_repo_identities() {
1949        let mut engine = Engine::new();
1950
1951        // Load two files with the same named repo, but different spec names.
1952        engine
1953            .load([(
1954                SourceType::Path(Arc::new(std::path::PathBuf::from("file1.lemma"))),
1955                "repo shared\nspec a\ndata x: 1".to_string(),
1956            )])
1957            .expect("first file should load");
1958
1959        engine
1960            .load([(
1961                SourceType::Path(Arc::new(std::path::PathBuf::from("file2.lemma"))),
1962                "repo shared\nspec b\ndata y: 2".to_string(),
1963            )])
1964            .expect("second file should load");
1965
1966        // Both specs should land under the same repo entry.
1967        // Workspace, embedded stdlib (`lemma`), plus the "shared" repo.
1968        assert_eq!(
1969            engine.context.repositories().len(),
1970            3,
1971            "should have workspace, stdlib repository, and one named user repository"
1972        );
1973
1974        let shared_repo = engine
1975            .context
1976            .find_repository("shared")
1977            .expect("shared repo should exist");
1978        let shared_specs = engine.context.repositories().get(&shared_repo).unwrap();
1979        assert_eq!(
1980            shared_specs.len(),
1981            2,
1982            "shared repo should contain both specs"
1983        );
1984        assert!(shared_specs.contains_key("a"));
1985        assert!(shared_specs.contains_key("b"));
1986
1987        // Loading a dependency with the same repo name should be rejected.
1988        let _result = engine.load([(
1989            SourceType::Dependency("@some/dep".to_string()),
1990            "repo shared\nspec c\ndata z: 3".to_string(),
1991        )]);
1992
1993        let result = engine.load([(
1994            SourceType::Path(Arc::new(std::path::PathBuf::from("file2.lemma"))),
1995            "repo shared\nspec a\ndata y: 2".to_string(),
1996        )]);
1997
1998        assert!(
1999            result.is_err(),
2000            "should reject duplicate spec name in same repo"
2001        );
2002        let load_err = result.unwrap_err();
2003        assert_eq!(
2004            load_err.errors.len(),
2005            2,
2006            "duplicate spec must error on both declaring sources, got: {:?}",
2007            load_err.errors
2008        );
2009        let joined = load_err
2010            .errors
2011            .iter()
2012            .map(|e| e.to_string())
2013            .collect::<Vec<_>>()
2014            .join("\n");
2015        assert!(
2016            joined.contains("Duplicate spec 'a'"),
2017            "error should mention duplicate spec, got: {joined}"
2018        );
2019        let paths: Vec<String> = load_err
2020            .errors
2021            .iter()
2022            .map(|err| {
2023                err.location()
2024                    .expect("duplicate errors must have source")
2025                    .source_type
2026                    .to_string()
2027            })
2028            .collect();
2029        assert!(
2030            paths.iter().any(|p| p == "file1.lemma"),
2031            "first declaring file must get conflict diagnostic, got paths: {paths:?}"
2032        );
2033        assert!(
2034            paths.iter().any(|p| p == "file2.lemma"),
2035            "incoming file must get conflict diagnostic, got paths: {paths:?}"
2036        );
2037    }
2038
2039    #[test]
2040    fn test_list_structure() {
2041        let mut engine = Engine::new();
2042        engine
2043            .load([(
2044                SourceType::Path(Arc::new(std::path::PathBuf::from("file1.lemma"))),
2045                "repo shared\nspec a\ndata x: 1\nrule r: x".to_string(),
2046            )])
2047            .expect("file should load");
2048
2049        let repos = engine.list();
2050        let shared_repo = repos
2051            .iter()
2052            .find(|r| r.repository.as_deref() == Some("shared"))
2053            .expect("shared repo in list");
2054        assert_eq!(shared_repo.specs.len(), 1);
2055        assert_eq!(shared_repo.specs[0].name, "a");
2056    }
2057}