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