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