1use crate::evaluation::Evaluator;
2use crate::parsing::ast::{DateTimeValue, LemmaSpec};
3use crate::parsing::EffectiveDate;
4use crate::planning::{LemmaSpecSet, SpecSchema};
5use crate::{parse, Error, ResourceLimits, Response};
6use std::collections::{BTreeMap, HashMap};
7use std::sync::Arc;
8
9#[cfg(not(target_arch = "wasm32"))]
10use std::collections::HashSet;
11#[cfg(not(target_arch = "wasm32"))]
12use std::path::Path;
13
14#[derive(Debug, Clone)]
16pub struct Errors {
17 pub errors: Vec<Error>,
18 pub sources: HashMap<String, String>,
19}
20
21impl Errors {
22 pub fn iter(&self) -> std::slice::Iter<'_, Error> {
24 self.errors.iter()
25 }
26}
27
28#[derive(Debug, Default)]
35pub struct Context {
36 spec_sets: BTreeMap<String, LemmaSpecSet>,
37}
38
39impl Context {
40 pub fn new() -> Self {
41 Self {
42 spec_sets: BTreeMap::new(),
43 }
44 }
45
46 #[must_use]
48 pub fn spec_sets(&self) -> &BTreeMap<String, LemmaSpecSet> {
49 &self.spec_sets
50 }
51
52 pub fn iter(&self) -> impl Iterator<Item = Arc<LemmaSpec>> + '_ {
53 self.spec_sets.values().flat_map(|ss| ss.iter_specs())
54 }
55
56 pub fn iter_with_ranges(
65 &self,
66 ) -> impl Iterator<Item = (Arc<LemmaSpec>, Option<DateTimeValue>, Option<DateTimeValue>)> + '_
67 {
68 self.spec_sets
69 .values()
70 .flat_map(|spec_set| spec_set.iter_with_ranges())
71 }
72
73 pub fn insert_spec(&mut self, spec: Arc<LemmaSpec>, from_registry: bool) -> Result<(), Error> {
79 if spec.from_registry && !from_registry {
80 return Err(Error::validation_with_context(
81 format!(
82 "Spec '{}' uses '@' registry prefix, which is reserved for dependencies",
83 spec.name
84 ),
85 None,
86 Some("Remove the '@' prefix, or load this file as a dependency."),
87 Some(Arc::clone(&spec)),
88 None,
89 ));
90 }
91
92 if from_registry && !spec.from_registry {
93 return Err(Error::validation_with_context(
94 format!(
95 "Registry bundle contains spec '{}' without '@' prefix; \
96 all specs in a registry bundle must use '@'-prefixed names \
97 to avoid conflicts with local specs",
98 spec.name
99 ),
100 None,
101 Some("Prefix the spec name with '@' (e.g. spec @org/project/name)."),
102 Some(Arc::clone(&spec)),
103 None,
104 ));
105 }
106
107 let name = spec.name.clone();
108 if self
109 .spec_sets
110 .get(&name)
111 .is_some_and(|ss| ss.get_exact(spec.effective_from()).is_some())
112 {
113 return Err(Error::validation_with_context(
114 format!(
115 "Duplicate spec '{}' (same name and effective_from already in context)",
116 spec.name
117 ),
118 None,
119 None::<String>,
120 Some(Arc::clone(&spec)),
121 None,
122 ));
123 }
124
125 let inserted = self
126 .spec_sets
127 .entry(name.clone())
128 .or_insert_with(|| LemmaSpecSet::new(name))
129 .insert(spec);
130 debug_assert!(inserted);
131 Ok(())
132 }
133
134 pub fn remove_spec(&mut self, spec: &Arc<LemmaSpec>) -> bool {
135 let key = spec.effective_from().cloned();
136 let Some(ss) = self.spec_sets.get_mut(&spec.name) else {
137 return false;
138 };
139 if !ss.remove(key.as_ref()) {
140 return false;
141 }
142 if ss.is_empty() {
143 self.spec_sets.remove(&spec.name);
144 }
145 true
146 }
147
148 #[cfg(test)]
149 pub(crate) fn len(&self) -> usize {
150 self.spec_sets.values().map(LemmaSpecSet::len).sum()
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum SourceType<'a> {
159 Labeled(&'a str),
161 Inline,
163 Dependency(&'a str),
165}
166
167impl SourceType<'_> {
168 pub const INLINE_KEY: &'static str = "inline source (no path)";
170
171 fn storage_key(self) -> Result<String, Vec<Error>> {
172 match self {
173 SourceType::Labeled(s) => {
174 if s.trim().is_empty() {
175 return Err(vec![Error::request(
176 "source label must be non-empty, or use SourceType::Inline",
177 None::<String>,
178 )]);
179 }
180 Ok(s.to_string())
181 }
182 SourceType::Inline => Ok(Self::INLINE_KEY.to_string()),
183 SourceType::Dependency(s) => Ok(s.to_string()),
184 }
185 }
186}
187
188pub struct Engine {
198 plan_sets: HashMap<String, crate::planning::ExecutionPlanSet>,
200 specs: Context,
201 sources: HashMap<String, String>,
202 evaluator: Evaluator,
203 limits: ResourceLimits,
204 total_expression_count: usize,
205}
206
207impl Default for Engine {
208 fn default() -> Self {
209 Self {
210 plan_sets: HashMap::new(),
211 specs: Context::new(),
212 sources: HashMap::new(),
213 evaluator: Evaluator,
214 limits: ResourceLimits::default(),
215 total_expression_count: 0,
216 }
217 }
218}
219
220impl Engine {
221 pub fn new() -> Self {
222 Self::default()
223 }
224
225 pub fn sources(&self) -> &HashMap<String, String> {
227 &self.sources
228 }
229
230 pub fn with_limits(limits: ResourceLimits) -> Self {
232 Self {
233 plan_sets: HashMap::new(),
234 specs: Context::new(),
235 sources: HashMap::new(),
236 evaluator: Evaluator,
237 limits,
238 total_expression_count: 0,
239 }
240 }
241
242 fn apply_planning_result(&mut self, pr: crate::planning::PlanningResult) {
243 self.plan_sets.clear();
244 for r in &pr.results {
245 self.plan_sets
246 .insert(r.name.clone(), r.execution_plan_set());
247 }
248 }
249
250 pub fn load(&mut self, code: &str, source: SourceType<'_>) -> Result<(), Errors> {
253 let from_registry = matches!(source, SourceType::Dependency(_));
254 let mut files = HashMap::new();
255 let key = source.storage_key().map_err(|errs| Errors {
256 errors: errs,
257 sources: HashMap::new(),
258 })?;
259 files.insert(key, code.to_string());
260 self.add_files_inner(files, from_registry)
261 }
262
263 #[cfg(not(target_arch = "wasm32"))]
267 pub fn load_from_paths<P: AsRef<Path>>(
268 &mut self,
269 paths: &[P],
270 from_registry: bool,
271 ) -> Result<(), Errors> {
272 use std::fs;
273
274 let mut files = HashMap::new();
275 let mut seen = HashSet::<String>::new();
276
277 for path in paths {
278 let path = path.as_ref();
279 if path.is_file() {
280 if !path.extension().map(|e| e == "lemma").unwrap_or(false) {
282 continue;
283 }
284 let key = path.display().to_string();
285 if seen.contains(&key) {
286 continue;
287 }
288 seen.insert(key.clone());
289 let content = fs::read_to_string(path).map_err(|e| Errors {
290 errors: vec![Error::request(
291 format!("Cannot read '{}': {}", path.display(), e),
292 None::<String>,
293 )],
294 sources: HashMap::new(),
295 })?;
296 files.insert(key, content);
297 } else if path.is_dir() {
298 let read_dir = fs::read_dir(path).map_err(|e| Errors {
299 errors: vec![Error::request(
300 format!("Cannot read directory '{}': {}", path.display(), e),
301 None::<String>,
302 )],
303 sources: HashMap::new(),
304 })?;
305 for entry in read_dir.filter_map(Result::ok) {
306 let p = entry.path();
307 if !p.is_file() || !p.extension().map(|e| e == "lemma").unwrap_or(false) {
308 continue;
309 }
310 let key = p.display().to_string();
311 if seen.contains(&key) {
312 continue;
313 }
314 seen.insert(key.clone());
315 let Ok(content) = fs::read_to_string(&p) else {
316 continue;
317 };
318 files.insert(key, content);
319 }
320 }
321 }
322
323 self.add_files_inner(files, from_registry)
324 }
325
326 fn add_files_inner(
327 &mut self,
328 files: HashMap<String, String>,
329 from_registry: bool,
330 ) -> Result<(), Errors> {
331 let limits = &self.limits;
332 if files.len() > limits.max_files {
333 return Err(Errors {
334 errors: vec![Error::resource_limit_exceeded(
335 "max_files",
336 limits.max_files.to_string(),
337 files.len().to_string(),
338 "Reduce the number of paths or files",
339 None::<crate::Source>,
340 None,
341 None,
342 )],
343 sources: files,
344 });
345 }
346 let total_loaded_bytes: usize = files.values().map(|s| s.len()).sum();
347 if total_loaded_bytes > limits.max_loaded_bytes {
348 return Err(Errors {
349 errors: vec![Error::resource_limit_exceeded(
350 "max_loaded_bytes",
351 limits.max_loaded_bytes.to_string(),
352 total_loaded_bytes.to_string(),
353 "Load fewer or smaller files",
354 None::<crate::Source>,
355 None,
356 None,
357 )],
358 sources: files,
359 });
360 }
361 for code in files.values() {
362 if code.len() > limits.max_file_size_bytes {
363 return Err(Errors {
364 errors: vec![Error::resource_limit_exceeded(
365 "max_file_size_bytes",
366 limits.max_file_size_bytes.to_string(),
367 code.len().to_string(),
368 "Use a smaller file or increase limit",
369 None::<crate::Source>,
370 None,
371 None,
372 )],
373 sources: files,
374 });
375 }
376 }
377
378 let mut errors: Vec<Error> = Vec::new();
379
380 for (source_id, code) in &files {
381 match parse(code, source_id, &self.limits) {
382 Ok(result) => {
383 self.total_expression_count += result.expression_count;
384 if self.total_expression_count > self.limits.max_total_expression_count {
385 errors.push(Error::resource_limit_exceeded(
386 "max_total_expression_count",
387 self.limits.max_total_expression_count.to_string(),
388 self.total_expression_count.to_string(),
389 "Split logic across fewer files or reduce expression complexity",
390 None::<crate::Source>,
391 None,
392 None,
393 ));
394 return Err(Errors {
395 errors,
396 sources: files,
397 });
398 }
399 let new_specs = result.specs;
400 for spec in new_specs {
401 let attribute = spec.attribute.clone().unwrap_or_else(|| spec.name.clone());
402 let start_line = spec.start_line;
403
404 if from_registry {
405 let bare_refs =
406 crate::planning::graph::collect_bare_registry_refs(&spec);
407 if !bare_refs.is_empty() {
408 let source = crate::Source::new(
409 &attribute,
410 crate::parsing::ast::Span {
411 start: 0,
412 end: 0,
413 line: start_line,
414 col: 0,
415 },
416 );
417 errors.push(Error::validation(
418 format!(
419 "Registry spec '{}' contains references without '@' prefix: {}. \
420 The registry must rewrite all references to use '@'-prefixed names",
421 spec.name,
422 bare_refs.join(", ")
423 ),
424 Some(source),
425 Some(
426 "The registry must prefix all spec references with '@' \
427 before serving the bundle.",
428 ),
429 ));
430 continue;
431 }
432 }
433
434 match self.specs.insert_spec(Arc::new(spec), from_registry) {
435 Ok(()) => {
436 self.sources.insert(attribute, code.clone());
437 }
438 Err(e) => {
439 let source = crate::Source::new(
440 &attribute,
441 crate::parsing::ast::Span {
442 start: 0,
443 end: 0,
444 line: start_line,
445 col: 0,
446 },
447 );
448 errors.push(Error::validation(
449 e.to_string(),
450 Some(source),
451 None::<String>,
452 ));
453 }
454 }
455 }
456 }
457 Err(e) => errors.push(e),
458 }
459 }
460
461 let planning_result = crate::planning::plan(&self.specs);
462 for set_result in &planning_result.results {
463 for spec_result in &set_result.specs {
464 let ctx = Arc::clone(&spec_result.spec);
465 for err in &spec_result.errors {
466 errors.push(err.clone().with_spec_context(Arc::clone(&ctx)));
467 }
468 }
469 }
470 self.apply_planning_result(planning_result);
471
472 if errors.is_empty() {
473 Ok(())
474 } else {
475 Err(Errors {
476 errors,
477 sources: files,
478 })
479 }
480 }
481
482 #[must_use]
489 pub fn get_spec_set(&self, name: &str) -> Option<&LemmaSpecSet> {
490 self.specs.spec_sets().get(name)
491 }
492
493 pub fn get_spec(
494 &self,
495 name: &str,
496 effective: Option<&DateTimeValue>,
497 ) -> Result<Arc<LemmaSpec>, Error> {
498 let effective = self.effective_or_now(effective);
499
500 self.get_spec_set(name)
501 .and_then(|spec_set| spec_set.spec_at(&EffectiveDate::DateTimeValue(effective.clone())))
502 .ok_or_else(|| self.spec_not_found_error(name, &effective))
503 }
504
505 pub fn list_specs(&self) -> Vec<Arc<LemmaSpec>> {
507 self.specs.iter().collect()
508 }
509
510 pub fn list_specs_with_ranges(
517 &self,
518 ) -> Vec<(Arc<LemmaSpec>, Option<DateTimeValue>, Option<DateTimeValue>)> {
519 self.specs.iter_with_ranges().collect()
520 }
521
522 pub fn list_specs_effective(&self, effective: &DateTimeValue) -> Vec<Arc<LemmaSpec>> {
526 let mut seen_names = std::collections::HashSet::new();
527 let mut result = Vec::new();
528 for spec in self.specs.iter() {
529 if seen_names.contains(&spec.name) {
530 continue;
531 }
532 if let Some(active) = self
533 .specs
534 .spec_sets()
535 .get(&spec.name)
536 .and_then(|ss| ss.spec_at(&EffectiveDate::DateTimeValue(effective.clone())))
537 {
538 if seen_names.insert(active.name.clone()) {
539 result.push(active);
540 }
541 }
542 }
543 result.sort_by(|a, b| a.name.cmp(&b.name));
544 result
545 }
546
547 pub fn schema(
549 &self,
550 name: &str,
551 effective: Option<&DateTimeValue>,
552 ) -> Result<SpecSchema, Error> {
553 let effective = self.effective_or_now(effective);
554 Ok(self.get_plan(name, Some(&effective))?.schema())
555 }
556
557 pub fn run(
562 &self,
563 name: &str,
564 effective: Option<&DateTimeValue>,
565 data_values: HashMap<String, String>,
566 record_operations: bool,
567 ) -> Result<Response, Error> {
568 let effective = self.effective_or_now(effective);
569 let plan = self.get_plan(name, Some(&effective))?;
570 self.run_plan(plan, Some(&effective), data_values, record_operations)
571 }
572
573 pub fn invert(
578 &self,
579 name: &str,
580 effective: Option<&DateTimeValue>,
581 rule_name: &str,
582 target: crate::inversion::Target,
583 values: HashMap<String, String>,
584 ) -> Result<crate::InversionResponse, Error> {
585 let effective = self.effective_or_now(effective);
586 let base_plan = self.get_plan(name, Some(&effective))?;
587
588 let plan = base_plan.clone().with_data_values(values, &self.limits)?;
589 let provided_data: std::collections::HashSet<_> = plan
590 .data
591 .iter()
592 .filter(|(_, d)| d.value().is_some())
593 .map(|(p, _)| p.clone())
594 .collect();
595
596 crate::inversion::invert(rule_name, target, &plan, &provided_data)
597 }
598
599 pub fn get_plan(
601 &self,
602 name: &str,
603 effective: Option<&DateTimeValue>,
604 ) -> Result<&crate::planning::ExecutionPlan, Error> {
605 let effective = self.effective_or_now(effective);
606
607 if self
608 .specs
609 .spec_sets()
610 .get(name)
611 .and_then(|ss| ss.spec_at(&EffectiveDate::DateTimeValue(effective.clone())))
612 .is_none()
613 {
614 return Err(self.spec_not_found_error(name, &effective));
615 }
616
617 let plan_set = self.plan_sets.get(name).ok_or_else(|| {
618 Error::request_not_found(
619 format!("No execution plans for spec '{}'", name),
620 Some("Ensure sources loaded and planning succeeded"),
621 )
622 })?;
623
624 plan_set
625 .plan_at(&EffectiveDate::DateTimeValue(effective.clone()))
626 .ok_or_else(|| {
627 Error::request_not_found(
628 format!(
629 "No execution plan slice for spec '{}' at effective {}",
630 name, effective
631 ),
632 None::<String>,
633 )
634 })
635 }
636
637 pub fn run_plan(
642 &self,
643 plan: &crate::planning::ExecutionPlan,
644 effective: Option<&DateTimeValue>,
645 data_values: HashMap<String, String>,
646 record_operations: bool,
647 ) -> Result<Response, Error> {
648 let effective = self.effective_or_now(effective);
649 let plan = plan.clone().with_data_values(data_values, &self.limits)?;
650 self.evaluate_plan(plan, &effective, record_operations)
651 }
652
653 pub fn remove(&mut self, name: &str, effective: Option<&DateTimeValue>) -> Result<(), Error> {
654 let effective = self.effective_or_now(effective);
655 let arc = self.get_spec(name, Some(&effective))?;
656 self.specs.remove_spec(&arc);
657 let pr = crate::planning::plan(&self.specs);
658 let planning_errs: Vec<Error> = pr
659 .results
660 .iter()
661 .flat_map(|r| r.errors().cloned())
662 .collect();
663 self.apply_planning_result(pr);
664 if let Some(e) = planning_errs.into_iter().next() {
665 return Err(e);
666 }
667 Ok(())
668 }
669
670 fn spec_not_found_error(&self, spec_name: &str, effective: &DateTimeValue) -> Error {
673 let available = self
674 .specs
675 .spec_sets()
676 .get(spec_name)
677 .map(|ss| ss.iter_specs().collect::<Vec<_>>())
678 .unwrap_or_default();
679 let msg = if available.is_empty() {
680 format!("Spec '{}' not found", spec_name)
681 } else {
682 let listing: Vec<String> = available
683 .iter()
684 .map(|s| match s.effective_from() {
685 Some(dt) => format!(" {} (effective from {})", s.name, dt),
686 None => format!(" {} (no effective_from)", s.name),
687 })
688 .collect();
689 format!(
690 "Spec '{}' not found for effective {}. Available specs:\n{}",
691 spec_name,
692 effective,
693 listing.join("\n")
694 )
695 };
696 Error::request_not_found(msg, None::<String>)
697 }
698
699 fn evaluate_plan(
700 &self,
701 plan: crate::planning::ExecutionPlan,
702 effective: &DateTimeValue,
703 record_operations: bool,
704 ) -> Result<Response, Error> {
705 let now_semantic = crate::planning::semantics::date_time_to_semantic(effective);
706 let now_literal = crate::planning::semantics::LiteralValue {
707 value: crate::planning::semantics::ValueKind::Date(now_semantic),
708 lemma_type: crate::planning::semantics::primitive_date().clone(),
709 };
710 Ok(self
711 .evaluator
712 .evaluate(&plan, now_literal, record_operations))
713 }
714
715 #[must_use]
717 fn effective_or_now(&self, effective: Option<&DateTimeValue>) -> DateTimeValue {
718 effective.cloned().unwrap_or_else(DateTimeValue::now)
719 }
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725 use rust_decimal::Decimal;
726 use std::str::FromStr;
727
728 fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
729 DateTimeValue {
730 year,
731 month,
732 day,
733 hour: 0,
734 minute: 0,
735 second: 0,
736 microsecond: 0,
737 timezone: None,
738 }
739 }
740
741 fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
742 let mut spec = LemmaSpec::new(name.to_string());
743 spec.effective_from = crate::parsing::ast::EffectiveDate::from_option(effective_from);
744 spec
745 }
746
747 #[test]
750 fn list_specs_order_is_name_then_effective_from_ascending() {
751 let mut ctx = Context::new();
752 let s_2026 = Arc::new(make_spec_with_range("mortgage", Some(date(2026, 1, 1))));
753 let s_2025 = Arc::new(make_spec_with_range("mortgage", Some(date(2025, 1, 1))));
754 ctx.insert_spec(Arc::clone(&s_2026), false).unwrap();
755 ctx.insert_spec(Arc::clone(&s_2025), false).unwrap();
756 let listed: Vec<_> = ctx.iter().collect();
757 assert_eq!(listed.len(), 2);
758 assert_eq!(listed[0].effective_from(), Some(&date(2025, 1, 1)));
759 assert_eq!(listed[1].effective_from(), Some(&date(2026, 1, 1)));
760 }
761
762 #[test]
763 fn get_spec_resolves_temporal_version_by_effective() {
764 let mut engine = Engine::new();
765 engine
766 .load(
767 r#"
768 spec pricing 2025-01-01
769 data x: 1
770 rule r: x
771 "#,
772 SourceType::Labeled("a.lemma"),
773 )
774 .unwrap();
775 engine
776 .load(
777 r#"
778 spec pricing 2025-06-01
779 data x: 2
780 rule r: x
781 "#,
782 SourceType::Labeled("b.lemma"),
783 )
784 .unwrap();
785
786 let jan = DateTimeValue {
787 year: 2025,
788 month: 1,
789 day: 15,
790 hour: 0,
791 minute: 0,
792 second: 0,
793 microsecond: 0,
794 timezone: None,
795 };
796 let jul = DateTimeValue {
797 year: 2025,
798 month: 7,
799 day: 1,
800 hour: 0,
801 minute: 0,
802 second: 0,
803 microsecond: 0,
804 timezone: None,
805 };
806
807 let v1 = DateTimeValue {
808 year: 2025,
809 month: 1,
810 day: 1,
811 hour: 0,
812 minute: 0,
813 second: 0,
814 microsecond: 0,
815 timezone: None,
816 };
817 let v2 = DateTimeValue {
818 year: 2025,
819 month: 6,
820 day: 1,
821 hour: 0,
822 minute: 0,
823 second: 0,
824 microsecond: 0,
825 timezone: None,
826 };
827
828 let s_jan = engine.get_spec("pricing", Some(&jan)).expect("jan spec");
829 let s_jul = engine.get_spec("pricing", Some(&jul)).expect("jul spec");
830 assert_eq!(s_jan.effective_from(), Some(&v1));
831 assert_eq!(s_jul.effective_from(), Some(&v2));
832 }
833
834 #[test]
839 fn get_spec_set_returns_all_versions_with_half_open_ranges() {
840 let mut engine = Engine::new();
841 engine
842 .load(
843 r#"
844 spec pricing 2025-01-01
845 data x: 1
846 rule r: x
847 "#,
848 SourceType::Labeled("a.lemma"),
849 )
850 .unwrap();
851 engine
852 .load(
853 r#"
854 spec pricing 2025-06-01
855 data x: 2
856 rule r: x
857 "#,
858 SourceType::Labeled("b.lemma"),
859 )
860 .unwrap();
861
862 let january = date(2025, 1, 1);
863 let june = date(2025, 6, 1);
864
865 let spec_set = engine
866 .get_spec_set("pricing")
867 .expect("spec set must exist after load");
868
869 let versions: Vec<_> = spec_set
870 .iter_specs()
871 .map(|spec| spec_set.effective_range(&spec))
872 .collect();
873
874 assert_eq!(versions.len(), 2);
875 assert_eq!(
876 versions[0],
877 (Some(january.clone()), Some(june.clone())),
878 "earlier row ends at the next row's effective_from"
879 );
880 assert_eq!(
881 versions[1],
882 (Some(june.clone()), None),
883 "latest row has no successor; effective_to is None"
884 );
885
886 assert!(engine.get_spec_set("unknown").is_none());
887 }
888
889 #[test]
895 fn list_specs_with_ranges_flattens_all_spec_sets_with_half_open_ranges() {
896 let mut engine = Engine::new();
897 engine
898 .load(
899 r#"
900 spec pricing 2025-01-01
901 data x: 1
902 rule r: x
903 "#,
904 SourceType::Labeled("pricing_v1.lemma"),
905 )
906 .unwrap();
907 engine
908 .load(
909 r#"
910 spec pricing 2026-01-01
911 data x: 2
912 rule r: x
913 "#,
914 SourceType::Labeled("pricing_v2.lemma"),
915 )
916 .unwrap();
917 engine
918 .load(
919 r#"
920 spec taxes
921 data rate: 0.21
922 rule amount: rate
923 "#,
924 SourceType::Labeled("taxes.lemma"),
925 )
926 .unwrap();
927
928 let entries = engine.list_specs_with_ranges();
929 assert_eq!(
930 entries.len(),
931 3,
932 "one row per loaded spec version across all names"
933 );
934
935 let names: Vec<&str> = entries
936 .iter()
937 .map(|(spec, _, _)| spec.name.as_str())
938 .collect();
939 assert_eq!(
940 names,
941 vec!["pricing", "pricing", "taxes"],
942 "ordered by spec name ascending, then by effective_from ascending"
943 );
944
945 let (_, pricing_v1_from, pricing_v1_to) = &entries[0];
946 assert_eq!(pricing_v1_from, &Some(date(2025, 1, 1)));
947 assert_eq!(
948 pricing_v1_to,
949 &Some(date(2026, 1, 1)),
950 "earlier pricing row ends at the next pricing row's effective_from"
951 );
952
953 let (_, pricing_v2_from, pricing_v2_to) = &entries[1];
954 assert_eq!(pricing_v2_from, &Some(date(2026, 1, 1)));
955 assert_eq!(
956 pricing_v2_to, &None,
957 "latest pricing row has no successor; effective_to is None"
958 );
959
960 let (_, taxes_from, taxes_to) = &entries[2];
961 assert_eq!(
962 taxes_from, &None,
963 "unversioned spec has no declared effective_from"
964 );
965 assert_eq!(
966 taxes_to, &None,
967 "unversioned spec has no successor; effective_to is None"
968 );
969 }
970
971 #[test]
972 fn test_evaluate_spec_all_rules() {
973 let mut engine = Engine::new();
974 engine
975 .load(
976 r#"
977 spec test
978 data x: 10
979 data y: 5
980 rule sum: x + y
981 rule product: x * y
982 "#,
983 SourceType::Labeled("test.lemma"),
984 )
985 .unwrap();
986
987 let now = DateTimeValue::now();
988 let response = engine
989 .run("test", Some(&now), HashMap::new(), false)
990 .unwrap();
991 assert_eq!(response.results.len(), 2);
992
993 let sum_result = response
994 .results
995 .values()
996 .find(|r| r.rule.name == "sum")
997 .unwrap();
998 assert_eq!(
999 sum_result.result,
1000 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1001 Decimal::from_str("15").unwrap()
1002 )))
1003 );
1004
1005 let product_result = response
1006 .results
1007 .values()
1008 .find(|r| r.rule.name == "product")
1009 .unwrap();
1010 assert_eq!(
1011 product_result.result,
1012 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1013 Decimal::from_str("50").unwrap()
1014 )))
1015 );
1016 }
1017
1018 #[test]
1019 fn test_evaluate_empty_data() {
1020 let mut engine = Engine::new();
1021 engine
1022 .load(
1023 r#"
1024 spec test
1025 data price: 100
1026 rule total: price * 2
1027 "#,
1028 SourceType::Labeled("test.lemma"),
1029 )
1030 .unwrap();
1031
1032 let now = DateTimeValue::now();
1033 let response = engine
1034 .run("test", Some(&now), HashMap::new(), false)
1035 .unwrap();
1036 assert_eq!(response.results.len(), 1);
1037 assert_eq!(
1038 response.results.values().next().unwrap().result,
1039 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1040 Decimal::from_str("200").unwrap()
1041 )))
1042 );
1043 }
1044
1045 #[test]
1046 fn test_evaluate_boolean_rule() {
1047 let mut engine = Engine::new();
1048 engine
1049 .load(
1050 r#"
1051 spec test
1052 data age: 25
1053 rule is_adult: age >= 18
1054 "#,
1055 SourceType::Labeled("test.lemma"),
1056 )
1057 .unwrap();
1058
1059 let now = DateTimeValue::now();
1060 let response = engine
1061 .run("test", Some(&now), HashMap::new(), false)
1062 .unwrap();
1063 assert_eq!(
1064 response.results.values().next().unwrap().result,
1065 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::from_bool(true)))
1066 );
1067 }
1068
1069 #[test]
1070 fn test_evaluate_with_unless_clause() {
1071 let mut engine = Engine::new();
1072 engine
1073 .load(
1074 r#"
1075 spec test
1076 data quantity: 15
1077 rule discount: 0
1078 unless quantity >= 10 then 10
1079 "#,
1080 SourceType::Labeled("test.lemma"),
1081 )
1082 .unwrap();
1083
1084 let now = DateTimeValue::now();
1085 let response = engine
1086 .run("test", Some(&now), HashMap::new(), false)
1087 .unwrap();
1088 assert_eq!(
1089 response.results.values().next().unwrap().result,
1090 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1091 Decimal::from_str("10").unwrap()
1092 )))
1093 );
1094 }
1095
1096 #[test]
1097 fn test_spec_not_found() {
1098 let engine = Engine::new();
1099 let now = DateTimeValue::now();
1100 let result = engine.run("nonexistent", Some(&now), HashMap::new(), false);
1101 assert!(result.is_err());
1102 assert!(result.unwrap_err().to_string().contains("not found"));
1103 }
1104
1105 #[test]
1106 fn test_multiple_specs() {
1107 let mut engine = Engine::new();
1108 engine
1109 .load(
1110 r#"
1111 spec spec1
1112 data x: 10
1113 rule result: x * 2
1114 "#,
1115 SourceType::Labeled("spec 1.lemma"),
1116 )
1117 .unwrap();
1118
1119 engine
1120 .load(
1121 r#"
1122 spec spec2
1123 data y: 5
1124 rule result: y * 3
1125 "#,
1126 SourceType::Labeled("spec 2.lemma"),
1127 )
1128 .unwrap();
1129
1130 let now = DateTimeValue::now();
1131 let response1 = engine
1132 .run("spec1", Some(&now), HashMap::new(), false)
1133 .unwrap();
1134 assert_eq!(
1135 response1.results[0].result,
1136 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1137 Decimal::from_str("20").unwrap()
1138 )))
1139 );
1140
1141 let response2 = engine
1142 .run("spec2", Some(&now), HashMap::new(), false)
1143 .unwrap();
1144 assert_eq!(
1145 response2.results[0].result,
1146 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1147 Decimal::from_str("15").unwrap()
1148 )))
1149 );
1150 }
1151
1152 #[test]
1153 fn test_runtime_error_mapping() {
1154 let mut engine = Engine::new();
1155 engine
1156 .load(
1157 r#"
1158 spec test
1159 data numerator: 10
1160 data denominator: 0
1161 rule division: numerator / denominator
1162 "#,
1163 SourceType::Labeled("test.lemma"),
1164 )
1165 .unwrap();
1166
1167 let now = DateTimeValue::now();
1168 let result = engine.run("test", Some(&now), HashMap::new(), false);
1169 assert!(result.is_ok(), "Evaluation should succeed");
1171 let response = result.unwrap();
1172 let division_result = response
1173 .results
1174 .values()
1175 .find(|r| r.rule.name == "division");
1176 assert!(
1177 division_result.is_some(),
1178 "Should have division rule result"
1179 );
1180 match &division_result.unwrap().result {
1181 crate::OperationResult::Veto(crate::VetoType::Computation { message }) => {
1182 assert!(
1183 message.contains("Division by zero"),
1184 "Veto message should mention division by zero: {:?}",
1185 message
1186 );
1187 }
1188 other => panic!("Expected Veto for division by zero, got {:?}", other),
1189 }
1190 }
1191
1192 #[test]
1193 fn test_rules_sorted_by_source_order() {
1194 let mut engine = Engine::new();
1195 engine
1196 .load(
1197 r#"
1198 spec test
1199 data a: 1
1200 data b: 2
1201 rule z: a + b
1202 rule y: a * b
1203 rule x: a - b
1204 "#,
1205 SourceType::Labeled("test.lemma"),
1206 )
1207 .unwrap();
1208
1209 let now = DateTimeValue::now();
1210 let response = engine
1211 .run("test", Some(&now), HashMap::new(), false)
1212 .unwrap();
1213 assert_eq!(response.results.len(), 3);
1214
1215 let z_pos = response
1217 .results
1218 .values()
1219 .find(|r| r.rule.name == "z")
1220 .unwrap()
1221 .rule
1222 .source_location
1223 .span
1224 .start;
1225 let y_pos = response
1226 .results
1227 .values()
1228 .find(|r| r.rule.name == "y")
1229 .unwrap()
1230 .rule
1231 .source_location
1232 .span
1233 .start;
1234 let x_pos = response
1235 .results
1236 .values()
1237 .find(|r| r.rule.name == "x")
1238 .unwrap()
1239 .rule
1240 .source_location
1241 .span
1242 .start;
1243
1244 assert!(z_pos < y_pos);
1245 assert!(y_pos < x_pos);
1246 }
1247
1248 #[test]
1249 fn test_rule_filtering_evaluates_dependencies() {
1250 let mut engine = Engine::new();
1251 engine
1252 .load(
1253 r#"
1254 spec test
1255 data base: 100
1256 rule subtotal: base * 2
1257 rule tax: subtotal * 10%
1258 rule total: subtotal + tax
1259 "#,
1260 SourceType::Labeled("test.lemma"),
1261 )
1262 .unwrap();
1263
1264 let now = DateTimeValue::now();
1266 let rules = vec!["total".to_string()];
1267 let mut response = engine
1268 .run("test", Some(&now), HashMap::new(), false)
1269 .unwrap();
1270 response.filter_rules(&rules);
1271
1272 assert_eq!(response.results.len(), 1);
1273 assert_eq!(response.results.keys().next().unwrap(), "total");
1274
1275 let total = response.results.values().next().unwrap();
1277 assert_eq!(
1278 total.result,
1279 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1280 Decimal::from_str("220").unwrap()
1281 )))
1282 );
1283 }
1284
1285 use crate::parsing::ast::DateTimeValue;
1290
1291 #[test]
1292 fn pre_resolved_deps_in_file_map_evaluates_external_spec() {
1293 let mut engine = Engine::new();
1294
1295 engine
1296 .load(
1297 "spec @org/project/helper\ndata quantity: 42",
1298 SourceType::Dependency("deps/org_project_helper.lemma"),
1299 )
1300 .expect("should load dependency files");
1301
1302 engine
1303 .load(
1304 r#"spec main_spec
1305with external: @org/project/helper
1306rule value: external.quantity"#,
1307 SourceType::Labeled("main.lemma"),
1308 )
1309 .expect("should succeed with pre-resolved deps");
1310
1311 let now = DateTimeValue::now();
1312 let response = engine
1313 .run("main_spec", Some(&now), HashMap::new(), false)
1314 .expect("evaluate should succeed");
1315
1316 let value_result = response
1317 .results
1318 .get("value")
1319 .expect("rule 'value' should exist");
1320 assert_eq!(
1321 value_result.result,
1322 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1323 Decimal::from_str("42").unwrap()
1324 )))
1325 );
1326 }
1327
1328 #[test]
1329 fn load_no_external_refs_works() {
1330 let mut engine = Engine::new();
1331
1332 engine
1333 .load(
1334 r#"spec local_only
1335data price: 100
1336rule doubled: price * 2"#,
1337 SourceType::Labeled("local.lemma"),
1338 )
1339 .expect("should succeed when there are no @... references");
1340
1341 let now = DateTimeValue::now();
1342 let response = engine
1343 .run("local_only", Some(&now), HashMap::new(), false)
1344 .expect("evaluate should succeed");
1345
1346 let doubled = response
1347 .results
1348 .get("doubled")
1349 .expect("doubled rule")
1350 .result
1351 .value()
1352 .expect("value");
1353 assert_eq!(doubled.to_string(), "200");
1354 }
1355
1356 #[test]
1357 fn unresolved_external_ref_without_deps_fails() {
1358 let mut engine = Engine::new();
1359
1360 let result = engine.load(
1361 r#"spec main_spec
1362with external: @org/project/missing
1363rule value: external.quantity"#,
1364 SourceType::Labeled("main.lemma"),
1365 );
1366
1367 let errs = result.expect_err("Should fail when @... dep is not in file map");
1368 let msg = errs
1369 .iter()
1370 .map(|e| e.to_string())
1371 .collect::<Vec<_>>()
1372 .join(" ");
1373 assert!(
1374 msg.contains("missing") || msg.contains("not found") || msg.contains("Unknown"),
1375 "error should indicate missing dep: {msg}"
1376 );
1377 }
1378
1379 #[test]
1380 fn pre_resolved_deps_with_spec_and_type_refs() {
1381 let mut engine = Engine::new();
1382
1383 let mut deps = HashMap::new();
1384 deps.insert(
1385 "deps/helper.lemma".to_string(),
1386 "spec @org/example/helper\ndata value: 42".to_string(),
1387 );
1388 deps.insert(
1389 "deps/finance.lemma".to_string(),
1390 "spec @lemma/std/finance\ndata money: scale\n -> unit eur 1.00\n -> decimals 2"
1391 .to_string(),
1392 );
1393 engine
1394 .load(
1395 "spec @org/example/helper\ndata value: 42",
1396 SourceType::Dependency("deps/helper.lemma"),
1397 )
1398 .expect("should load helper file");
1399
1400 engine
1401 .load(
1402 "spec @lemma/std/finance\ndata money: scale\n -> unit eur 1.00\n -> decimals 2",
1403 SourceType::Dependency("deps/finance.lemma"),
1404 )
1405 .expect("should load finance file");
1406
1407 engine
1408 .load(
1409 r#"spec registry_demo
1410data money from @lemma/std/finance
1411data unit_price: 5 eur
1412with @org/example/helper
1413rule helper_value: helper.value
1414rule line_total: unit_price * 2
1415rule formatted: helper_value + 0"#,
1416 SourceType::Labeled("main.lemma"),
1417 )
1418 .expect("should succeed with pre-resolved spec and type deps");
1419
1420 let now = DateTimeValue::now();
1421 let response = engine
1422 .run("registry_demo", Some(&now), HashMap::new(), false)
1423 .expect("evaluate should succeed");
1424
1425 assert_eq!(
1426 response
1427 .results
1428 .get("helper_value")
1429 .expect("helper_value")
1430 .result
1431 .value()
1432 .expect("value")
1433 .to_string(),
1434 "42"
1435 );
1436 let line = response
1437 .results
1438 .get("line_total")
1439 .expect("line_total")
1440 .result
1441 .value()
1442 .expect("value")
1443 .to_string();
1444 assert!(
1445 line.contains("10") && line.to_lowercase().contains("eur"),
1446 "5 eur * 2 => ~10 eur, got {line}"
1447 );
1448 assert_eq!(
1449 response
1450 .results
1451 .get("formatted")
1452 .expect("formatted")
1453 .result
1454 .value()
1455 .expect("value")
1456 .to_string(),
1457 "42"
1458 );
1459 }
1460
1461 #[test]
1462 fn load_empty_labeled_source_is_error() {
1463 let mut engine = Engine::new();
1464 let err = engine
1465 .load("spec x\ndata a: 1", SourceType::Labeled(" "))
1466 .unwrap_err();
1467 assert!(err.errors.iter().any(|e| e.message().contains("non-empty")));
1468 }
1469
1470 #[test]
1471 fn load_rejects_registry_spec_definitions() {
1472 let mut engine = Engine::new();
1473 let result = engine.load(
1474 "spec @org/example/helper\ndata x: 1",
1475 SourceType::Labeled("bad.lemma"),
1476 );
1477 assert!(result.is_err(), "should reject @-prefixed spec in load");
1478 let errors = result.unwrap_err();
1479 assert!(
1480 errors
1481 .errors
1482 .iter()
1483 .any(|e| e.message().contains("registry prefix")),
1484 "error should mention registry prefix, got: {:?}",
1485 errors
1486 );
1487 }
1488
1489 #[test]
1490 fn add_dependency_files_accepts_registry_spec_definitions() {
1491 let mut engine = Engine::new();
1492 let mut files = HashMap::new();
1493 files.insert(
1494 "deps/helper.lemma".to_string(),
1495 "spec @org/my/helper\ndata x: 1".to_string(),
1496 );
1497 engine
1498 .load(
1499 "spec @org/my/helper\ndata x: 1",
1500 SourceType::Dependency("helper.lemma"),
1501 )
1502 .expect("add_dependency_files should accept @-prefixed specs");
1503 }
1504
1505 #[test]
1506 fn add_dependency_files_rejects_bare_named_spec_in_registry_bundle() {
1507 let mut engine = Engine::new();
1508 let result = engine.load(
1509 "spec local_looking_name\ndata x: 1",
1510 SourceType::Dependency("bundle.lemma"),
1511 );
1512 assert!(
1513 result.is_err(),
1514 "should reject non-@-prefixed spec in registry bundle"
1515 );
1516 let errors = result.unwrap_err();
1517 assert!(
1518 errors
1519 .errors
1520 .iter()
1521 .any(|e| e.message().contains("without '@' prefix")),
1522 "error should mention missing @ prefix, got: {:?}",
1523 errors
1524 );
1525 }
1526
1527 #[test]
1528 fn add_dependency_files_rejects_spec_with_bare_spec_reference() {
1529 let mut engine = Engine::new();
1530 let result = engine.load(
1531 "spec @org/billing\nwith rates: local_rates",
1532 SourceType::Dependency("billing.lemma"),
1533 );
1534 assert!(
1535 result.is_err(),
1536 "should reject registry spec referencing non-@ spec"
1537 );
1538 let errors = result.unwrap_err();
1539 assert!(
1540 errors
1541 .errors
1542 .iter()
1543 .any(|e| e.message().contains("local_rates")),
1544 "error should mention bare ref name, got: {:?}",
1545 errors
1546 );
1547 }
1548
1549 #[test]
1550 fn add_dependency_files_rejects_spec_with_bare_type_import() {
1551 let mut engine = Engine::new();
1552 let result = engine.load(
1553 "spec @org/billing\ndata money from local_finance",
1554 SourceType::Dependency("billing.lemma"),
1555 );
1556 assert!(
1557 result.is_err(),
1558 "should reject registry spec importing type from non-@ spec"
1559 );
1560 let errors = result.unwrap_err();
1561 assert!(
1562 errors
1563 .errors
1564 .iter()
1565 .any(|e| e.message().contains("local_finance")),
1566 "error should mention bare ref name, got: {:?}",
1567 errors
1568 );
1569 }
1570
1571 #[test]
1572 fn add_dependency_files_accepts_fully_qualified_references() {
1573 let mut engine = Engine::new();
1574 let mut files = HashMap::new();
1575 files.insert(
1576 "deps/bundle.lemma".to_string(),
1577 r#"spec @org/billing
1578with @org/rates
1579
1580spec @org/rates
1581data rate: 10"#
1582 .to_string(),
1583 );
1584 engine
1585 .load(
1586 r#"spec @org/billing
1587with @org/rates
1588
1589spec @org/rates
1590data rate: 10"#,
1591 SourceType::Dependency("bundle.lemma"),
1592 )
1593 .expect("fully @-prefixed bundle should be accepted");
1594 }
1595
1596 #[test]
1597 fn load_returns_all_errors_not_just_first() {
1598 let mut engine = Engine::new();
1599
1600 let result = engine.load(
1601 r#"spec demo
1602data money from nonexistent_type_source
1603with helper: nonexistent_spec
1604data price: 10
1605rule total: helper.value + price"#,
1606 SourceType::Labeled("test.lemma"),
1607 );
1608
1609 assert!(result.is_err(), "Should fail with multiple errors");
1610 let load_err = result.unwrap_err();
1611 assert!(
1612 load_err.errors.len() >= 2,
1613 "expected at least 2 errors (type + spec ref), got {}",
1614 load_err.errors.len()
1615 );
1616 let error_message = load_err
1617 .errors
1618 .iter()
1619 .map(ToString::to_string)
1620 .collect::<Vec<_>>()
1621 .join("; ");
1622
1623 assert!(
1624 error_message.contains("nonexistent_type_source"),
1625 "Should mention type import source spec. Got:\n{}",
1626 error_message
1627 );
1628 assert!(
1629 error_message.contains("nonexistent_spec"),
1630 "Should mention spec reference error about 'nonexistent_spec'. Got:\n{}",
1631 error_message
1632 );
1633 }
1634
1635 #[test]
1641 fn planning_rejects_invalid_number_default() {
1642 let mut engine = Engine::new();
1643 let result = engine.load(
1644 "spec t\ndata x: number -> default \"10 $$\"]\nrule r: x",
1645 SourceType::Labeled("t.lemma"),
1646 );
1647 assert!(
1648 result.is_err(),
1649 "must reject non-numeric default on number type"
1650 );
1651 }
1652
1653 #[test]
1654 fn planning_rejects_text_literal_as_number_default() {
1655 let mut engine = Engine::new();
1660 let result = engine.load(
1661 "spec t\ndata x: number -> default \"10\"]\nrule r: x",
1662 SourceType::Labeled("t.lemma"),
1663 );
1664 assert!(
1665 result.is_err(),
1666 "must reject text literal \"10\" as default for number type"
1667 );
1668 }
1669
1670 #[test]
1671 fn planning_rejects_invalid_boolean_default() {
1672 let mut engine = Engine::new();
1673 let result = engine.load(
1674 "spec t\ndata x: [boolean -> default \"maybe\"]\nrule r: x",
1675 SourceType::Labeled("t.lemma"),
1676 );
1677 assert!(
1678 result.is_err(),
1679 "must reject non-boolean default on boolean type"
1680 );
1681 }
1682
1683 #[test]
1684 fn planning_rejects_invalid_named_type_default() {
1685 let mut engine = Engine::new();
1687 let result = engine.load("spec t\ndata custom: number -> minimum 0\ndata x: [custom -> default \"abc\"]\nrule r: x", SourceType::Labeled("t.lemma",));
1688 assert!(
1689 result.is_err(),
1690 "must reject non-numeric default on named number type"
1691 );
1692 }
1693}