1use crate::evaluation::Evaluator;
2use crate::parsing::ast::{DateTimeValue, LemmaRepository, LemmaSpec};
3use crate::parsing::source::SourceType;
4use crate::parsing::EffectiveDate;
5use crate::planning::{DataOverlay, DataValueInput, LemmaSpecSet, SpecSchema};
6use crate::{parse, Error, ResourceLimits, Response};
7use indexmap::IndexMap;
8use std::collections::HashMap;
9use std::sync::Arc;
10
11#[cfg(not(target_arch = "wasm32"))]
12use std::collections::HashSet;
13#[cfg(not(target_arch = "wasm32"))]
14use std::path::Path;
15
16#[derive(Debug, Clone)]
18pub struct Errors {
19 pub errors: Vec<Error>,
20 pub sources: HashMap<SourceType, String>,
21}
22
23impl Errors {
24 pub fn iter(&self) -> std::slice::Iter<'_, Error> {
26 self.errors.iter()
27 }
28}
29
30pub const EMBEDDED_STDLIB_REPOSITORY: &str = "lemma";
33
34#[cfg(not(target_arch = "wasm32"))]
37pub fn collect_lemma_sources<P: AsRef<Path>>(
38 paths: &[P],
39) -> Result<HashMap<SourceType, String>, Errors> {
40 use std::fs;
41 use std::path::PathBuf;
42 use std::sync::Arc;
43
44 let mut sources = HashMap::new();
45 let mut seen = HashSet::<PathBuf>::new();
46
47 for path in paths {
48 let path = path.as_ref();
49 if path.is_file() {
50 if path.extension().is_none_or(|e| e != "lemma") {
51 continue;
52 }
53 let p = path.to_path_buf();
54 if seen.contains(&p) {
55 continue;
56 }
57 seen.insert(p.clone());
58 let content = fs::read_to_string(path).map_err(|e| Errors {
59 errors: vec![Error::request(
60 format!("Cannot read '{}': {}", path.display(), e),
61 None::<String>,
62 )],
63 sources: HashMap::new(),
64 })?;
65 sources.insert(SourceType::Path(Arc::new(p)), content);
66 } else if path.is_dir() {
67 let read_dir = fs::read_dir(path).map_err(|e| Errors {
68 errors: vec![Error::request(
69 format!("Cannot read directory '{}': {}", path.display(), e),
70 None::<String>,
71 )],
72 sources: HashMap::new(),
73 })?;
74 for entry_result in read_dir {
75 let entry = entry_result.map_err(|e| Errors {
76 errors: vec![Error::request(
77 format!("Cannot read directory entry in '{}': {}", path.display(), e),
78 None::<String>,
79 )],
80 sources: HashMap::new(),
81 })?;
82 let p = entry.path();
83 if !p.is_file() || p.extension().is_none_or(|e| e != "lemma") {
84 continue;
85 }
86 if seen.contains(&p) {
87 continue;
88 }
89 seen.insert(p.clone());
90 let content = fs::read_to_string(&p).map_err(|e| Errors {
91 errors: vec![Error::request(
92 format!("Cannot read '{}': {}", p.display(), e),
93 None::<String>,
94 )],
95 sources: HashMap::new(),
96 })?;
97 sources.insert(SourceType::Path(Arc::new(p)), content);
98 }
99 }
100 }
101
102 Ok(sources)
103}
104
105#[derive(Debug, Clone, serde::Serialize)]
111pub struct ResolvedRepository {
112 pub repository: Arc<LemmaRepository>,
113 pub specs: Vec<LemmaSpecSet>,
114}
115
116#[derive(Debug)]
128pub struct Context {
129 repositories: IndexMap<Arc<LemmaRepository>, IndexMap<String, LemmaSpecSet>>,
130 workspace: Arc<LemmaRepository>,
131}
132
133impl Default for Context {
134 fn default() -> Self {
135 Self::new()
136 }
137}
138
139impl Context {
140 pub fn new() -> Self {
142 let workspace = Arc::new(LemmaRepository::new(None));
143 let mut repositories = IndexMap::new();
144 repositories.insert(Arc::clone(&workspace), IndexMap::new());
145 Self {
146 repositories,
147 workspace,
148 }
149 }
150
151 #[must_use]
155 pub fn workspace(&self) -> Arc<LemmaRepository> {
156 Arc::clone(&self.workspace)
157 }
158
159 #[must_use]
161 pub fn find_repository(&self, name: &str) -> Option<Arc<LemmaRepository>> {
162 let probe = Arc::new(LemmaRepository::new(Some(name.to_string())));
163 self.repositories
164 .get_key_value(&probe)
165 .map(|(k, _)| Arc::clone(k))
166 }
167
168 #[must_use]
171 pub fn repositories(&self) -> &IndexMap<Arc<LemmaRepository>, IndexMap<String, LemmaSpecSet>> {
172 &self.repositories
173 }
174
175 pub fn iter(&self) -> impl Iterator<Item = Arc<LemmaSpec>> + '_ {
176 self.repositories
177 .values()
178 .flat_map(|m| m.values())
179 .flat_map(|ss| ss.iter_specs())
180 }
181
182 pub fn iter_with_ranges(
186 &self,
187 ) -> impl Iterator<Item = (Arc<LemmaSpec>, Option<DateTimeValue>, Option<DateTimeValue>)> + '_
188 {
189 self.repositories
190 .values()
191 .flat_map(|m| m.values())
192 .flat_map(|ss| ss.iter_with_ranges())
193 }
194
195 #[must_use]
198 pub fn spec_set(&self, repository: &Arc<LemmaRepository>, name: &str) -> Option<&LemmaSpecSet> {
199 let canonical_name = crate::parsing::ast::ascii_lowercase_logical_name(name.to_string());
200 self.repositories
201 .get(repository)
202 .and_then(|m| m.get(&canonical_name))
203 }
204
205 #[must_use]
208 pub(crate) fn spec_sets_for(&self, repository: &Arc<LemmaRepository>) -> Vec<LemmaSpecSet> {
209 self.repositories
210 .get(repository)
211 .expect("BUG: repository not in context")
212 .values()
213 .cloned()
214 .collect()
215 }
216
217 pub fn insert_spec(
225 &mut self,
226 repository: Arc<LemmaRepository>,
227 spec: Arc<LemmaSpec>,
228 ) -> Result<(), Error> {
229 if let Some((existing_repo, _)) = self.repositories.get_key_value(&repository) {
230 if existing_repo.dependency != repository.dependency {
231 let repo_display = repository.name.as_deref().unwrap_or("(main)");
232 let existing_owner = match &existing_repo.dependency {
233 None => "the workspace".to_string(),
234 Some(id) => format!("dependency '{id}'"),
235 };
236 let new_owner = match &repository.dependency {
237 None => "the workspace".to_string(),
238 Some(id) => format!("dependency '{id}'"),
239 };
240 return Err(Error::validation_with_context(
241 format!(
242 "Repository '{repo_display}' was introduced by {existing_owner} but {new_owner} also declares it"
243 ),
244 None,
245 Some("Each dependency's repositories must be unique across all loaded sources"),
246 Some(Arc::clone(&spec)),
247 None,
248 ));
249 }
250 }
251
252 let entry = self
253 .repositories
254 .entry(Arc::clone(&repository))
255 .or_default();
256 if entry
257 .get(&spec.name)
258 .is_some_and(|ss| ss.get_exact(spec.effective_from()).is_some())
259 {
260 return Err(Error::validation_with_context(
261 format!(
262 "Duplicate spec '{}' (same repository, name and effective_from already in context)",
263 spec.name
264 ),
265 None,
266 None::<String>,
267 Some(Arc::clone(&spec)),
268 None,
269 ));
270 }
271
272 let name = spec.name.clone();
273 let inserted = entry
274 .entry(name.clone())
275 .or_insert_with(|| LemmaSpecSet::new(repository, name))
276 .insert(spec);
277 debug_assert!(inserted);
278 Ok(())
279 }
280
281 pub fn remove_spec(
282 &mut self,
283 repository: &Arc<LemmaRepository>,
284 spec: &Arc<LemmaSpec>,
285 ) -> bool {
286 let Some(inner) = self.repositories.get_mut(repository) else {
287 return false;
288 };
289 let Some(ss) = inner.get_mut(&spec.name) else {
290 return false;
291 };
292 if !ss.remove(spec.effective_from()) {
293 return false;
294 }
295 if ss.is_empty() {
296 inner.shift_remove(&spec.name);
297 }
298 true
299 }
300
301 #[cfg(test)]
302 pub(crate) fn len(&self) -> usize {
303 self.repositories
304 .values()
305 .flat_map(|m| m.values())
306 .map(LemmaSpecSet::len)
307 .sum()
308 }
309}
310
311pub struct Engine {
323 plan_sets: HashMap<
325 Arc<crate::parsing::ast::LemmaRepository>,
326 HashMap<String, crate::planning::ExecutionPlanSet>,
327 >,
328 specs: Context,
329 evaluator: Evaluator,
330 limits: ResourceLimits,
331 total_expression_count: usize,
332}
333
334impl Default for Engine {
335 fn default() -> Self {
336 Self::new()
337 }
338}
339
340impl Engine {
341 pub fn new() -> Self {
342 Self::with_limits(ResourceLimits::default())
343 }
344
345 pub fn with_limits(limits: ResourceLimits) -> Self {
346 let mut engine = Self {
347 plan_sets: HashMap::new(),
348 specs: Context::new(),
349 evaluator: Evaluator,
350 limits,
351 total_expression_count: 0,
352 };
353 engine
354 .add_sources_inner(
355 HashMap::from([(SourceType::Volatile, crate::stdlib::UNITS_LEMMA.to_string())]),
356 Some("lemma"),
357 true,
358 )
359 .expect("BUG: embedded stdlib must load");
360 engine
361 }
362
363 pub fn specs(&self) -> &Context {
364 &self.specs
365 }
366
367 pub fn specs_mut(&mut self) -> &mut Context {
368 &mut self.specs
369 }
370
371 pub fn resolve_effective(raw: Option<&str>) -> Result<DateTimeValue, Error> {
376 match raw {
377 Some(s) if !s.trim().is_empty() => s.trim().parse::<DateTimeValue>().map_err(|_| {
378 Error::request(
379 format!(
380 "Invalid effective value '{}'. Expected: YYYY, YYYY-MM, YYYY-MM-DD, or ISO 8601 datetime",
381 s.trim()
382 ),
383 None::<String>,
384 )
385 }),
386 _ => Ok(DateTimeValue::now()),
387 }
388 }
389
390 fn apply_planning_result(&mut self, pr: crate::planning::PlanningResult) {
391 self.plan_sets.clear();
392 for r in &pr.results {
393 if r.errors().next().is_some() {
394 continue;
395 }
396 self.plan_sets
397 .entry(Arc::clone(&r.repository))
398 .or_default()
399 .insert(r.name.clone(), r.execution_plan_set());
400 }
401 }
402
403 pub fn load(&mut self, code: impl Into<String>, source: SourceType) -> Result<(), Errors> {
405 self.load_batch(HashMap::from([(source, code.into())]), None)
406 }
407
408 pub fn load_batch(
413 &mut self,
414 sources: HashMap<SourceType, String>,
415 dependency: Option<&str>,
416 ) -> Result<(), Errors> {
417 self.add_sources_inner(sources, dependency, false)
418 }
419
420 fn validate_source_for_load(source: &SourceType) -> Result<(), Errors> {
421 match source {
422 SourceType::Path(p) if p.as_os_str().to_string_lossy().trim().is_empty() => {
423 Err(Errors {
424 errors: vec![Error::request(
425 "Source path must be non-empty",
426 None::<String>,
427 )],
428 sources: HashMap::new(),
429 })
430 }
431 SourceType::Registry(repo) => {
432 if repo.name.as_deref().unwrap_or("").is_empty() {
433 Err(Errors {
434 errors: vec![Error::request(
435 "Registry source identifier must be non-empty",
436 None::<String>,
437 )],
438 sources: HashMap::new(),
439 })
440 } else {
441 Ok(())
442 }
443 }
444 _ => Ok(()),
445 }
446 }
447
448 fn reject_reserved_stdlib_repository(repository: &Arc<LemmaRepository>) -> Option<Error> {
449 if repository.name.as_deref() == Some(EMBEDDED_STDLIB_REPOSITORY) {
450 Some(Error::validation_with_context(
451 format!(
452 "Repository '{EMBEDDED_STDLIB_REPOSITORY}' is reserved for the embedded standard library and cannot be loaded via load or load_batch; use @owner/repo qualifiers (e.g. '@iso/countries'), not the reserved 'lemma' repository"
453 ),
454 None,
455 Some("Load registry dependencies with @owner/repo qualifiers, not the reserved 'lemma' stdlib repository".to_string()),
456 None,
457 None,
458 ))
459 } else {
460 None
461 }
462 }
463
464 fn add_sources_inner(
465 &mut self,
466 sources: HashMap<SourceType, String>,
467 dependency: Option<&str>,
468 embedded_stdlib: bool,
469 ) -> Result<(), Errors> {
470 for st in sources.keys() {
471 Self::validate_source_for_load(st)?;
472 }
473 if !embedded_stdlib {
474 let limits = &self.limits;
475 if sources.len() > limits.max_sources {
476 return Err(Errors {
477 errors: vec![Error::resource_limit_exceeded(
478 "max_sources",
479 limits.max_sources.to_string(),
480 sources.len().to_string(),
481 "Reduce the number of paths or sources in one load",
482 None::<crate::parsing::source::Source>,
483 None,
484 None,
485 )],
486 sources,
487 });
488 }
489 let total_loaded_bytes: usize = sources.values().map(|s| s.len()).sum();
490 if total_loaded_bytes > limits.max_loaded_bytes {
491 return Err(Errors {
492 errors: vec![Error::resource_limit_exceeded(
493 "max_loaded_bytes",
494 limits.max_loaded_bytes.to_string(),
495 total_loaded_bytes.to_string(),
496 "Load fewer or smaller sources",
497 None::<crate::parsing::source::Source>,
498 None,
499 None,
500 )],
501 sources,
502 });
503 }
504 for code in sources.values() {
505 if code.len() > limits.max_source_size_bytes {
506 return Err(Errors {
507 errors: vec![Error::resource_limit_exceeded(
508 "max_source_size_bytes",
509 limits.max_source_size_bytes.to_string(),
510 code.len().to_string(),
511 "Use a smaller source text or increase limit",
512 None::<crate::parsing::source::Source>,
513 None,
514 None,
515 )],
516 sources,
517 });
518 }
519 }
520 }
521
522 let parse_limits = if embedded_stdlib {
523 &ResourceLimits::default()
524 } else {
525 &self.limits
526 };
527 let mut errors: Vec<Error> = Vec::new();
528
529 for (source_id, code) in &sources {
530 match parse(code, source_id.clone(), parse_limits) {
531 Ok(result) => {
532 if !embedded_stdlib {
533 self.total_expression_count += result.expression_count;
534 if self.total_expression_count > self.limits.max_total_expression_count {
535 errors.push(Error::resource_limit_exceeded(
536 "max_total_expression_count",
537 self.limits.max_total_expression_count.to_string(),
538 self.total_expression_count.to_string(),
539 "Split logic across fewer sources or reduce expression complexity",
540 None::<crate::parsing::source::Source>,
541 None,
542 None,
543 ));
544 return Err(Errors { errors, sources });
545 }
546 }
547 if result.repositories.is_empty() {
548 continue;
549 }
550
551 for (parsed_repo, specs) in &result.repositories {
552 let repository_arc = if let Some(dep_id) = dependency {
553 let repo_name = parsed_repo
554 .name
555 .clone()
556 .or_else(|| Some(dep_id.to_string()));
558 Arc::new(
559 LemmaRepository::new(repo_name)
560 .with_dependency(dep_id)
561 .with_start_line(parsed_repo.start_line),
562 )
563 } else {
564 Arc::clone(parsed_repo)
565 };
566 if !embedded_stdlib {
567 if let Some(reserved_err) =
568 Self::reject_reserved_stdlib_repository(&repository_arc)
569 {
570 let source = crate::parsing::source::Source::new(
571 source_id.clone(),
572 crate::parsing::ast::Span {
573 start: 0,
574 end: 0,
575 line: parsed_repo.start_line,
576 col: 0,
577 },
578 );
579 errors.push(Error::validation(
580 reserved_err.to_string(),
581 Some(source),
582 reserved_err.suggestion().map(str::to_string),
583 ));
584 continue;
585 }
586 }
587 for spec in specs {
588 match self
589 .specs
590 .insert_spec(Arc::clone(&repository_arc), Arc::new(spec.clone()))
591 {
592 Ok(()) => {}
593 Err(e) => {
594 let source = crate::parsing::source::Source::new(
595 source_id.clone(),
596 crate::parsing::ast::Span {
597 start: 0,
598 end: 0,
599 line: spec.start_line,
600 col: 0,
601 },
602 );
603 errors.push(Error::validation(
604 e.to_string(),
605 Some(source),
606 None::<String>,
607 ));
608 }
609 }
610 }
611 }
612 }
613 Err(e) => errors.push(e),
614 }
615 }
616
617 let planning_result = crate::planning::plan(&self.specs, &self.limits);
618 for set_result in &planning_result.results {
619 for spec_result in &set_result.slice_results {
620 let ctx = Arc::clone(&spec_result.spec);
621 for err in &spec_result.errors {
622 errors.push(err.clone().with_spec_context(Arc::clone(&ctx)));
623 }
624 }
625 }
626 self.apply_planning_result(planning_result);
627
628 if errors.is_empty() {
629 Ok(())
630 } else {
631 Err(Errors { errors, sources })
632 }
633 }
634
635 pub fn get_spec(
642 &self,
643 name: &str,
644 effective: Option<&DateTimeValue>,
645 ) -> Result<Arc<LemmaSpec>, Error> {
646 let effective_dt = self.effective_or_now(effective);
647 let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
648 let repository = self.specs.workspace();
649 let spec_set = self
650 .specs
651 .spec_set(&repository, name)
652 .ok_or_else(|| self.spec_not_found_error(name, &effective_dt))?;
653 spec_set
654 .spec_at(&instant)
655 .ok_or_else(|| self.spec_not_found_error(name, &effective_dt))
656 }
657
658 #[must_use]
664 pub fn list(&self) -> Vec<ResolvedRepository> {
665 self.specs
666 .repositories()
667 .iter()
668 .map(|(repo, inner)| ResolvedRepository {
669 repository: Arc::clone(repo),
670 specs: inner.values().cloned().collect(),
671 })
672 .collect()
673 }
674
675 #[must_use]
677 pub fn get_workspace(&self) -> ResolvedRepository {
678 let repo = self.specs.workspace();
679 let specs = self.specs.spec_sets_for(&repo);
680 ResolvedRepository {
681 repository: repo,
682 specs,
683 }
684 }
685
686 pub fn get_repository(&self, qualifier: &str) -> Result<ResolvedRepository, Error> {
689 let q = qualifier.trim();
690 if q.is_empty() {
691 return Err(Error::request(
692 "Repository qualifier cannot be empty",
693 None::<String>,
694 ));
695 }
696 match self.specs.find_repository(q) {
697 Some(repo) => {
698 let specs = self.specs.spec_sets_for(&repo);
699 Ok(ResolvedRepository {
700 repository: repo,
701 specs,
702 })
703 }
704 None => Err(Error::request_not_found(
705 format!("Repository '{qualifier}' not loaded"),
706 Some(format!(
707 "List repositories with `{}` after loading your workspace",
708 "lemma list"
709 )),
710 )),
711 }
712 }
713
714 pub fn format_repository(&self, repository: &str) -> Result<String, Error> {
716 let resolved = self.get_repository(repository)?;
717 let mut specs: Vec<Arc<LemmaSpec>> = resolved
718 .specs
719 .iter()
720 .flat_map(|ss| ss.iter_specs())
721 .collect();
722 specs.sort_by(|a, b| {
723 a.name
724 .cmp(&b.name)
725 .then_with(|| a.effective_from.cmp(&b.effective_from))
726 });
727 let spec_refs: Vec<&LemmaSpec> = specs.iter().map(AsRef::as_ref).collect();
728 let body = crate::formatting::format_spec_refs(&spec_refs);
729 let mut out = String::new();
730 if let Some(name) = resolved.repository.name.as_deref() {
731 out.push_str("repo ");
732 out.push_str(name);
733 out.push_str("\n\n");
734 }
735 out.push_str(&body);
736 Ok(out)
737 }
738
739 pub fn schema(
743 &self,
744 repo: Option<&str>,
745 spec: &str,
746 effective: Option<&DateTimeValue>,
747 ) -> Result<SpecSchema, Error> {
748 Ok(self
749 .get_plan(repo, spec, effective)?
750 .schema(&DataOverlay::default()))
751 }
752
753 pub fn limits(&self) -> &ResourceLimits {
757 &self.limits
758 }
759
760 pub fn run(
772 &self,
773 repo: Option<&str>,
774 spec: &str,
775 effective: Option<&DateTimeValue>,
776 data_values: HashMap<String, String>,
777 explain: bool,
778 rules: Option<&[String]>,
779 ) -> Result<Response, Error> {
780 let effective = self.effective_or_now(effective);
781 let plan = self.get_plan(repo, spec, Some(&effective))?;
782 let data_values: HashMap<String, DataValueInput> = data_values
783 .into_iter()
784 .map(|(k, v)| (k, DataValueInput::convenience(v)))
785 .collect();
786 self.run_plan(plan, Some(&effective), data_values, explain, rules)
787 }
788
789 pub fn get_plan(
793 &self,
794 repo: Option<&str>,
795 name: &str,
796 effective: Option<&DateTimeValue>,
797 ) -> Result<&crate::planning::ExecutionPlan, Error> {
798 let effective_dt = self.effective_or_now(effective);
799 let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
800 let canonical_name = crate::parsing::ast::ascii_lowercase_logical_name(name.to_string());
801
802 let repository = match repo {
803 Some(q) => self.specs.find_repository(q).ok_or_else(|| {
804 Error::request_not_found(
805 format!("Repository '{q}' not loaded"),
806 Some("List repositories with `lemma list` after loading your workspace"),
807 )
808 })?,
809 None => self.specs.workspace(),
810 };
811
812 let Some(spec_set) = self.specs.spec_set(&repository, &canonical_name) else {
813 return Err(self.spec_not_found_in_repository_error(&repository, name, &effective_dt));
814 };
815
816 if spec_set.spec_at(&instant).is_none() {
817 return Err(self.spec_not_found_in_repository_error(&repository, name, &effective_dt));
818 }
819
820 let plan_set = self
821 .plan_sets
822 .get(&repository)
823 .and_then(|by_name| by_name.get(&canonical_name))
824 .ok_or_else(|| {
825 Error::request_not_found(
826 format!("No execution plans for spec '{name}'"),
827 Some("Ensure sources loaded and planning succeeded"),
828 )
829 })?;
830
831 plan_set.plan_at(&instant).ok_or_else(|| {
832 Error::request_not_found(
833 format!("No execution plan slice for spec '{name}' at effective {effective_dt}"),
834 None::<String>,
835 )
836 })
837 }
838
839 pub fn run_plan(
852 &self,
853 plan: &crate::planning::ExecutionPlan,
854 effective: Option<&DateTimeValue>,
855 data_values: HashMap<String, DataValueInput>,
856 explain: bool,
857 rules: Option<&[String]>,
858 ) -> Result<Response, Error> {
859 let response_rules = plan.validated_response_rule_names(rules)?;
860 let effective = self.effective_or_now(effective);
861 let overlay = DataOverlay::resolve(plan, data_values, &self.limits)?;
862 crate::planning::execution_plan::validate_unit_index_references(plan)?;
863 let now_semantic = crate::planning::semantics::date_time_to_semantic(&effective);
864 let now_literal = crate::planning::semantics::LiteralValue {
865 value: crate::planning::semantics::ValueKind::Date(now_semantic),
866 lemma_type: crate::planning::semantics::primitive_date_arc().clone(),
867 };
868 let (mut response, context) =
869 self.evaluator
870 .evaluate(plan, &overlay, now_literal, explain, &response_rules);
871 if explain {
872 self.evaluator.explain(&mut response, plan, &context);
873 }
874 Ok(response)
875 }
876
877 pub fn remove(&mut self, name: &str, effective: Option<&DateTimeValue>) -> Result<(), Error> {
878 let effective = self.effective_or_now(effective);
879 let repository_arc = self.specs.workspace();
880 let spec_arc = self.get_spec(name, Some(&effective))?;
881 self.specs.remove_spec(&repository_arc, &spec_arc);
882 let pr = crate::planning::plan(&self.specs, &self.limits);
883 let planning_errs: Vec<Error> = pr
884 .results
885 .iter()
886 .flat_map(|r| r.errors().cloned())
887 .collect();
888 self.apply_planning_result(pr);
889 if let Some(e) = planning_errs.into_iter().next() {
890 return Err(e);
891 }
892 Ok(())
893 }
894
895 fn spec_not_found_error(&self, spec_name: &str, effective: &DateTimeValue) -> Error {
898 let workspace = self.specs.workspace();
899 let available = match self.specs.spec_set(&workspace, spec_name) {
900 Some(ss) => ss.iter_specs().collect::<Vec<_>>(),
901 None => Vec::new(),
902 };
903 let msg = if available.is_empty() {
904 format!("Spec '{}' not found", spec_name)
905 } else {
906 let listing: Vec<String> = available
907 .iter()
908 .map(|s| match s.effective_from() {
909 Some(dt) => format!(" {} (effective from {})", s.name, dt),
910 None => format!(" {} (no effective_from)", s.name),
911 })
912 .collect();
913 format!(
914 "Spec '{}' not found for effective {}. Available versions:\n{}",
915 spec_name,
916 effective,
917 listing.join("\n")
918 )
919 };
920 Error::request_not_found(msg, None::<String>)
921 }
922
923 fn spec_not_found_in_repository_error(
924 &self,
925 repository: &LemmaRepository,
926 spec_name: &str,
927 effective: &DateTimeValue,
928 ) -> Error {
929 let repo_label = match &repository.name {
930 Some(n) => n.clone(),
931 None => "(workspace)".to_string(),
932 };
933 Error::request_not_found(
934 format!(
935 "Spec '{spec_name}' not found in repository {repo_label} at effective {effective}",
936 ),
937 Some("Try `lemma list`"),
938 )
939 }
940
941 #[must_use]
943 fn effective_or_now(&self, effective: Option<&DateTimeValue>) -> DateTimeValue {
944 effective.cloned().unwrap_or_else(DateTimeValue::now)
945 }
946}
947
948#[cfg(test)]
949mod tests {
950 use super::*;
951
952 fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
953 DateTimeValue {
954 year,
955 month,
956 day,
957 hour: 0,
958 minute: 0,
959 second: 0,
960 microsecond: 0,
961 timezone: None,
962 granularity: crate::literals::DateGranularity::Full,
963 }
964 }
965
966 fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
967 let mut spec = LemmaSpec::new(name.to_string());
968 spec.effective_from = crate::parsing::ast::EffectiveDate::from_option(effective_from);
969 spec
970 }
971
972 #[test]
975 fn list_specs_order_is_name_then_effective_from_ascending() {
976 let mut ctx = Context::new();
977 let repository = ctx.workspace();
978 let s_2026 = Arc::new(make_spec_with_range("mortgage", Some(date(2026, 1, 1))));
979 let s_2025 = Arc::new(make_spec_with_range("mortgage", Some(date(2025, 1, 1))));
980 ctx.insert_spec(Arc::clone(&repository), Arc::clone(&s_2026))
981 .unwrap();
982 ctx.insert_spec(Arc::clone(&repository), Arc::clone(&s_2025))
983 .unwrap();
984 let listed: Vec<_> = ctx
985 .spec_set(&repository, "mortgage")
986 .expect("mortgage set")
987 .iter_specs()
988 .collect();
989 assert_eq!(listed.len(), 2);
990 assert_eq!(listed[0].effective_from(), Some(&date(2025, 1, 1)));
991 assert_eq!(listed[1].effective_from(), Some(&date(2026, 1, 1)));
992 }
993
994 #[test]
995 fn get_spec_resolves_temporal_version_by_effective() {
996 let mut engine = Engine::new();
997 engine
998 .load(
999 r#"
1000 spec pricing 2025-01-01
1001 data x: 1
1002 rule r: x
1003 "#,
1004 SourceType::Path(Arc::new(std::path::PathBuf::from("a.lemma"))),
1005 )
1006 .unwrap();
1007 engine
1008 .load(
1009 r#"
1010 spec pricing 2025-06-01
1011 data x: 2
1012 rule r: x
1013 "#,
1014 SourceType::Path(Arc::new(std::path::PathBuf::from("b.lemma"))),
1015 )
1016 .unwrap();
1017
1018 let jan = DateTimeValue {
1019 year: 2025,
1020 month: 1,
1021 day: 15,
1022 hour: 0,
1023 minute: 0,
1024 second: 0,
1025 microsecond: 0,
1026 timezone: None,
1027 granularity: crate::literals::DateGranularity::Full,
1028 };
1029 let jul = DateTimeValue {
1030 year: 2025,
1031 month: 7,
1032 day: 1,
1033 hour: 0,
1034 minute: 0,
1035 second: 0,
1036 microsecond: 0,
1037 timezone: None,
1038 granularity: crate::literals::DateGranularity::Full,
1039 };
1040
1041 let v1 = DateTimeValue {
1042 year: 2025,
1043 month: 1,
1044 day: 1,
1045 hour: 0,
1046 minute: 0,
1047 second: 0,
1048 microsecond: 0,
1049 timezone: None,
1050 granularity: crate::literals::DateGranularity::Full,
1051 };
1052 let v2 = DateTimeValue {
1053 year: 2025,
1054 month: 6,
1055 day: 1,
1056 hour: 0,
1057 minute: 0,
1058 second: 0,
1059 microsecond: 0,
1060 timezone: None,
1061 granularity: crate::literals::DateGranularity::Full,
1062 };
1063
1064 let s_jan = engine.get_spec("pricing", Some(&jan)).expect("jan spec");
1065 let s_jul = engine.get_spec("pricing", Some(&jul)).expect("jul spec");
1066 assert_eq!(s_jan.effective_from(), Some(&v1));
1067 assert_eq!(s_jul.effective_from(), Some(&v2));
1068 }
1069
1070 #[test]
1075 fn list_specs_returns_half_open_ranges_per_temporal_version() {
1076 let mut engine = Engine::new();
1077 engine
1078 .load(
1079 r#"
1080 spec pricing 2025-01-01
1081 data x: 1
1082 rule r: x
1083 "#,
1084 SourceType::Path(Arc::new(std::path::PathBuf::from("a.lemma"))),
1085 )
1086 .unwrap();
1087 engine
1088 .load(
1089 r#"
1090 spec pricing 2025-06-01
1091 data x: 2
1092 rule r: x
1093 "#,
1094 SourceType::Path(Arc::new(std::path::PathBuf::from("b.lemma"))),
1095 )
1096 .unwrap();
1097
1098 let january = date(2025, 1, 1);
1099 let june = date(2025, 6, 1);
1100
1101 let workspace = engine.get_workspace();
1102 let pricing_set = workspace
1103 .specs
1104 .iter()
1105 .find(|ss| ss.name == "pricing")
1106 .expect("pricing spec set exists");
1107 let mut ranges: Vec<(Option<DateTimeValue>, Option<DateTimeValue>)> = pricing_set
1108 .iter_with_ranges()
1109 .map(|(_, from, to)| (from, to))
1110 .collect();
1111 ranges.sort_by(|a, b| match (&a.0, &b.0) {
1112 (Some(x), Some(y)) => x.cmp(y),
1113 (None, Some(_)) => std::cmp::Ordering::Less,
1114 (Some(_), None) => std::cmp::Ordering::Greater,
1115 (None, None) => std::cmp::Ordering::Equal,
1116 });
1117 assert_eq!(ranges.len(), 2);
1118 assert_eq!(
1119 ranges[0],
1120 (Some(january.clone()), Some(june.clone())),
1121 "earlier row ends at the next row's effective_from"
1122 );
1123 assert_eq!(
1124 ranges[1],
1125 (Some(june.clone()), None),
1126 "latest row has no successor; effective_to is None"
1127 );
1128
1129 assert!(
1130 !engine
1131 .get_workspace()
1132 .specs
1133 .iter()
1134 .any(|ss| ss.name == "unknown"),
1135 "no rows for unknown spec"
1136 );
1137 }
1138
1139 #[test]
1142 fn get_workspace_specs_with_half_open_ranges() {
1143 let mut engine = Engine::new();
1144 engine
1145 .load(
1146 r#"
1147 spec pricing 2025-01-01
1148 data x: 1
1149 rule r: x
1150 "#,
1151 SourceType::Path(Arc::new(std::path::PathBuf::from("pricing_v1.lemma"))),
1152 )
1153 .unwrap();
1154 engine
1155 .load(
1156 r#"
1157 spec pricing 2026-01-01
1158 data x: 2
1159 rule r: x
1160 "#,
1161 SourceType::Path(Arc::new(std::path::PathBuf::from("pricing_v2.lemma"))),
1162 )
1163 .unwrap();
1164 engine
1165 .load(
1166 r#"
1167 spec taxes
1168 data rate: 0.21
1169 rule amount: rate
1170 "#,
1171 SourceType::Path(Arc::new(std::path::PathBuf::from("taxes.lemma"))),
1172 )
1173 .unwrap();
1174
1175 let workspace = engine.get_workspace();
1176 assert_eq!(workspace.specs.len(), 2, "two spec sets: pricing and taxes");
1177
1178 let pricing_set = workspace
1179 .specs
1180 .iter()
1181 .find(|ss| ss.name == "pricing")
1182 .expect("pricing spec set exists");
1183 let ranges: Vec<_> = pricing_set.iter_with_ranges().collect();
1184 assert_eq!(ranges.len(), 2);
1185 assert_eq!(ranges[0].1, Some(date(2025, 1, 1)));
1186 assert_eq!(
1187 ranges[0].2,
1188 Some(date(2026, 1, 1)),
1189 "earlier pricing row ends at the next pricing row's effective_from"
1190 );
1191 assert_eq!(ranges[1].1, Some(date(2026, 1, 1)));
1192 assert_eq!(
1193 ranges[1].2, None,
1194 "latest pricing row has no successor; effective_to is None"
1195 );
1196
1197 let taxes_set = workspace
1198 .specs
1199 .iter()
1200 .find(|ss| ss.name == "taxes")
1201 .expect("taxes spec set exists");
1202 let tax_ranges: Vec<_> = taxes_set.iter_with_ranges().collect();
1203 assert_eq!(tax_ranges.len(), 1);
1204 assert_eq!(
1205 tax_ranges[0].1, None,
1206 "unversioned spec has no declared effective_from"
1207 );
1208 assert_eq!(
1209 tax_ranges[0].2, None,
1210 "unversioned spec has no successor; effective_to is None"
1211 );
1212 }
1213
1214 #[test]
1215 fn test_evaluate_spec_all_rules() {
1216 let mut engine = Engine::new();
1217 engine
1218 .load(
1219 r#"
1220 spec test
1221 data x: 10
1222 data y: 5
1223 rule sum: x + y
1224 rule product: x * y
1225 "#,
1226 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1227 )
1228 .unwrap();
1229
1230 let now = DateTimeValue::now();
1231 let response = engine
1232 .run(None, "test", Some(&now), HashMap::new(), false, None)
1233 .unwrap();
1234 assert_eq!(response.results.len(), 2);
1235
1236 let sum_result = response
1237 .results
1238 .values()
1239 .find(|r| r.rule.name == "sum")
1240 .unwrap();
1241 assert_eq!(sum_result.display.clone().expect("display"), "15");
1242
1243 let product_result = response
1244 .results
1245 .values()
1246 .find(|r| r.rule.name == "product")
1247 .unwrap();
1248 assert_eq!(product_result.display.clone().expect("display"), "50");
1249 }
1250
1251 #[test]
1252 fn test_evaluate_empty_data() {
1253 let mut engine = Engine::new();
1254 engine
1255 .load(
1256 r#"
1257 spec test
1258 data price: 100
1259 rule total: price * 2
1260 "#,
1261 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1262 )
1263 .unwrap();
1264
1265 let now = DateTimeValue::now();
1266 let response = engine
1267 .run(None, "test", Some(&now), HashMap::new(), false, None)
1268 .unwrap();
1269 assert_eq!(response.results.len(), 1);
1270 assert_eq!(
1271 response
1272 .results
1273 .values()
1274 .next()
1275 .unwrap()
1276 .display
1277 .clone()
1278 .expect("display"),
1279 "200"
1280 );
1281 }
1282
1283 #[test]
1284 fn test_evaluate_boolean_rule() {
1285 let mut engine = Engine::new();
1286 engine
1287 .load(
1288 r#"
1289 spec test
1290 data age: 25
1291 rule is_adult: age >= 18
1292 "#,
1293 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1294 )
1295 .unwrap();
1296
1297 let now = DateTimeValue::now();
1298 let response = engine
1299 .run(None, "test", Some(&now), HashMap::new(), false, None)
1300 .unwrap();
1301 assert_eq!(
1302 response.results.values().next().unwrap().boolean,
1303 Some(true)
1304 );
1305 }
1306
1307 #[test]
1308 fn test_evaluate_with_unless_clause() {
1309 let mut engine = Engine::new();
1310 engine
1311 .load(
1312 r#"
1313 spec test
1314 data quantity: 15
1315 rule discount: 0
1316 unless quantity >= 10 then 10
1317 "#,
1318 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1319 )
1320 .unwrap();
1321
1322 let now = DateTimeValue::now();
1323 let response = engine
1324 .run(None, "test", Some(&now), HashMap::new(), false, None)
1325 .unwrap();
1326 assert_eq!(
1327 response
1328 .results
1329 .values()
1330 .next()
1331 .unwrap()
1332 .display
1333 .clone()
1334 .expect("display"),
1335 "10"
1336 );
1337 }
1338
1339 #[test]
1340 fn test_spec_not_found() {
1341 let engine = Engine::new();
1342 let now = DateTimeValue::now();
1343 let result = engine.run(None, "nonexistent", Some(&now), HashMap::new(), false, None);
1344 assert!(result.is_err());
1345 assert!(result.unwrap_err().to_string().contains("not found"));
1346 }
1347
1348 #[test]
1349 fn test_multiple_specs() {
1350 let mut engine = Engine::new();
1351 engine
1352 .load(
1353 r#"
1354 spec spec1
1355 data x: 10
1356 rule result: x * 2
1357 "#,
1358 SourceType::Path(Arc::new(std::path::PathBuf::from("spec 1.lemma"))),
1359 )
1360 .unwrap();
1361
1362 engine
1363 .load(
1364 r#"
1365 spec spec2
1366 data y: 5
1367 rule result: y * 3
1368 "#,
1369 SourceType::Path(Arc::new(std::path::PathBuf::from("spec 2.lemma"))),
1370 )
1371 .unwrap();
1372
1373 let now = DateTimeValue::now();
1374 let response1 = engine
1375 .run(None, "spec1", Some(&now), HashMap::new(), false, None)
1376 .unwrap();
1377 assert_eq!(response1.results[0].display.clone().expect("display"), "20");
1378 let response2 = engine
1379 .run(None, "spec2", Some(&now), HashMap::new(), false, None)
1380 .unwrap();
1381 assert_eq!(response2.results[0].display.clone().expect("display"), "15");
1382 }
1383
1384 #[test]
1385 fn test_runtime_error_mapping() {
1386 let mut engine = Engine::new();
1387 engine
1388 .load(
1389 r#"
1390 spec test
1391 data numerator: 10
1392 data denominator: 0
1393 rule division: numerator / denominator
1394 "#,
1395 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1396 )
1397 .unwrap();
1398
1399 let now = DateTimeValue::now();
1400 let result = engine.run(None, "test", Some(&now), HashMap::new(), false, None);
1401 assert!(result.is_ok(), "Evaluation should succeed");
1403 let response = result.unwrap();
1404 let division_result = response
1405 .results
1406 .values()
1407 .find(|r| r.rule.name == "division");
1408 assert!(
1409 division_result.is_some(),
1410 "Should have division rule result"
1411 );
1412 let division = division_result.unwrap();
1413 assert!(division.vetoed);
1414 assert!(
1415 division
1416 .veto_reason
1417 .as_deref()
1418 .unwrap()
1419 .contains("Division by zero"),
1420 "Veto message should mention division by zero: {:?}",
1421 division.veto_reason
1422 );
1423 }
1424
1425 #[test]
1426 fn test_rules_sorted_by_source_order() {
1427 let mut engine = Engine::new();
1428 engine
1429 .load(
1430 r#"
1431 spec test
1432 data a: 1
1433 data b: 2
1434 rule z: a + b
1435 rule y: a * b
1436 rule x: a - b
1437 "#,
1438 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1439 )
1440 .unwrap();
1441
1442 let now = DateTimeValue::now();
1443 let response = engine
1444 .run(None, "test", Some(&now), HashMap::new(), false, None)
1445 .unwrap();
1446 assert_eq!(response.results.len(), 3);
1447
1448 let z_pos = response
1450 .results
1451 .values()
1452 .find(|r| r.rule.name == "z")
1453 .unwrap()
1454 .rule
1455 .source_location
1456 .span
1457 .start;
1458 let y_pos = response
1459 .results
1460 .values()
1461 .find(|r| r.rule.name == "y")
1462 .unwrap()
1463 .rule
1464 .source_location
1465 .span
1466 .start;
1467 let x_pos = response
1468 .results
1469 .values()
1470 .find(|r| r.rule.name == "x")
1471 .unwrap()
1472 .rule
1473 .source_location
1474 .span
1475 .start;
1476
1477 assert!(z_pos < y_pos);
1478 assert!(y_pos < x_pos);
1479 }
1480
1481 #[test]
1482 fn test_rule_filtering_evaluates_dependencies() {
1483 let mut engine = Engine::new();
1484 engine
1485 .load(
1486 r#"
1487 spec test
1488 data base: 100
1489 rule subtotal: base * 2
1490 rule tax: subtotal * 10%
1491 rule total: subtotal + tax
1492 "#,
1493 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1494 )
1495 .unwrap();
1496
1497 let now = DateTimeValue::now();
1498 let response = engine
1499 .run(
1500 None,
1501 "test",
1502 Some(&now),
1503 HashMap::new(),
1504 false,
1505 Some(&["total".to_string()]),
1506 )
1507 .unwrap();
1508
1509 assert_eq!(response.results.len(), 1);
1510 assert_eq!(response.results.keys().next().unwrap(), "total");
1511
1512 let total = response.results.values().next().unwrap();
1514 assert_eq!(total.display.clone().expect("display"), "220");
1515 }
1516
1517 use crate::parsing::ast::DateTimeValue;
1522
1523 #[test]
1524 fn pre_resolved_deps_in_file_map_evaluates_external_spec() {
1525 let mut engine = Engine::new();
1526
1527 engine
1528 .load_batch(
1529 HashMap::from([(
1530 SourceType::Volatile,
1531 "repo @org/project\nspec helper\ndata quantity: 42".to_string(),
1532 )]),
1533 Some("@org/project"),
1534 )
1535 .expect("should load dependency files");
1536
1537 engine
1538 .load(
1539 r#"spec main_spec
1540uses external: @org/project helper
1541rule value: external.quantity"#,
1542 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1543 )
1544 .expect("should succeed with pre-resolved deps");
1545
1546 let now = DateTimeValue::now();
1547 let response = engine
1548 .run(None, "main_spec", Some(&now), HashMap::new(), false, None)
1549 .expect("evaluate should succeed");
1550
1551 let value_result = response
1552 .results
1553 .get("value")
1554 .expect("rule 'value' should exist");
1555 assert_eq!(value_result.display.clone().expect("display"), "42");
1556 }
1557
1558 #[test]
1559 fn schema_with_repo_resolves_registry_spec() {
1560 let mut engine = Engine::new();
1561 engine
1562 .load_batch(
1563 HashMap::from([(
1564 SourceType::Volatile,
1565 "repo @org/project\nspec helper\ndata quantity: 42\nrule expose: quantity"
1566 .to_string(),
1567 )]),
1568 Some("@org/project"),
1569 )
1570 .expect("registry bundle loads");
1571
1572 engine
1573 .load(
1574 r#"spec main_spec
1575data x: 1"#,
1576 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1577 )
1578 .expect("main loads");
1579
1580 let now = DateTimeValue::now();
1581 let schema = engine
1582 .schema(Some("@org/project"), "helper", Some(&now))
1583 .expect("schema for registry spec");
1584 assert!(schema.data.contains_key("quantity"));
1585 }
1586
1587 #[test]
1588 fn load_no_external_refs_works() {
1589 let mut engine = Engine::new();
1590
1591 engine
1592 .load(
1593 r#"spec local_only
1594data price: 100
1595rule doubled: price * 2"#,
1596 SourceType::Path(Arc::new(std::path::PathBuf::from("local.lemma"))),
1597 )
1598 .expect("should succeed when there are no @... references");
1599
1600 let now = DateTimeValue::now();
1601 let response = engine
1602 .run(None, "local_only", Some(&now), HashMap::new(), false, None)
1603 .expect("evaluate should succeed");
1604
1605 let doubled = response.results.get("doubled").expect("doubled rule");
1606 assert_eq!(doubled.display.clone().expect("display"), "200");
1607 }
1608
1609 #[test]
1610 fn unresolved_external_ref_without_deps_fails() {
1611 let mut engine = Engine::new();
1612
1613 let result = engine.load(
1614 r#"spec main_spec
1615uses external: @org/project missing
1616rule value: external.quantity"#,
1617 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1618 );
1619
1620 let errs = result.expect_err("Should fail when registry dep is not loaded");
1621 assert!(
1622 errs.iter()
1623 .any(|e| e.kind() == crate::ErrorKind::MissingRepository),
1624 "expected MissingRepository, got: {:?}",
1625 errs.iter().map(|e| e.kind()).collect::<Vec<_>>()
1626 );
1627 }
1628
1629 #[test]
1630 fn pre_resolved_deps_with_spec_and_type_refs() {
1631 let mut engine = Engine::new();
1632
1633 engine
1634 .load_batch(
1635 HashMap::from([(
1636 SourceType::Volatile,
1637 "repo @org/example\nspec helper\ndata value: 42".to_string(),
1638 )]),
1639 Some("@org/example"),
1640 )
1641 .expect("should load helper file");
1642
1643 engine
1644 .load_batch(
1645 HashMap::from([(
1646 SourceType::Volatile,
1647 "repo @iso/countries\nspec alpha2\ndata code: text\n -> option \"NL\"\n -> option \"BE\"".to_string(),
1648 )]),
1649 Some("@iso/countries"),
1650 )
1651 .expect("should load alpha2 file");
1652
1653 engine
1654 .load(
1655 r#"spec registry_demo
1656uses @iso/countries alpha2
1657data country: alpha2.code
1658data unit_count: 5
1659uses @org/example helper
1660rule helper_value: helper.value
1661rule line_total: unit_count * 2
1662rule formatted: helper_value + 0"#,
1663 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1664 )
1665 .expect("should succeed with pre-resolved spec and type deps");
1666
1667 let now = DateTimeValue::now();
1668 let response = engine
1669 .run(
1670 None,
1671 "registry_demo",
1672 Some(&now),
1673 HashMap::new(),
1674 false,
1675 None,
1676 )
1677 .expect("evaluate should succeed");
1678
1679 assert_eq!(
1680 response
1681 .results
1682 .get("helper_value")
1683 .expect("helper_value")
1684 .display
1685 .clone()
1686 .expect("display"),
1687 "42"
1688 );
1689 let line = response
1690 .results
1691 .get("line_total")
1692 .expect("line_total")
1693 .display
1694 .clone()
1695 .expect("display");
1696 assert_eq!(line, "10");
1697 assert_eq!(
1698 response
1699 .results
1700 .get("formatted")
1701 .expect("formatted")
1702 .display
1703 .clone()
1704 .expect("display"),
1705 "42"
1706 );
1707 }
1708
1709 #[test]
1710 fn load_empty_labeled_source_is_error() {
1711 let mut engine = Engine::new();
1712 let err = engine
1713 .load(
1714 "spec x\ndata a: 1",
1715 SourceType::Path(Arc::new(std::path::PathBuf::from(" "))),
1716 )
1717 .unwrap_err();
1718 assert!(err.errors.iter().any(|e| e.message().contains("non-empty")));
1719 }
1720
1721 #[test]
1722 fn add_dependency_files_accepts_registry_bundle_specs() {
1723 let mut engine = Engine::new();
1724 engine
1725 .load_batch(
1726 HashMap::from([(
1727 SourceType::Volatile,
1728 "repo @org/my\nspec helper\ndata x: 1".to_string(),
1729 )]),
1730 Some("@org/my"),
1731 )
1732 .expect("dependency bundle specs should be accepted");
1733 }
1734
1735 #[test]
1736 fn user_load_rejects_reserved_embedded_stdlib_repository() {
1737 let mut engine = Engine::new();
1738 let batch = engine.load_batch(
1739 HashMap::from([(
1740 SourceType::Volatile,
1741 "spec finance\ndata money: ratio -> decimals 2".to_string(),
1742 )]),
1743 Some(EMBEDDED_STDLIB_REPOSITORY),
1744 );
1745 assert!(
1746 batch.is_err(),
1747 "load_batch must not write reserved lemma stdlib repo"
1748 );
1749 let msg = batch
1750 .unwrap_err()
1751 .errors
1752 .iter()
1753 .map(ToString::to_string)
1754 .collect::<Vec<_>>()
1755 .join("\n");
1756 assert!(
1757 msg.contains(EMBEDDED_STDLIB_REPOSITORY) && msg.contains("reserved"),
1758 "expected reserved-repo error, got: {msg}"
1759 );
1760
1761 let workspace = engine.load("repo lemma\nspec x\ndata a: 1", SourceType::Volatile);
1762 assert!(workspace.is_err(), "workspace repo lemma must be rejected");
1763 let msg = workspace
1764 .unwrap_err()
1765 .errors
1766 .iter()
1767 .map(ToString::to_string)
1768 .collect::<Vec<_>>()
1769 .join("\n");
1770 assert!(
1771 msg.contains(EMBEDDED_STDLIB_REPOSITORY) && msg.contains("reserved"),
1772 "expected reserved-repo error, got: {msg}"
1773 );
1774 }
1775
1776 #[test]
1777 fn dependency_cannot_merge_with_workspace_repo() {
1778 let mut engine = Engine::new();
1779 engine
1780 .load(
1781 "repo billing\nspec local_billing\ndata x: 1",
1782 SourceType::Path(Arc::new(std::path::PathBuf::from("local.lemma"))),
1783 )
1784 .expect("workspace load");
1785
1786 let result = engine.load_batch(
1787 HashMap::from([(
1788 SourceType::Volatile,
1789 "repo billing\nspec dep_billing\ndata y: 2".to_string(),
1790 )]),
1791 Some("@evil/pkg"),
1792 );
1793 assert!(
1794 result.is_err(),
1795 "dependency declaring same repo name as workspace must be rejected"
1796 );
1797 let msg = result
1798 .unwrap_err()
1799 .errors
1800 .iter()
1801 .map(ToString::to_string)
1802 .collect::<Vec<_>>()
1803 .join("\n");
1804 assert!(
1805 msg.contains("billing") && msg.contains("workspace"),
1806 "error should mention repo name and workspace provenance, got: {msg}"
1807 );
1808 }
1809
1810 #[test]
1811 fn load_rejects_empty_registry_source_identifier() {
1812 let mut engine = Engine::new();
1813 let result = engine.load(
1814 "spec helper\ndata x: 1",
1815 SourceType::Registry(Arc::new(LemmaRepository::new(Some("".to_string())))),
1816 );
1817 assert!(
1818 result.is_err(),
1819 "empty registry dependency source identifier must be rejected"
1820 );
1821 }
1822
1823 #[test]
1824 fn load_dependency_accepts_split_bundles() {
1825 let mut engine = Engine::new();
1826 engine
1827 .load_batch(
1828 HashMap::from([(
1829 SourceType::Volatile,
1830 "repo @org/rates\nspec rates\ndata rate: 10".to_string(),
1831 )]),
1832 Some("@org/rates"),
1833 )
1834 .expect("rates bundle should load");
1835 engine
1836 .load_batch(
1837 HashMap::from([(
1838 SourceType::Volatile,
1839 "repo @org/billing\nspec billing\nuses @org/rates rates".to_string(),
1840 )]),
1841 Some("@org/billing"),
1842 )
1843 .expect("billing bundle should load");
1844 }
1845
1846 #[test]
1847 fn load_returns_all_errors_not_just_first() {
1848 let mut engine = Engine::new();
1849
1850 let result = engine.load(
1851 r#"spec demo
1852uses type_src: nonexistent_type_source
1853with type_src.amount: 10
1854uses helper: nonexistent_spec
1855data price: 10
1856rule total: helper.value + price"#,
1857 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1858 );
1859
1860 assert!(result.is_err(), "Should fail with multiple errors");
1861 let load_err = result.unwrap_err();
1862 assert!(
1863 load_err.errors.len() >= 2,
1864 "expected at least 2 errors (type + spec ref), got {}",
1865 load_err.errors.len()
1866 );
1867 let error_message = load_err
1868 .errors
1869 .iter()
1870 .map(ToString::to_string)
1871 .collect::<Vec<_>>()
1872 .join("; ");
1873
1874 assert!(
1875 error_message.contains("nonexistent_type_source"),
1876 "Should mention data import source spec. Got:\n{}",
1877 error_message
1878 );
1879 assert!(
1880 error_message.contains("nonexistent_spec"),
1881 "Should mention spec reference error about 'nonexistent_spec'. Got:\n{}",
1882 error_message
1883 );
1884 }
1885
1886 #[test]
1892 fn planning_rejects_invalid_number_default() {
1893 let mut engine = Engine::new();
1894 let result = engine.load(
1895 "spec t\ndata x: number -> default \"10 $$\"]\nrule r: x",
1896 SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
1897 );
1898 assert!(
1899 result.is_err(),
1900 "must reject non-numeric default on number type"
1901 );
1902 }
1903
1904 #[test]
1905 fn planning_rejects_text_literal_as_number_default() {
1906 let mut engine = Engine::new();
1911 let result = engine.load(
1912 "spec t\ndata x: number -> default \"10\"]\nrule r: x",
1913 SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
1914 );
1915 assert!(
1916 result.is_err(),
1917 "must reject text literal \"10\" as default for number type"
1918 );
1919 }
1920
1921 #[test]
1922 fn planning_rejects_invalid_boolean_default() {
1923 let mut engine = Engine::new();
1924 let result = engine.load(
1925 "spec t\ndata x: [boolean -> default \"maybe\"]\nrule r: x",
1926 SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
1927 );
1928 assert!(
1929 result.is_err(),
1930 "must reject non-boolean default on boolean type"
1931 );
1932 }
1933
1934 #[test]
1935 fn planning_rejects_invalid_named_type_default() {
1936 let mut engine = Engine::new();
1938 let result = engine.load("spec t\ndata custom: number -> minimum 0\ndata x: [custom -> default \"abc\"]\nrule r: x", SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))));
1939 assert!(
1940 result.is_err(),
1941 "must reject non-numeric default on named number type"
1942 );
1943 }
1944
1945 #[test]
1946 fn context_merges_cross_file_repo_identities() {
1947 let mut engine = Engine::new();
1948
1949 engine
1951 .load(
1952 "repo shared\nspec a\ndata x: 1",
1953 SourceType::Path(Arc::new(std::path::PathBuf::from("file1.lemma"))),
1954 )
1955 .expect("first file should load");
1956
1957 engine
1958 .load(
1959 "repo shared\nspec b\ndata y: 2",
1960 SourceType::Path(Arc::new(std::path::PathBuf::from("file2.lemma"))),
1961 )
1962 .expect("second file should load");
1963
1964 assert_eq!(
1967 engine.specs.repositories().len(),
1968 3,
1969 "should have workspace, stdlib repository, and one named user repository"
1970 );
1971
1972 let shared_repo = engine
1973 .specs
1974 .find_repository("shared")
1975 .expect("shared repo should exist");
1976 let shared_specs = engine.specs.repositories().get(&shared_repo).unwrap();
1977 assert_eq!(
1978 shared_specs.len(),
1979 2,
1980 "shared repo should contain both specs"
1981 );
1982 assert!(shared_specs.contains_key("a"));
1983 assert!(shared_specs.contains_key("b"));
1984
1985 let result = engine.load_batch(
1987 HashMap::from([(
1988 SourceType::Volatile,
1989 "repo shared\nspec c\ndata z: 3".to_string(),
1990 )]),
1991 Some("@some/dep"),
1992 );
1993 assert!(
1994 result.is_err(),
1995 "dependency repo with same name as workspace repo must be rejected"
1996 );
1997 }
1998
1999 #[test]
2000 fn context_rejects_duplicate_spec_in_same_repo_across_files() {
2001 let mut engine = Engine::new();
2002
2003 engine
2004 .load(
2005 "repo shared\nspec a\ndata x: 1",
2006 SourceType::Path(Arc::new(std::path::PathBuf::from("file1.lemma"))),
2007 )
2008 .expect("first file should load");
2009
2010 let result = engine.load(
2011 "repo shared\nspec a\ndata y: 2",
2012 SourceType::Path(Arc::new(std::path::PathBuf::from("file2.lemma"))),
2013 );
2014
2015 assert!(
2016 result.is_err(),
2017 "should reject duplicate spec name in same repo"
2018 );
2019 let err_msg = result.unwrap_err().errors[0].to_string();
2020 assert!(
2021 err_msg.contains("Duplicate spec 'a'"),
2022 "error should mention duplicate spec"
2023 );
2024 }
2025
2026 #[test]
2027 fn test_list_serialization() {
2028 let mut engine = Engine::new();
2029 engine
2030 .load(
2031 "repo shared\nspec a\ndata x: 1\nrule r: x",
2032 SourceType::Path(Arc::new(std::path::PathBuf::from("file1.lemma"))),
2033 )
2034 .expect("file should load");
2035
2036 let repos = engine.list();
2037 let json = serde_json::to_string(&repos).expect("should serialize");
2038
2039 assert!(json.contains("\"repository\""));
2041 assert!(json.contains("\"name\":\"shared\""));
2042 assert!(json.contains("\"specs\""));
2043 assert!(json.contains("\"name\":\"a\""));
2044 assert!(json.contains("\"data\""));
2045 assert!(json.contains("\"rules\""));
2046 }
2047
2048 #[test]
2049 fn out_of_memory_during_exact_multiply_vetoes_without_crashing() {
2050 use crate::computation::bigint::{test_clear_alloc_fail, test_force_alloc_fail};
2051
2052 let code = r#"
2053spec oom_multiply
2054data a: 9999999999999999999999999999
2055data b: 9999999999999999999999999999
2056rule huge: a * b
2057"#;
2058
2059 let mut engine = Engine::new();
2060 engine
2061 .load(code, SourceType::Volatile)
2062 .expect("load must succeed");
2063
2064 test_force_alloc_fail(50);
2065
2066 let now = DateTimeValue::now();
2067 let response = engine
2068 .run(
2069 None,
2070 "oom_multiply",
2071 Some(&now),
2072 HashMap::new(),
2073 false,
2074 None,
2075 )
2076 .expect("evaluation must complete without process crash");
2077
2078 test_clear_alloc_fail();
2079
2080 let rule = response
2081 .results
2082 .get("huge")
2083 .expect("huge rule must be present");
2084
2085 assert!(rule.vetoed);
2086 assert_eq!(rule.veto_reason.as_deref(), Some("out of memory"));
2087 }
2088}