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