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