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