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 self.plan_sets
394 .entry(Arc::clone(&r.repository))
395 .or_default()
396 .insert(r.name.clone(), r.execution_plan_set());
397 }
398 }
399
400 pub fn load(&mut self, code: impl Into<String>, source: SourceType) -> Result<(), Errors> {
402 self.load_batch(HashMap::from([(source, code.into())]), None)
403 }
404
405 pub fn load_batch(
410 &mut self,
411 sources: HashMap<SourceType, String>,
412 dependency: Option<&str>,
413 ) -> Result<(), Errors> {
414 self.add_sources_inner(sources, dependency, false)
415 }
416
417 fn validate_source_for_load(source: &SourceType) -> Result<(), Errors> {
418 match source {
419 SourceType::Path(p) if p.as_os_str().to_string_lossy().trim().is_empty() => {
420 Err(Errors {
421 errors: vec![Error::request(
422 "Source path must be non-empty",
423 None::<String>,
424 )],
425 sources: HashMap::new(),
426 })
427 }
428 SourceType::Registry(repo) => {
429 if repo.name.as_deref().unwrap_or("").is_empty() {
430 Err(Errors {
431 errors: vec![Error::request(
432 "Registry source identifier must be non-empty",
433 None::<String>,
434 )],
435 sources: HashMap::new(),
436 })
437 } else {
438 Ok(())
439 }
440 }
441 _ => Ok(()),
442 }
443 }
444
445 fn reject_reserved_stdlib_repository(repository: &Arc<LemmaRepository>) -> Option<Error> {
446 if repository.name.as_deref() == Some(EMBEDDED_STDLIB_REPOSITORY) {
447 Some(Error::validation_with_context(
448 format!(
449 "Repository '{EMBEDDED_STDLIB_REPOSITORY}' is reserved for the embedded standard library and cannot be loaded via load or load_batch; use '@lemma/std' for registry packages such as finance"
450 ),
451 None,
452 Some("Load registry dependencies as '@lemma/std', not the reserved 'lemma' stdlib repository".to_string()),
453 None,
454 None,
455 ))
456 } else {
457 None
458 }
459 }
460
461 fn add_sources_inner(
462 &mut self,
463 sources: HashMap<SourceType, String>,
464 dependency: Option<&str>,
465 embedded_stdlib: bool,
466 ) -> Result<(), Errors> {
467 for st in sources.keys() {
468 Self::validate_source_for_load(st)?;
469 }
470 if !embedded_stdlib {
471 let limits = &self.limits;
472 if sources.len() > limits.max_sources {
473 return Err(Errors {
474 errors: vec![Error::resource_limit_exceeded(
475 "max_sources",
476 limits.max_sources.to_string(),
477 sources.len().to_string(),
478 "Reduce the number of paths or sources in one load",
479 None::<crate::parsing::source::Source>,
480 None,
481 None,
482 )],
483 sources,
484 });
485 }
486 let total_loaded_bytes: usize = sources.values().map(|s| s.len()).sum();
487 if total_loaded_bytes > limits.max_loaded_bytes {
488 return Err(Errors {
489 errors: vec![Error::resource_limit_exceeded(
490 "max_loaded_bytes",
491 limits.max_loaded_bytes.to_string(),
492 total_loaded_bytes.to_string(),
493 "Load fewer or smaller sources",
494 None::<crate::parsing::source::Source>,
495 None,
496 None,
497 )],
498 sources,
499 });
500 }
501 for code in sources.values() {
502 if code.len() > limits.max_source_size_bytes {
503 return Err(Errors {
504 errors: vec![Error::resource_limit_exceeded(
505 "max_source_size_bytes",
506 limits.max_source_size_bytes.to_string(),
507 code.len().to_string(),
508 "Use a smaller source text or increase limit",
509 None::<crate::parsing::source::Source>,
510 None,
511 None,
512 )],
513 sources,
514 });
515 }
516 }
517 }
518
519 let parse_limits = if embedded_stdlib {
520 &ResourceLimits::default()
521 } else {
522 &self.limits
523 };
524 let mut errors: Vec<Error> = Vec::new();
525
526 for (source_id, code) in &sources {
527 match parse(code, source_id.clone(), parse_limits) {
528 Ok(result) => {
529 if !embedded_stdlib {
530 self.total_expression_count += result.expression_count;
531 if self.total_expression_count > self.limits.max_total_expression_count {
532 errors.push(Error::resource_limit_exceeded(
533 "max_total_expression_count",
534 self.limits.max_total_expression_count.to_string(),
535 self.total_expression_count.to_string(),
536 "Split logic across fewer sources or reduce expression complexity",
537 None::<crate::parsing::source::Source>,
538 None,
539 None,
540 ));
541 return Err(Errors { errors, sources });
542 }
543 }
544 if result.repositories.is_empty() {
545 continue;
546 }
547
548 for (parsed_repo, specs) in &result.repositories {
549 let repository_arc = if let Some(dep_id) = dependency {
550 let repo_name = parsed_repo
551 .name
552 .clone()
553 .or_else(|| Some(dep_id.to_string()));
555 Arc::new(
556 LemmaRepository::new(repo_name)
557 .with_dependency(dep_id)
558 .with_start_line(parsed_repo.start_line),
559 )
560 } else {
561 Arc::clone(parsed_repo)
562 };
563 if !embedded_stdlib {
564 if let Some(reserved_err) =
565 Self::reject_reserved_stdlib_repository(&repository_arc)
566 {
567 let source = crate::parsing::source::Source::new(
568 source_id.clone(),
569 crate::parsing::ast::Span {
570 start: 0,
571 end: 0,
572 line: parsed_repo.start_line,
573 col: 0,
574 },
575 );
576 errors.push(Error::validation(
577 reserved_err.to_string(),
578 Some(source),
579 reserved_err.suggestion().map(str::to_string),
580 ));
581 continue;
582 }
583 }
584 for spec in specs {
585 match self
586 .specs
587 .insert_spec(Arc::clone(&repository_arc), Arc::new(spec.clone()))
588 {
589 Ok(()) => {}
590 Err(e) => {
591 let source = crate::parsing::source::Source::new(
592 source_id.clone(),
593 crate::parsing::ast::Span {
594 start: 0,
595 end: 0,
596 line: spec.start_line,
597 col: 0,
598 },
599 );
600 errors.push(Error::validation(
601 e.to_string(),
602 Some(source),
603 None::<String>,
604 ));
605 }
606 }
607 }
608 }
609 }
610 Err(e) => errors.push(e),
611 }
612 }
613
614 let planning_result = crate::planning::plan(&self.specs, &self.limits);
615 for set_result in &planning_result.results {
616 for spec_result in &set_result.slice_results {
617 let ctx = Arc::clone(&spec_result.spec);
618 for err in &spec_result.errors {
619 errors.push(err.clone().with_spec_context(Arc::clone(&ctx)));
620 }
621 }
622 }
623 self.apply_planning_result(planning_result);
624
625 if errors.is_empty() {
626 Ok(())
627 } else {
628 Err(Errors { errors, sources })
629 }
630 }
631
632 pub fn get_spec(
639 &self,
640 name: &str,
641 effective: Option<&DateTimeValue>,
642 ) -> Result<Arc<LemmaSpec>, Error> {
643 let effective_dt = self.effective_or_now(effective);
644 let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
645 let repository = self.specs.workspace();
646 let spec_set = self
647 .specs
648 .spec_set(&repository, name)
649 .ok_or_else(|| self.spec_not_found_error(name, &effective_dt))?;
650 spec_set
651 .spec_at(&instant)
652 .ok_or_else(|| self.spec_not_found_error(name, &effective_dt))
653 }
654
655 #[must_use]
661 pub fn list(&self) -> Vec<ResolvedRepository> {
662 self.specs
663 .repositories()
664 .iter()
665 .map(|(repo, inner)| ResolvedRepository {
666 repository: Arc::clone(repo),
667 specs: inner.values().cloned().collect(),
668 })
669 .collect()
670 }
671
672 #[must_use]
674 pub fn get_workspace(&self) -> ResolvedRepository {
675 let repo = self.specs.workspace();
676 let specs = self.specs.spec_sets_for(&repo);
677 ResolvedRepository {
678 repository: repo,
679 specs,
680 }
681 }
682
683 pub fn get_repository(&self, qualifier: &str) -> Result<ResolvedRepository, Error> {
686 let q = qualifier.trim();
687 if q.is_empty() {
688 return Err(Error::request(
689 "Repository qualifier cannot be empty",
690 None::<String>,
691 ));
692 }
693 match self.specs.find_repository(q) {
694 Some(repo) => {
695 let specs = self.specs.spec_sets_for(&repo);
696 Ok(ResolvedRepository {
697 repository: repo,
698 specs,
699 })
700 }
701 None => Err(Error::request_not_found(
702 format!("Repository '{qualifier}' not loaded"),
703 Some(format!(
704 "List repositories with `{}` after loading your workspace",
705 "lemma list"
706 )),
707 )),
708 }
709 }
710
711 pub fn format_repository(&self, repository: &str) -> Result<String, Error> {
713 let resolved = self.get_repository(repository)?;
714 let mut specs: Vec<Arc<LemmaSpec>> = resolved
715 .specs
716 .iter()
717 .flat_map(|ss| ss.iter_specs())
718 .collect();
719 specs.sort_by(|a, b| {
720 a.name
721 .cmp(&b.name)
722 .then_with(|| a.effective_from.cmp(&b.effective_from))
723 });
724 let spec_refs: Vec<&LemmaSpec> = specs.iter().map(AsRef::as_ref).collect();
725 let body = crate::formatting::format_spec_refs(&spec_refs);
726 let mut out = String::new();
727 if let Some(name) = resolved.repository.name.as_deref() {
728 out.push_str("repo ");
729 out.push_str(name);
730 out.push_str("\n\n");
731 }
732 out.push_str(&body);
733 Ok(out)
734 }
735
736 pub fn schema(
740 &self,
741 repo: Option<&str>,
742 spec: &str,
743 effective: Option<&DateTimeValue>,
744 ) -> Result<SpecSchema, Error> {
745 Ok(self
746 .get_plan(repo, spec, effective)?
747 .schema(&DataOverlay::default()))
748 }
749
750 pub fn limits(&self) -> &ResourceLimits {
754 &self.limits
755 }
756
757 pub fn run(
769 &self,
770 repo: Option<&str>,
771 spec: &str,
772 effective: Option<&DateTimeValue>,
773 data_values: HashMap<String, String>,
774 explain: bool,
775 rules: Option<&[String]>,
776 ) -> Result<Response, Error> {
777 let effective = self.effective_or_now(effective);
778 let plan = self.get_plan(repo, spec, Some(&effective))?;
779 let data_values: HashMap<String, DataValueInput> = data_values
780 .into_iter()
781 .map(|(k, v)| (k, DataValueInput::convenience(v)))
782 .collect();
783 self.run_plan(plan, Some(&effective), data_values, explain, rules)
784 }
785
786 pub fn get_plan(
790 &self,
791 repo: Option<&str>,
792 name: &str,
793 effective: Option<&DateTimeValue>,
794 ) -> Result<&crate::planning::ExecutionPlan, Error> {
795 let effective_dt = self.effective_or_now(effective);
796 let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
797 let canonical_name = crate::parsing::ast::ascii_lowercase_logical_name(name.to_string());
798
799 let repository = match repo {
800 Some(q) => self.specs.find_repository(q).ok_or_else(|| {
801 Error::request_not_found(
802 format!("Repository '{q}' not loaded"),
803 Some("List repositories with `lemma list` after loading your workspace"),
804 )
805 })?,
806 None => self.specs.workspace(),
807 };
808
809 let Some(spec_set) = self.specs.spec_set(&repository, &canonical_name) else {
810 return Err(self.spec_not_found_in_repository_error(&repository, name, &effective_dt));
811 };
812
813 if spec_set.spec_at(&instant).is_none() {
814 return Err(self.spec_not_found_in_repository_error(&repository, name, &effective_dt));
815 }
816
817 let plan_set = self
818 .plan_sets
819 .get(&repository)
820 .and_then(|by_name| by_name.get(&canonical_name))
821 .ok_or_else(|| {
822 Error::request_not_found(
823 format!("No execution plans for spec '{name}'"),
824 Some("Ensure sources loaded and planning succeeded"),
825 )
826 })?;
827
828 plan_set.plan_at(&instant).ok_or_else(|| {
829 Error::request_not_found(
830 format!("No execution plan slice for spec '{name}' at effective {effective_dt}"),
831 None::<String>,
832 )
833 })
834 }
835
836 pub fn run_plan(
849 &self,
850 plan: &crate::planning::ExecutionPlan,
851 effective: Option<&DateTimeValue>,
852 data_values: HashMap<String, DataValueInput>,
853 explain: bool,
854 rules: Option<&[String]>,
855 ) -> Result<Response, Error> {
856 let response_rules = plan.validated_response_rule_names(rules)?;
857 let effective = self.effective_or_now(effective);
858 let overlay = DataOverlay::resolve(plan, data_values, &self.limits)?;
859 crate::planning::execution_plan::validate_unit_index_references(plan)?;
860 let now_semantic = crate::planning::semantics::date_time_to_semantic(&effective);
861 let now_literal = crate::planning::semantics::LiteralValue {
862 value: crate::planning::semantics::ValueKind::Date(now_semantic),
863 lemma_type: crate::planning::semantics::primitive_date_arc().clone(),
864 };
865 let (mut response, mut context) =
866 self.evaluator
867 .evaluate(plan, &overlay, now_literal, explain, &response_rules);
868 if explain {
869 self.evaluator.explain(&mut response, plan, &mut context);
870 }
871 Ok(response)
872 }
873
874 pub fn remove(&mut self, name: &str, effective: Option<&DateTimeValue>) -> Result<(), Error> {
875 let effective = self.effective_or_now(effective);
876 let repository_arc = self.specs.workspace();
877 let spec_arc = self.get_spec(name, Some(&effective))?;
878 self.specs.remove_spec(&repository_arc, &spec_arc);
879 let pr = crate::planning::plan(&self.specs, &self.limits);
880 let planning_errs: Vec<Error> = pr
881 .results
882 .iter()
883 .flat_map(|r| r.errors().cloned())
884 .collect();
885 self.apply_planning_result(pr);
886 if let Some(e) = planning_errs.into_iter().next() {
887 return Err(e);
888 }
889 Ok(())
890 }
891
892 fn spec_not_found_error(&self, spec_name: &str, effective: &DateTimeValue) -> Error {
895 let workspace = self.specs.workspace();
896 let available = match self.specs.spec_set(&workspace, spec_name) {
897 Some(ss) => ss.iter_specs().collect::<Vec<_>>(),
898 None => Vec::new(),
899 };
900 let msg = if available.is_empty() {
901 format!("Spec '{}' not found", spec_name)
902 } else {
903 let listing: Vec<String> = available
904 .iter()
905 .map(|s| match s.effective_from() {
906 Some(dt) => format!(" {} (effective from {})", s.name, dt),
907 None => format!(" {} (no effective_from)", s.name),
908 })
909 .collect();
910 format!(
911 "Spec '{}' not found for effective {}. Available versions:\n{}",
912 spec_name,
913 effective,
914 listing.join("\n")
915 )
916 };
917 Error::request_not_found(msg, None::<String>)
918 }
919
920 fn spec_not_found_in_repository_error(
921 &self,
922 repository: &LemmaRepository,
923 spec_name: &str,
924 effective: &DateTimeValue,
925 ) -> Error {
926 let repo_label = match &repository.name {
927 Some(n) => n.clone(),
928 None => "(workspace)".to_string(),
929 };
930 Error::request_not_found(
931 format!(
932 "Spec '{spec_name}' not found in repository {repo_label} at effective {effective}",
933 ),
934 Some("Try `lemma list`"),
935 )
936 }
937
938 #[must_use]
940 fn effective_or_now(&self, effective: Option<&DateTimeValue>) -> DateTimeValue {
941 effective.cloned().unwrap_or_else(DateTimeValue::now)
942 }
943}
944
945#[cfg(test)]
946mod tests {
947 use super::*;
948
949 fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
950 DateTimeValue {
951 year,
952 month,
953 day,
954 hour: 0,
955 minute: 0,
956 second: 0,
957 microsecond: 0,
958 timezone: None,
959 granularity: crate::literals::DateGranularity::Full,
960 }
961 }
962
963 fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
964 let mut spec = LemmaSpec::new(name.to_string());
965 spec.effective_from = crate::parsing::ast::EffectiveDate::from_option(effective_from);
966 spec
967 }
968
969 #[test]
972 fn list_specs_order_is_name_then_effective_from_ascending() {
973 let mut ctx = Context::new();
974 let repository = ctx.workspace();
975 let s_2026 = Arc::new(make_spec_with_range("mortgage", Some(date(2026, 1, 1))));
976 let s_2025 = Arc::new(make_spec_with_range("mortgage", Some(date(2025, 1, 1))));
977 ctx.insert_spec(Arc::clone(&repository), Arc::clone(&s_2026))
978 .unwrap();
979 ctx.insert_spec(Arc::clone(&repository), Arc::clone(&s_2025))
980 .unwrap();
981 let listed: Vec<_> = ctx
982 .spec_set(&repository, "mortgage")
983 .expect("mortgage set")
984 .iter_specs()
985 .collect();
986 assert_eq!(listed.len(), 2);
987 assert_eq!(listed[0].effective_from(), Some(&date(2025, 1, 1)));
988 assert_eq!(listed[1].effective_from(), Some(&date(2026, 1, 1)));
989 }
990
991 #[test]
992 fn get_spec_resolves_temporal_version_by_effective() {
993 let mut engine = Engine::new();
994 engine
995 .load(
996 r#"
997 spec pricing 2025-01-01
998 data x: 1
999 rule r: x
1000 "#,
1001 SourceType::Path(Arc::new(std::path::PathBuf::from("a.lemma"))),
1002 )
1003 .unwrap();
1004 engine
1005 .load(
1006 r#"
1007 spec pricing 2025-06-01
1008 data x: 2
1009 rule r: x
1010 "#,
1011 SourceType::Path(Arc::new(std::path::PathBuf::from("b.lemma"))),
1012 )
1013 .unwrap();
1014
1015 let jan = DateTimeValue {
1016 year: 2025,
1017 month: 1,
1018 day: 15,
1019 hour: 0,
1020 minute: 0,
1021 second: 0,
1022 microsecond: 0,
1023 timezone: None,
1024 granularity: crate::literals::DateGranularity::Full,
1025 };
1026 let jul = DateTimeValue {
1027 year: 2025,
1028 month: 7,
1029 day: 1,
1030 hour: 0,
1031 minute: 0,
1032 second: 0,
1033 microsecond: 0,
1034 timezone: None,
1035 granularity: crate::literals::DateGranularity::Full,
1036 };
1037
1038 let v1 = DateTimeValue {
1039 year: 2025,
1040 month: 1,
1041 day: 1,
1042 hour: 0,
1043 minute: 0,
1044 second: 0,
1045 microsecond: 0,
1046 timezone: None,
1047 granularity: crate::literals::DateGranularity::Full,
1048 };
1049 let v2 = DateTimeValue {
1050 year: 2025,
1051 month: 6,
1052 day: 1,
1053 hour: 0,
1054 minute: 0,
1055 second: 0,
1056 microsecond: 0,
1057 timezone: None,
1058 granularity: crate::literals::DateGranularity::Full,
1059 };
1060
1061 let s_jan = engine.get_spec("pricing", Some(&jan)).expect("jan spec");
1062 let s_jul = engine.get_spec("pricing", Some(&jul)).expect("jul spec");
1063 assert_eq!(s_jan.effective_from(), Some(&v1));
1064 assert_eq!(s_jul.effective_from(), Some(&v2));
1065 }
1066
1067 #[test]
1072 fn list_specs_returns_half_open_ranges_per_temporal_version() {
1073 let mut engine = Engine::new();
1074 engine
1075 .load(
1076 r#"
1077 spec pricing 2025-01-01
1078 data x: 1
1079 rule r: x
1080 "#,
1081 SourceType::Path(Arc::new(std::path::PathBuf::from("a.lemma"))),
1082 )
1083 .unwrap();
1084 engine
1085 .load(
1086 r#"
1087 spec pricing 2025-06-01
1088 data x: 2
1089 rule r: x
1090 "#,
1091 SourceType::Path(Arc::new(std::path::PathBuf::from("b.lemma"))),
1092 )
1093 .unwrap();
1094
1095 let january = date(2025, 1, 1);
1096 let june = date(2025, 6, 1);
1097
1098 let workspace = engine.get_workspace();
1099 let pricing_set = workspace
1100 .specs
1101 .iter()
1102 .find(|ss| ss.name == "pricing")
1103 .expect("pricing spec set exists");
1104 let mut ranges: Vec<(Option<DateTimeValue>, Option<DateTimeValue>)> = pricing_set
1105 .iter_with_ranges()
1106 .map(|(_, from, to)| (from, to))
1107 .collect();
1108 ranges.sort_by(|a, b| match (&a.0, &b.0) {
1109 (Some(x), Some(y)) => x.cmp(y),
1110 (None, Some(_)) => std::cmp::Ordering::Less,
1111 (Some(_), None) => std::cmp::Ordering::Greater,
1112 (None, None) => std::cmp::Ordering::Equal,
1113 });
1114 assert_eq!(ranges.len(), 2);
1115 assert_eq!(
1116 ranges[0],
1117 (Some(january.clone()), Some(june.clone())),
1118 "earlier row ends at the next row's effective_from"
1119 );
1120 assert_eq!(
1121 ranges[1],
1122 (Some(june.clone()), None),
1123 "latest row has no successor; effective_to is None"
1124 );
1125
1126 assert!(
1127 !engine
1128 .get_workspace()
1129 .specs
1130 .iter()
1131 .any(|ss| ss.name == "unknown"),
1132 "no rows for unknown spec"
1133 );
1134 }
1135
1136 #[test]
1139 fn get_workspace_specs_with_half_open_ranges() {
1140 let mut engine = Engine::new();
1141 engine
1142 .load(
1143 r#"
1144 spec pricing 2025-01-01
1145 data x: 1
1146 rule r: x
1147 "#,
1148 SourceType::Path(Arc::new(std::path::PathBuf::from("pricing_v1.lemma"))),
1149 )
1150 .unwrap();
1151 engine
1152 .load(
1153 r#"
1154 spec pricing 2026-01-01
1155 data x: 2
1156 rule r: x
1157 "#,
1158 SourceType::Path(Arc::new(std::path::PathBuf::from("pricing_v2.lemma"))),
1159 )
1160 .unwrap();
1161 engine
1162 .load(
1163 r#"
1164 spec taxes
1165 data rate: 0.21
1166 rule amount: rate
1167 "#,
1168 SourceType::Path(Arc::new(std::path::PathBuf::from("taxes.lemma"))),
1169 )
1170 .unwrap();
1171
1172 let workspace = engine.get_workspace();
1173 assert_eq!(workspace.specs.len(), 2, "two spec sets: pricing and taxes");
1174
1175 let pricing_set = workspace
1176 .specs
1177 .iter()
1178 .find(|ss| ss.name == "pricing")
1179 .expect("pricing spec set exists");
1180 let ranges: Vec<_> = pricing_set.iter_with_ranges().collect();
1181 assert_eq!(ranges.len(), 2);
1182 assert_eq!(ranges[0].1, Some(date(2025, 1, 1)));
1183 assert_eq!(
1184 ranges[0].2,
1185 Some(date(2026, 1, 1)),
1186 "earlier pricing row ends at the next pricing row's effective_from"
1187 );
1188 assert_eq!(ranges[1].1, Some(date(2026, 1, 1)));
1189 assert_eq!(
1190 ranges[1].2, None,
1191 "latest pricing row has no successor; effective_to is None"
1192 );
1193
1194 let taxes_set = workspace
1195 .specs
1196 .iter()
1197 .find(|ss| ss.name == "taxes")
1198 .expect("taxes spec set exists");
1199 let tax_ranges: Vec<_> = taxes_set.iter_with_ranges().collect();
1200 assert_eq!(tax_ranges.len(), 1);
1201 assert_eq!(
1202 tax_ranges[0].1, None,
1203 "unversioned spec has no declared effective_from"
1204 );
1205 assert_eq!(
1206 tax_ranges[0].2, None,
1207 "unversioned spec has no successor; effective_to is None"
1208 );
1209 }
1210
1211 #[test]
1212 fn test_evaluate_spec_all_rules() {
1213 let mut engine = Engine::new();
1214 engine
1215 .load(
1216 r#"
1217 spec test
1218 data x: 10
1219 data y: 5
1220 rule sum: x + y
1221 rule product: x * y
1222 "#,
1223 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1224 )
1225 .unwrap();
1226
1227 let now = DateTimeValue::now();
1228 let response = engine
1229 .run(None, "test", Some(&now), HashMap::new(), false, None)
1230 .unwrap();
1231 assert_eq!(response.results.len(), 2);
1232
1233 let sum_result = response
1234 .results
1235 .values()
1236 .find(|r| r.rule.name == "sum")
1237 .unwrap();
1238 assert_eq!(sum_result.display.clone().expect("display"), "15");
1239
1240 let product_result = response
1241 .results
1242 .values()
1243 .find(|r| r.rule.name == "product")
1244 .unwrap();
1245 assert_eq!(product_result.display.clone().expect("display"), "50");
1246 }
1247
1248 #[test]
1249 fn test_evaluate_empty_data() {
1250 let mut engine = Engine::new();
1251 engine
1252 .load(
1253 r#"
1254 spec test
1255 data price: 100
1256 rule total: price * 2
1257 "#,
1258 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1259 )
1260 .unwrap();
1261
1262 let now = DateTimeValue::now();
1263 let response = engine
1264 .run(None, "test", Some(&now), HashMap::new(), false, None)
1265 .unwrap();
1266 assert_eq!(response.results.len(), 1);
1267 assert_eq!(
1268 response
1269 .results
1270 .values()
1271 .next()
1272 .unwrap()
1273 .display
1274 .clone()
1275 .expect("display"),
1276 "200"
1277 );
1278 }
1279
1280 #[test]
1281 fn test_evaluate_boolean_rule() {
1282 let mut engine = Engine::new();
1283 engine
1284 .load(
1285 r#"
1286 spec test
1287 data age: 25
1288 rule is_adult: age >= 18
1289 "#,
1290 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1291 )
1292 .unwrap();
1293
1294 let now = DateTimeValue::now();
1295 let response = engine
1296 .run(None, "test", Some(&now), HashMap::new(), false, None)
1297 .unwrap();
1298 assert_eq!(
1299 response.results.values().next().unwrap().boolean,
1300 Some(true)
1301 );
1302 }
1303
1304 #[test]
1305 fn test_evaluate_with_unless_clause() {
1306 let mut engine = Engine::new();
1307 engine
1308 .load(
1309 r#"
1310 spec test
1311 data quantity: 15
1312 rule discount: 0
1313 unless quantity >= 10 then 10
1314 "#,
1315 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1316 )
1317 .unwrap();
1318
1319 let now = DateTimeValue::now();
1320 let response = engine
1321 .run(None, "test", Some(&now), HashMap::new(), false, None)
1322 .unwrap();
1323 assert_eq!(
1324 response
1325 .results
1326 .values()
1327 .next()
1328 .unwrap()
1329 .display
1330 .clone()
1331 .expect("display"),
1332 "10"
1333 );
1334 }
1335
1336 #[test]
1337 fn test_spec_not_found() {
1338 let engine = Engine::new();
1339 let now = DateTimeValue::now();
1340 let result = engine.run(None, "nonexistent", Some(&now), HashMap::new(), false, None);
1341 assert!(result.is_err());
1342 assert!(result.unwrap_err().to_string().contains("not found"));
1343 }
1344
1345 #[test]
1346 fn test_multiple_specs() {
1347 let mut engine = Engine::new();
1348 engine
1349 .load(
1350 r#"
1351 spec spec1
1352 data x: 10
1353 rule result: x * 2
1354 "#,
1355 SourceType::Path(Arc::new(std::path::PathBuf::from("spec 1.lemma"))),
1356 )
1357 .unwrap();
1358
1359 engine
1360 .load(
1361 r#"
1362 spec spec2
1363 data y: 5
1364 rule result: y * 3
1365 "#,
1366 SourceType::Path(Arc::new(std::path::PathBuf::from("spec 2.lemma"))),
1367 )
1368 .unwrap();
1369
1370 let now = DateTimeValue::now();
1371 let response1 = engine
1372 .run(None, "spec1", Some(&now), HashMap::new(), false, None)
1373 .unwrap();
1374 assert_eq!(response1.results[0].display.clone().expect("display"), "20");
1375 let response2 = engine
1376 .run(None, "spec2", Some(&now), HashMap::new(), false, None)
1377 .unwrap();
1378 assert_eq!(response2.results[0].display.clone().expect("display"), "15");
1379 }
1380
1381 #[test]
1382 fn test_runtime_error_mapping() {
1383 let mut engine = Engine::new();
1384 engine
1385 .load(
1386 r#"
1387 spec test
1388 data numerator: 10
1389 data denominator: 0
1390 rule division: numerator / denominator
1391 "#,
1392 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1393 )
1394 .unwrap();
1395
1396 let now = DateTimeValue::now();
1397 let result = engine.run(None, "test", Some(&now), HashMap::new(), false, None);
1398 assert!(result.is_ok(), "Evaluation should succeed");
1400 let response = result.unwrap();
1401 let division_result = response
1402 .results
1403 .values()
1404 .find(|r| r.rule.name == "division");
1405 assert!(
1406 division_result.is_some(),
1407 "Should have division rule result"
1408 );
1409 let division = division_result.unwrap();
1410 assert!(division.vetoed);
1411 assert!(
1412 division
1413 .veto_reason
1414 .as_deref()
1415 .unwrap()
1416 .contains("Division by zero"),
1417 "Veto message should mention division by zero: {:?}",
1418 division.veto_reason
1419 );
1420 }
1421
1422 #[test]
1423 fn test_rules_sorted_by_source_order() {
1424 let mut engine = Engine::new();
1425 engine
1426 .load(
1427 r#"
1428 spec test
1429 data a: 1
1430 data b: 2
1431 rule z: a + b
1432 rule y: a * b
1433 rule x: a - b
1434 "#,
1435 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1436 )
1437 .unwrap();
1438
1439 let now = DateTimeValue::now();
1440 let response = engine
1441 .run(None, "test", Some(&now), HashMap::new(), false, None)
1442 .unwrap();
1443 assert_eq!(response.results.len(), 3);
1444
1445 let z_pos = response
1447 .results
1448 .values()
1449 .find(|r| r.rule.name == "z")
1450 .unwrap()
1451 .rule
1452 .source_location
1453 .span
1454 .start;
1455 let y_pos = response
1456 .results
1457 .values()
1458 .find(|r| r.rule.name == "y")
1459 .unwrap()
1460 .rule
1461 .source_location
1462 .span
1463 .start;
1464 let x_pos = response
1465 .results
1466 .values()
1467 .find(|r| r.rule.name == "x")
1468 .unwrap()
1469 .rule
1470 .source_location
1471 .span
1472 .start;
1473
1474 assert!(z_pos < y_pos);
1475 assert!(y_pos < x_pos);
1476 }
1477
1478 #[test]
1479 fn test_rule_filtering_evaluates_dependencies() {
1480 let mut engine = Engine::new();
1481 engine
1482 .load(
1483 r#"
1484 spec test
1485 data base: 100
1486 rule subtotal: base * 2
1487 rule tax: subtotal * 10%
1488 rule total: subtotal + tax
1489 "#,
1490 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1491 )
1492 .unwrap();
1493
1494 let now = DateTimeValue::now();
1495 let response = engine
1496 .run(
1497 None,
1498 "test",
1499 Some(&now),
1500 HashMap::new(),
1501 false,
1502 Some(&["total".to_string()]),
1503 )
1504 .unwrap();
1505
1506 assert_eq!(response.results.len(), 1);
1507 assert_eq!(response.results.keys().next().unwrap(), "total");
1508
1509 let total = response.results.values().next().unwrap();
1511 assert_eq!(total.display.clone().expect("display"), "220");
1512 }
1513
1514 use crate::parsing::ast::DateTimeValue;
1519
1520 #[test]
1521 fn pre_resolved_deps_in_file_map_evaluates_external_spec() {
1522 let mut engine = Engine::new();
1523
1524 engine
1525 .load_batch(
1526 HashMap::from([(
1527 SourceType::Volatile,
1528 "repo @org/project\nspec helper\ndata quantity: 42".to_string(),
1529 )]),
1530 Some("@org/project"),
1531 )
1532 .expect("should load dependency files");
1533
1534 engine
1535 .load(
1536 r#"spec main_spec
1537uses external: @org/project helper
1538rule value: external.quantity"#,
1539 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1540 )
1541 .expect("should succeed with pre-resolved deps");
1542
1543 let now = DateTimeValue::now();
1544 let response = engine
1545 .run(None, "main_spec", Some(&now), HashMap::new(), false, None)
1546 .expect("evaluate should succeed");
1547
1548 let value_result = response
1549 .results
1550 .get("value")
1551 .expect("rule 'value' should exist");
1552 assert_eq!(value_result.display.clone().expect("display"), "42");
1553 }
1554
1555 #[test]
1556 fn schema_with_repo_resolves_registry_spec() {
1557 let mut engine = Engine::new();
1558 engine
1559 .load_batch(
1560 HashMap::from([(
1561 SourceType::Volatile,
1562 "repo @org/project\nspec helper\ndata quantity: 42\nrule expose: quantity"
1563 .to_string(),
1564 )]),
1565 Some("@org/project"),
1566 )
1567 .expect("registry bundle loads");
1568
1569 engine
1570 .load(
1571 r#"spec main_spec
1572data x: 1"#,
1573 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1574 )
1575 .expect("main loads");
1576
1577 let now = DateTimeValue::now();
1578 let schema = engine
1579 .schema(Some("@org/project"), "helper", Some(&now))
1580 .expect("schema for registry spec");
1581 assert!(schema.data.contains_key("quantity"));
1582 }
1583
1584 #[test]
1585 fn load_no_external_refs_works() {
1586 let mut engine = Engine::new();
1587
1588 engine
1589 .load(
1590 r#"spec local_only
1591data price: 100
1592rule doubled: price * 2"#,
1593 SourceType::Path(Arc::new(std::path::PathBuf::from("local.lemma"))),
1594 )
1595 .expect("should succeed when there are no @... references");
1596
1597 let now = DateTimeValue::now();
1598 let response = engine
1599 .run(None, "local_only", Some(&now), HashMap::new(), false, None)
1600 .expect("evaluate should succeed");
1601
1602 let doubled = response.results.get("doubled").expect("doubled rule");
1603 assert_eq!(doubled.display.clone().expect("display"), "200");
1604 }
1605
1606 #[test]
1607 fn unresolved_external_ref_without_deps_fails() {
1608 let mut engine = Engine::new();
1609
1610 let result = engine.load(
1611 r#"spec main_spec
1612uses external: @org/project missing
1613rule value: external.quantity"#,
1614 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1615 );
1616
1617 let errs = result.expect_err("Should fail when registry dep is not loaded");
1618 assert!(
1619 errs.iter()
1620 .any(|e| e.kind() == crate::ErrorKind::MissingRepository),
1621 "expected MissingRepository, got: {:?}",
1622 errs.iter().map(|e| e.kind()).collect::<Vec<_>>()
1623 );
1624 }
1625
1626 #[test]
1627 fn pre_resolved_deps_with_spec_and_type_refs() {
1628 let mut engine = Engine::new();
1629
1630 engine
1631 .load_batch(
1632 HashMap::from([(
1633 SourceType::Volatile,
1634 "repo @org/example\nspec helper\ndata value: 42".to_string(),
1635 )]),
1636 Some("@org/example"),
1637 )
1638 .expect("should load helper file");
1639
1640 engine
1641 .load_batch(
1642 HashMap::from([(
1643 SourceType::Volatile,
1644 "repo @lemma/std\nspec finance\ndata money: quantity\n -> unit eur 1.00\n -> decimals 2".to_string(),
1645 )]),
1646 Some("@lemma/std"),
1647 )
1648 .expect("should load finance file");
1649
1650 engine
1651 .load(
1652 r#"spec registry_demo
1653uses @lemma/std finance
1654data money: finance.money
1655data unit_price: 5 eur
1656uses @org/example helper
1657rule helper_value: helper.value
1658rule line_total: unit_price * 2
1659rule formatted: helper_value + 0"#,
1660 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1661 )
1662 .expect("should succeed with pre-resolved spec and type deps");
1663
1664 let now = DateTimeValue::now();
1665 let response = engine
1666 .run(
1667 None,
1668 "registry_demo",
1669 Some(&now),
1670 HashMap::new(),
1671 false,
1672 None,
1673 )
1674 .expect("evaluate should succeed");
1675
1676 assert_eq!(
1677 response
1678 .results
1679 .get("helper_value")
1680 .expect("helper_value")
1681 .display
1682 .clone()
1683 .expect("display"),
1684 "42"
1685 );
1686 let line = response
1687 .results
1688 .get("line_total")
1689 .expect("line_total")
1690 .display
1691 .clone()
1692 .expect("display");
1693 assert!(
1694 line.contains("10") && line.to_lowercase().contains("eur"),
1695 "5 eur * 2 => ~10 eur, got {line}"
1696 );
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}