1use crate::evaluation::Evaluator;
2use crate::parsing::ast::{DateTimeValue, LemmaSpec};
3use crate::planning::SpecSchema;
4use crate::spec_id;
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, Clone, PartialEq, Eq)]
34pub(crate) enum TemporalBound {
35 NegInf,
36 At(DateTimeValue),
37 PosInf,
38}
39
40impl PartialOrd for TemporalBound {
41 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
42 Some(self.cmp(other))
43 }
44}
45
46impl Ord for TemporalBound {
47 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
48 use std::cmp::Ordering;
49 match (self, other) {
50 (TemporalBound::NegInf, TemporalBound::NegInf) => Ordering::Equal,
51 (TemporalBound::NegInf, _) => Ordering::Less,
52 (_, TemporalBound::NegInf) => Ordering::Greater,
53 (TemporalBound::PosInf, TemporalBound::PosInf) => Ordering::Equal,
54 (TemporalBound::PosInf, _) => Ordering::Greater,
55 (_, TemporalBound::PosInf) => Ordering::Less,
56 (TemporalBound::At(a), TemporalBound::At(b)) => a.cmp(b),
57 }
58 }
59}
60
61impl TemporalBound {
62 pub(crate) fn from_start(opt: Option<&DateTimeValue>) -> Self {
64 match opt {
65 None => TemporalBound::NegInf,
66 Some(d) => TemporalBound::At(d.clone()),
67 }
68 }
69
70 pub(crate) fn from_end(opt: Option<&DateTimeValue>) -> Self {
72 match opt {
73 None => TemporalBound::PosInf,
74 Some(d) => TemporalBound::At(d.clone()),
75 }
76 }
77
78 pub(crate) fn to_start(&self) -> Option<DateTimeValue> {
80 match self {
81 TemporalBound::NegInf => None,
82 TemporalBound::At(d) => Some(d.clone()),
83 TemporalBound::PosInf => {
84 unreachable!("BUG: PosInf cannot represent a start bound")
85 }
86 }
87 }
88
89 pub(crate) fn to_end(&self) -> Option<DateTimeValue> {
91 match self {
92 TemporalBound::NegInf => {
93 unreachable!("BUG: NegInf cannot represent an end bound")
94 }
95 TemporalBound::At(d) => Some(d.clone()),
96 TemporalBound::PosInf => None,
97 }
98 }
99}
100
101#[derive(Debug, Default)]
108pub struct Context {
110 specs: BTreeMap<String, BTreeMap<Option<DateTimeValue>, Arc<LemmaSpec>>>,
111}
112
113impl Context {
114 pub fn new() -> Self {
115 Self {
116 specs: BTreeMap::new(),
117 }
118 }
119
120 pub(crate) fn specs_for_name(&self, name: &str) -> Vec<Arc<LemmaSpec>> {
121 self.specs
122 .get(name)
123 .map(|m| m.values().cloned().collect())
124 .unwrap_or_default()
125 }
126
127 pub fn get_spec_effective_from(
132 &self,
133 name: &str,
134 effective_from: Option<&DateTimeValue>,
135 ) -> Option<Arc<LemmaSpec>> {
136 let key = effective_from.cloned();
137 self.specs.get(name).and_then(|m| m.get(&key).cloned())
138 }
139
140 pub fn get_spec(&self, name: &str, effective: &DateTimeValue) -> Option<Arc<LemmaSpec>> {
149 let versions = self.specs_for_name(name);
150 if versions.is_empty() {
151 return None;
152 }
153
154 for (i, spec) in versions.iter().enumerate() {
155 let from_ok = spec
156 .effective_from()
157 .map(|f| *effective >= *f)
158 .unwrap_or(true);
159 if !from_ok {
160 continue;
161 }
162
163 let effective_to: Option<&DateTimeValue> =
164 versions.get(i + 1).and_then(|next| next.effective_from());
165 let to_ok = effective_to.map(|end| *effective < *end).unwrap_or(true);
166
167 if to_ok {
168 return Some(spec.clone());
169 }
170 }
171
172 None
173 }
174
175 pub fn iter(&self) -> impl Iterator<Item = Arc<LemmaSpec>> + '_ {
176 self.specs.values().flat_map(|m| m.values().cloned())
177 }
178
179 pub fn insert_spec(&mut self, spec: Arc<LemmaSpec>, from_registry: bool) -> Result<(), Error> {
185 if spec.from_registry && !from_registry {
186 return Err(Error::validation_with_context(
187 format!(
188 "Spec '{}' uses '@' registry prefix, which is reserved for dependencies",
189 spec.name
190 ),
191 None,
192 Some("Remove the '@' prefix, or load this file as a dependency."),
193 Some(Arc::clone(&spec)),
194 None,
195 ));
196 }
197
198 if from_registry && !spec.from_registry {
199 return Err(Error::validation_with_context(
200 format!(
201 "Registry bundle contains spec '{}' without '@' prefix; \
202 all specs in a registry bundle must use '@'-prefixed names \
203 to avoid conflicts with local specs",
204 spec.name
205 ),
206 None,
207 Some("Prefix the spec name with '@' (e.g. spec @org/project/name)."),
208 Some(Arc::clone(&spec)),
209 None,
210 ));
211 }
212
213 let key = spec.effective_from().cloned();
214 if self
215 .specs
216 .get(&spec.name)
217 .is_some_and(|m| m.contains_key(&key))
218 {
219 return Err(Error::validation_with_context(
220 format!(
221 "Duplicate spec '{}' (same name and effective_from already in context)",
222 spec.name
223 ),
224 None,
225 None::<String>,
226 Some(Arc::clone(&spec)),
227 None,
228 ));
229 }
230
231 self.specs
232 .entry(spec.name.clone())
233 .or_default()
234 .insert(key, spec);
235 Ok(())
236 }
237
238 pub fn remove_spec(&mut self, spec: &Arc<LemmaSpec>) -> bool {
239 let key = spec.effective_from().cloned();
240 self.specs
241 .get_mut(&spec.name)
242 .and_then(|m| m.remove(&key))
243 .is_some()
244 }
245
246 #[cfg(test)]
247 pub(crate) fn len(&self) -> usize {
248 self.specs.values().map(|m| m.len()).sum()
249 }
250
251 pub fn effective_range(
258 &self,
259 spec: &Arc<LemmaSpec>,
260 ) -> (Option<DateTimeValue>, Option<DateTimeValue>) {
261 let from = spec.effective_from().cloned();
262 let versions = self.specs_for_name(&spec.name);
263 let pos = versions
264 .iter()
265 .position(|v| Arc::ptr_eq(v, spec))
266 .unwrap_or_else(|| {
267 unreachable!(
268 "BUG: effective_range called with spec '{}' not in context",
269 spec.name
270 )
271 });
272 let to = versions
273 .get(pos + 1)
274 .and_then(|next| next.effective_from().cloned());
275 (from, to)
276 }
277
278 pub fn version_boundaries(&self, name: &str) -> Vec<DateTimeValue> {
281 self.specs_for_name(name)
282 .iter()
283 .filter_map(|s| s.effective_from().cloned())
284 .collect()
285 }
286
287 pub fn dep_coverage_gaps(
293 &self,
294 dep_name: &str,
295 required_from: Option<&DateTimeValue>,
296 required_to: Option<&DateTimeValue>,
297 ) -> Vec<(Option<DateTimeValue>, Option<DateTimeValue>)> {
298 let versions = self.specs_for_name(dep_name);
299 if versions.is_empty() {
300 return vec![(required_from.cloned(), required_to.cloned())];
301 }
302
303 let req_start = TemporalBound::from_start(required_from);
304 let req_end = TemporalBound::from_end(required_to);
305
306 let intervals: Vec<(TemporalBound, TemporalBound)> = versions
307 .iter()
308 .enumerate()
309 .map(|(i, v)| {
310 let start = TemporalBound::from_start(v.effective_from());
311 let end = match versions.get(i + 1).and_then(|next| next.effective_from()) {
312 Some(next_from) => TemporalBound::At(next_from.clone()),
313 None => TemporalBound::PosInf,
314 };
315 (start, end)
316 })
317 .collect();
318
319 let mut gaps = Vec::new();
320 let mut cursor = req_start.clone();
321
322 for (v_start, v_end) in &intervals {
323 if cursor >= req_end {
324 break;
325 }
326
327 if *v_end <= cursor {
328 continue;
329 }
330
331 if *v_start > cursor {
332 let gap_end = std::cmp::min(v_start.clone(), req_end.clone());
333 if cursor < gap_end {
334 gaps.push((cursor.to_start(), gap_end.to_end()));
335 }
336 }
337
338 if *v_end > cursor {
339 cursor = v_end.clone();
340 }
341 }
342
343 if cursor < req_end {
344 gaps.push((cursor.to_start(), req_end.to_end()));
345 }
346
347 gaps
348 }
349}
350
351fn find_slice_plan<'a>(
357 plans: &'a [crate::planning::ExecutionPlan],
358 effective: &DateTimeValue,
359) -> Option<&'a crate::planning::ExecutionPlan> {
360 for plan in plans {
361 let from_ok = plan
362 .valid_from
363 .as_ref()
364 .map(|f| *effective >= *f)
365 .unwrap_or(true);
366 let to_ok = plan
367 .valid_to
368 .as_ref()
369 .map(|t| *effective < *t)
370 .unwrap_or(true);
371 if from_ok && to_ok {
372 return Some(plan);
373 }
374 }
375 None
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
382pub enum SourceType<'a> {
383 Labeled(&'a str),
385 Inline,
387 Dependency(&'a str),
389}
390
391impl SourceType<'_> {
392 pub const INLINE_KEY: &'static str = "inline source (no path)";
394
395 fn storage_key(self) -> Result<String, Vec<Error>> {
396 match self {
397 SourceType::Labeled(s) => {
398 if s.trim().is_empty() {
399 return Err(vec![Error::request(
400 "source label must be non-empty, or use SourceType::Inline",
401 None::<String>,
402 )]);
403 }
404 Ok(s.to_string())
405 }
406 SourceType::Inline => Ok(Self::INLINE_KEY.to_string()),
407 SourceType::Dependency(s) => Ok(s.to_string()),
408 }
409 }
410}
411
412pub struct Engine {
422 execution_plans: HashMap<Arc<LemmaSpec>, Vec<crate::planning::ExecutionPlan>>,
423 plan_hash_registry: crate::planning::PlanHashRegistry,
424 specs: Context,
425 sources: HashMap<String, String>,
426 evaluator: Evaluator,
427 limits: ResourceLimits,
428 total_expression_count: usize,
429}
430
431impl Default for Engine {
432 fn default() -> Self {
433 Self {
434 execution_plans: HashMap::new(),
435 plan_hash_registry: crate::planning::PlanHashRegistry::default(),
436 specs: Context::new(),
437 sources: HashMap::new(),
438 evaluator: Evaluator,
439 limits: ResourceLimits::default(),
440 total_expression_count: 0,
441 }
442 }
443}
444
445impl Engine {
446 pub fn new() -> Self {
447 Self::default()
448 }
449
450 pub fn sources(&self) -> &HashMap<String, String> {
452 &self.sources
453 }
454
455 pub fn with_limits(limits: ResourceLimits) -> Self {
457 Self {
458 execution_plans: HashMap::new(),
459 plan_hash_registry: crate::planning::PlanHashRegistry::default(),
460 specs: Context::new(),
461 sources: HashMap::new(),
462 evaluator: Evaluator,
463 limits,
464 total_expression_count: 0,
465 }
466 }
467
468 pub fn load(&mut self, code: &str, source: SourceType<'_>) -> Result<(), Errors> {
471 let from_registry = matches!(source, SourceType::Dependency(_));
472 let mut files = HashMap::new();
473 let key = source.storage_key().map_err(|errs| Errors {
474 errors: errs,
475 sources: HashMap::new(),
476 })?;
477 files.insert(key, code.to_string());
478 self.add_files_inner(files, from_registry)
479 }
480
481 #[cfg(not(target_arch = "wasm32"))]
485 pub fn load_from_paths<P: AsRef<Path>>(
486 &mut self,
487 paths: &[P],
488 from_registry: bool,
489 ) -> Result<(), Errors> {
490 use std::fs;
491
492 let mut files = HashMap::new();
493 let mut seen = HashSet::<String>::new();
494
495 for path in paths {
496 let path = path.as_ref();
497 if path.is_file() {
498 if !path.extension().map(|e| e == "lemma").unwrap_or(false) {
500 continue;
501 }
502 let key = path.display().to_string();
503 if seen.contains(&key) {
504 continue;
505 }
506 seen.insert(key.clone());
507 let content = fs::read_to_string(path).map_err(|e| Errors {
508 errors: vec![Error::request(
509 format!("Cannot read '{}': {}", path.display(), e),
510 None::<String>,
511 )],
512 sources: HashMap::new(),
513 })?;
514 files.insert(key, content);
515 } else if path.is_dir() {
516 let read_dir = fs::read_dir(path).map_err(|e| Errors {
517 errors: vec![Error::request(
518 format!("Cannot read directory '{}': {}", path.display(), e),
519 None::<String>,
520 )],
521 sources: HashMap::new(),
522 })?;
523 for entry in read_dir.filter_map(Result::ok) {
524 let p = entry.path();
525 if !p.is_file() || !p.extension().map(|e| e == "lemma").unwrap_or(false) {
526 continue;
527 }
528 let key = p.display().to_string();
529 if seen.contains(&key) {
530 continue;
531 }
532 seen.insert(key.clone());
533 let Ok(content) = fs::read_to_string(&p) else {
534 continue;
535 };
536 files.insert(key, content);
537 }
538 }
539 }
540
541 self.add_files_inner(files, from_registry)
542 }
543
544 fn add_files_inner(
545 &mut self,
546 files: HashMap<String, String>,
547 from_registry: bool,
548 ) -> Result<(), Errors> {
549 let limits = &self.limits;
550 if files.len() > limits.max_files {
551 return Err(Errors {
552 errors: vec![Error::resource_limit_exceeded(
553 "max_files",
554 limits.max_files.to_string(),
555 files.len().to_string(),
556 "Reduce the number of paths or files",
557 None::<crate::Source>,
558 None,
559 None,
560 )],
561 sources: files,
562 });
563 }
564 let total_loaded_bytes: usize = files.values().map(|s| s.len()).sum();
565 if total_loaded_bytes > limits.max_loaded_bytes {
566 return Err(Errors {
567 errors: vec![Error::resource_limit_exceeded(
568 "max_loaded_bytes",
569 limits.max_loaded_bytes.to_string(),
570 total_loaded_bytes.to_string(),
571 "Load fewer or smaller files",
572 None::<crate::Source>,
573 None,
574 None,
575 )],
576 sources: files,
577 });
578 }
579 for code in files.values() {
580 if code.len() > limits.max_file_size_bytes {
581 return Err(Errors {
582 errors: vec![Error::resource_limit_exceeded(
583 "max_file_size_bytes",
584 limits.max_file_size_bytes.to_string(),
585 code.len().to_string(),
586 "Use a smaller file or increase limit",
587 None::<crate::Source>,
588 None,
589 None,
590 )],
591 sources: files,
592 });
593 }
594 }
595
596 let mut errors: Vec<Error> = Vec::new();
597
598 for (source_id, code) in &files {
599 match parse(code, source_id, &self.limits) {
600 Ok(result) => {
601 self.total_expression_count += result.expression_count;
602 if self.total_expression_count > self.limits.max_total_expression_count {
603 errors.push(Error::resource_limit_exceeded(
604 "max_total_expression_count",
605 self.limits.max_total_expression_count.to_string(),
606 self.total_expression_count.to_string(),
607 "Split logic across fewer files or reduce expression complexity",
608 None::<crate::Source>,
609 None,
610 None,
611 ));
612 return Err(Errors {
613 errors,
614 sources: files,
615 });
616 }
617 let new_specs = result.specs;
618 for spec in new_specs {
619 let attribute = spec.attribute.clone().unwrap_or_else(|| spec.name.clone());
620 let start_line = spec.start_line;
621
622 if from_registry {
623 let bare_refs =
624 crate::planning::validation::collect_bare_registry_refs(&spec);
625 if !bare_refs.is_empty() {
626 let source = crate::Source::new(
627 &attribute,
628 crate::parsing::ast::Span {
629 start: 0,
630 end: 0,
631 line: start_line,
632 col: 0,
633 },
634 );
635 errors.push(Error::validation(
636 format!(
637 "Registry spec '{}' contains references without '@' prefix: {}. \
638 The registry must rewrite all references to use '@'-prefixed names",
639 spec.name,
640 bare_refs.join(", ")
641 ),
642 Some(source),
643 Some(
644 "The registry must prefix all spec references with '@' \
645 before serving the bundle.",
646 ),
647 ));
648 continue;
649 }
650 }
651
652 match self.specs.insert_spec(Arc::new(spec), from_registry) {
653 Ok(()) => {
654 self.sources.insert(attribute, code.clone());
655 }
656 Err(e) => {
657 let source = crate::Source::new(
658 &attribute,
659 crate::parsing::ast::Span {
660 start: 0,
661 end: 0,
662 line: start_line,
663 col: 0,
664 },
665 );
666 errors.push(Error::validation(
667 e.to_string(),
668 Some(source),
669 None::<String>,
670 ));
671 }
672 }
673 }
674 }
675 Err(e) => errors.push(e),
676 }
677 }
678
679 let planning_result = crate::planning::plan(&self.specs, self.sources.clone());
680 self.plan_hash_registry = planning_result.plan_hash_registry.clone();
681 for spec_result in &planning_result.per_spec {
682 self.execution_plans
683 .insert(Arc::clone(&spec_result.spec), spec_result.plans.clone());
684 }
685 errors.extend(planning_result.global_errors);
686 for spec_result in planning_result.per_spec {
687 for err in spec_result.errors {
688 errors.push(err.with_spec_context(Arc::clone(&spec_result.spec)));
689 }
690 }
691
692 if errors.is_empty() {
693 Ok(())
694 } else {
695 Err(Errors {
696 errors,
697 sources: files,
698 })
699 }
700 }
701
702 fn spec_not_found_error(&self, spec_name: &str, effective: &DateTimeValue) -> Error {
706 let versions = self.specs.specs_for_name(spec_name);
707 let msg = if versions.is_empty() {
708 format!("Spec '{}' not found", spec_name)
709 } else {
710 let version_list: Vec<String> = versions
711 .iter()
712 .map(|s| match s.effective_from() {
713 Some(dt) => format!(" {} (effective from {})", s.name, dt),
714 None => format!(" {} (no effective_from)", s.name),
715 })
716 .collect();
717 format!(
718 "Spec '{}' not found for effective {}. Available temporal versions:\n{}",
719 spec_name,
720 effective,
721 version_list.join("\n")
722 )
723 };
724 Error::request_not_found(msg, None::<String>)
725 }
726
727 pub fn get_spec(
729 &self,
730 spec_id: &str,
731 effective: Option<&DateTimeValue>,
732 ) -> Result<Arc<LemmaSpec>, Error> {
733 let (name, hash_pin) = spec_id::parse_spec_id(spec_id)?;
734 let eff_val = effective.cloned().unwrap_or_else(DateTimeValue::now);
735
736 if let Some(pin) = &hash_pin {
737 let arc = self
738 .plan_hash_registry
739 .get_by_pin(&name, pin)
740 .cloned()
741 .ok_or_else(|| {
742 Error::request_not_found(
743 format!("No spec '{}' found with plan hash {}", name, pin),
744 Some("Use lemma schema <spec> --hash to get the current plan hash"),
745 )
746 })?;
747
748 if effective.is_some() {
749 let slice_plans = self.execution_plans.get(&arc).unwrap_or_else(|| {
750 panic!(
751 "BUG: spec '{}' from pin registry has no execution plan",
752 arc.name
753 )
754 });
755 let plan = slice_plans
756 .iter()
757 .find(|p| p.plan_hash().trim().eq_ignore_ascii_case(pin.trim()))
758 .ok_or_else(|| {
759 Error::request_not_found(
760 format!("No plan with hash {} for spec '{}'", pin, name),
761 Some("Use lemma schema <spec> --hash to get the current plan hash"),
762 )
763 })?;
764 let from_ok = plan
765 .valid_from
766 .as_ref()
767 .map(|f| eff_val >= *f)
768 .unwrap_or(true);
769 let to_ok = plan.valid_to.as_ref().map(|t| eff_val < *t).unwrap_or(true);
770 if !from_ok || !to_ok {
771 return Err(Error::request_not_found(
772 format!(
773 "Effective {} is outside the temporal range of spec '{}'~{} ([{:?}, {:?}))",
774 eff_val, name, pin, plan.valid_from, plan.valid_to
775 ),
776 Some("Use an effective datetime within the pinned spec's slice"),
777 ));
778 }
779 }
780
781 return Ok(arc);
782 }
783
784 self.specs
785 .get_spec(&name, &eff_val)
786 .ok_or_else(|| self.spec_not_found_error(&name, &eff_val))
787 }
788
789 pub fn get_plan_hash(
791 &self,
792 spec_id: &str,
793 effective: &DateTimeValue,
794 ) -> Result<Option<String>, Error> {
795 Ok(Some(self.get_plan(spec_id, Some(effective))?.plan_hash()))
796 }
797
798 pub fn remove(
800 &mut self,
801 spec_id: &str,
802 effective: Option<&DateTimeValue>,
803 ) -> Result<(), Error> {
804 let arc = self.get_spec(spec_id, effective)?;
805 self.execution_plans.remove(&arc);
806 self.specs.remove_spec(&arc);
807 Ok(())
808 }
809
810 pub fn list_specs(&self) -> Vec<Arc<LemmaSpec>> {
812 self.specs.iter().collect()
813 }
814
815 pub fn list_specs_effective(&self, effective: &DateTimeValue) -> Vec<Arc<LemmaSpec>> {
817 let mut seen_names = std::collections::HashSet::new();
818 let mut result = Vec::new();
819 for spec in self.specs.iter() {
820 if seen_names.contains(&spec.name) {
821 continue;
822 }
823 if let Some(active) = self.specs.get_spec(&spec.name, effective) {
824 if seen_names.insert(active.name.clone()) {
825 result.push(active);
826 }
827 }
828 }
829 result.sort_by(|a, b| a.name.cmp(&b.name));
830 result
831 }
832
833 pub fn schema(
835 &self,
836 spec: &str,
837 effective: Option<&DateTimeValue>,
838 ) -> Result<SpecSchema, Error> {
839 Ok(self.get_plan(spec, effective)?.schema())
840 }
841
842 pub fn get_plan(
848 &self,
849 spec_id: &str,
850 effective: Option<&DateTimeValue>,
851 ) -> Result<&crate::planning::ExecutionPlan, Error> {
852 let (name, hash_pin) = spec_id::parse_spec_id(spec_id)?;
853 let eff_val = effective.cloned().unwrap_or_else(DateTimeValue::now);
854
855 if let Some(pin) = &hash_pin {
856 let arc = self
857 .plan_hash_registry
858 .get_by_pin(&name, pin)
859 .cloned()
860 .ok_or_else(|| {
861 Error::request_not_found(
862 format!("No spec '{}' found with plan hash {}", name, pin),
863 Some("Use lemma schema <spec> --hash to get the current plan hash"),
864 )
865 })?;
866
867 let slice_plans = self.execution_plans.get(&arc).unwrap_or_else(|| {
868 panic!(
869 "BUG: spec '{}' from pin registry has no execution plan",
870 arc.name
871 )
872 });
873
874 let plan = slice_plans
875 .iter()
876 .find(|p| p.plan_hash().trim().eq_ignore_ascii_case(pin.trim()))
877 .ok_or_else(|| {
878 Error::request_not_found(
879 format!("No plan with hash {} for spec '{}'", pin, name),
880 Some("Use lemma schema <spec> --hash to get the current plan hash"),
881 )
882 })?;
883
884 if effective.is_some() {
885 let from_ok = plan
886 .valid_from
887 .as_ref()
888 .map(|f| eff_val >= *f)
889 .unwrap_or(true);
890 let to_ok = plan.valid_to.as_ref().map(|t| eff_val < *t).unwrap_or(true);
891
892 if !from_ok || !to_ok {
893 return Err(Error::request_not_found(
894 format!(
895 "Effective {} is outside the temporal range of spec '{}'~{} ([{:?}, {:?}))",
896 eff_val, name, pin, plan.valid_from, plan.valid_to
897 ),
898 Some("Use an effective datetime within the pinned spec's slice"),
899 ));
900 }
901 }
902
903 return Ok(plan);
904 }
905
906 let arc = self
907 .specs
908 .get_spec(&name, &eff_val)
909 .ok_or_else(|| self.spec_not_found_error(&name, &eff_val))?;
910
911 let slice_plans = self.execution_plans.get(&arc).unwrap_or_else(|| {
912 panic!(
913 "BUG: resolved spec '{}' has no execution plan (invariant: every loaded spec is planned)",
914 arc.name
915 )
916 });
917
918 Ok(find_slice_plan(slice_plans, &eff_val).unwrap_or_else(|| {
919 panic!(
920 "BUG: spec '{}' has {} slice plan(s) but none covers effective={} — every loaded spec has at least one plan covering its effective range",
921 arc.name, slice_plans.len(), eff_val
922 )
923 }))
924 }
925
926 pub fn run_plan(
931 &self,
932 plan: &crate::planning::ExecutionPlan,
933 effective: &DateTimeValue,
934 fact_values: HashMap<String, String>,
935 record_operations: bool,
936 ) -> Result<Response, Error> {
937 let plan = plan.clone().with_fact_values(fact_values, &self.limits)?;
938 self.evaluate_plan(plan, effective, record_operations)
939 }
940
941 pub fn run(
946 &self,
947 spec_id: &str,
948 effective: Option<&DateTimeValue>,
949 fact_values: HashMap<String, String>,
950 record_operations: bool,
951 ) -> Result<Response, Error> {
952 let eff_val = effective.cloned().unwrap_or_else(DateTimeValue::now);
953 let plan = self.get_plan(spec_id, effective)?;
954 self.run_plan(plan, &eff_val, fact_values, record_operations)
955 }
956
957 pub fn invert(
962 &self,
963 spec_name: &str,
964 effective: &DateTimeValue,
965 rule_name: &str,
966 target: crate::inversion::Target,
967 values: HashMap<String, String>,
968 ) -> Result<crate::InversionResponse, Error> {
969 let base_plan = self.get_plan(spec_name, Some(effective))?;
970
971 let plan = base_plan.clone().with_fact_values(values, &self.limits)?;
972 let provided_facts: std::collections::HashSet<_> = plan
973 .facts
974 .iter()
975 .filter(|(_, d)| d.value().is_some())
976 .map(|(p, _)| p.clone())
977 .collect();
978
979 crate::inversion::invert(rule_name, target, &plan, &provided_facts)
980 }
981
982 fn evaluate_plan(
983 &self,
984 plan: crate::planning::ExecutionPlan,
985 effective: &DateTimeValue,
986 record_operations: bool,
987 ) -> Result<Response, Error> {
988 let now_semantic = crate::planning::semantics::date_time_to_semantic(effective);
989 let now_literal = crate::planning::semantics::LiteralValue {
990 value: crate::planning::semantics::ValueKind::Date(now_semantic),
991 lemma_type: crate::planning::semantics::primitive_date().clone(),
992 };
993 Ok(self
994 .evaluator
995 .evaluate(&plan, now_literal, record_operations))
996 }
997}
998
999#[cfg(test)]
1000mod tests {
1001 use super::*;
1002 use rust_decimal::Decimal;
1003 use std::str::FromStr;
1004
1005 fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
1006 DateTimeValue {
1007 year,
1008 month,
1009 day,
1010 hour: 0,
1011 minute: 0,
1012 second: 0,
1013 microsecond: 0,
1014 timezone: None,
1015 }
1016 }
1017
1018 fn make_spec(name: &str) -> LemmaSpec {
1019 LemmaSpec::new(name.to_string())
1020 }
1021
1022 fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
1023 let mut spec = LemmaSpec::new(name.to_string());
1024 spec.effective_from = effective_from;
1025 spec
1026 }
1027
1028 #[test]
1032 fn list_specs_order_is_name_then_effective_from_ascending() {
1033 let mut ctx = Context::new();
1034 let s_2026 = Arc::new(make_spec_with_range("mortgage", Some(date(2026, 1, 1))));
1035 let s_2025 = Arc::new(make_spec_with_range("mortgage", Some(date(2025, 1, 1))));
1036 ctx.insert_spec(Arc::clone(&s_2026), false).unwrap();
1037 ctx.insert_spec(Arc::clone(&s_2025), false).unwrap();
1038 let listed: Vec<_> = ctx.iter().collect();
1039 assert_eq!(listed.len(), 2);
1040 assert_eq!(listed[0].effective_from(), Some(&date(2025, 1, 1)));
1041 assert_eq!(listed[1].effective_from(), Some(&date(2026, 1, 1)));
1042 }
1043
1044 #[test]
1047 fn effective_range_unbounded_single_version() {
1048 let mut ctx = Context::new();
1049 let spec = Arc::new(make_spec("a"));
1050 ctx.insert_spec(Arc::clone(&spec), false).unwrap();
1051
1052 let (from, to) = ctx.effective_range(&spec);
1053 assert_eq!(from, None);
1054 assert_eq!(to, None);
1055 }
1056
1057 #[test]
1058 fn effective_range_soft_end_from_next_version() {
1059 let mut ctx = Context::new();
1060 let v1 = Arc::new(make_spec_with_range("a", Some(date(2025, 1, 1))));
1061 let v2 = Arc::new(make_spec_with_range("a", Some(date(2025, 6, 1))));
1062 ctx.insert_spec(Arc::clone(&v1), false).unwrap();
1063 ctx.insert_spec(Arc::clone(&v2), false).unwrap();
1064
1065 let (from, to) = ctx.effective_range(&v1);
1066 assert_eq!(from, Some(date(2025, 1, 1)));
1067 assert_eq!(to, Some(date(2025, 6, 1)));
1068
1069 let (from, to) = ctx.effective_range(&v2);
1070 assert_eq!(from, Some(date(2025, 6, 1)));
1071 assert_eq!(to, None);
1072 }
1073
1074 #[test]
1075 fn effective_range_unbounded_start_with_successor() {
1076 let mut ctx = Context::new();
1077 let v1 = Arc::new(make_spec("a"));
1078 let v2 = Arc::new(make_spec_with_range("a", Some(date(2025, 3, 1))));
1079 ctx.insert_spec(Arc::clone(&v1), false).unwrap();
1080 ctx.insert_spec(Arc::clone(&v2), false).unwrap();
1081
1082 let (from, to) = ctx.effective_range(&v1);
1083 assert_eq!(from, None);
1084 assert_eq!(to, Some(date(2025, 3, 1)));
1085 }
1086
1087 #[test]
1090 fn version_boundaries_single_unversioned() {
1091 let mut ctx = Context::new();
1092 ctx.insert_spec(Arc::new(make_spec("a")), false).unwrap();
1093
1094 assert!(ctx.version_boundaries("a").is_empty());
1095 }
1096
1097 #[test]
1098 fn version_boundaries_multiple_versions() {
1099 let mut ctx = Context::new();
1100 ctx.insert_spec(Arc::new(make_spec("a")), false).unwrap();
1101 ctx.insert_spec(
1102 Arc::new(make_spec_with_range("a", Some(date(2025, 3, 1)))),
1103 false,
1104 )
1105 .unwrap();
1106 ctx.insert_spec(
1107 Arc::new(make_spec_with_range("a", Some(date(2025, 6, 1)))),
1108 false,
1109 )
1110 .unwrap();
1111
1112 let boundaries = ctx.version_boundaries("a");
1113 assert_eq!(boundaries, vec![date(2025, 3, 1), date(2025, 6, 1)]);
1114 }
1115
1116 #[test]
1117 fn version_boundaries_nonexistent_name() {
1118 let ctx = Context::new();
1119 assert!(ctx.version_boundaries("nope").is_empty());
1120 }
1121
1122 #[test]
1125 fn dep_coverage_no_versions_is_full_gap() {
1126 let ctx = Context::new();
1127 let gaps =
1128 ctx.dep_coverage_gaps("missing", Some(&date(2025, 1, 1)), Some(&date(2025, 6, 1)));
1129 assert_eq!(gaps, vec![(Some(date(2025, 1, 1)), Some(date(2025, 6, 1)))]);
1130 }
1131
1132 #[test]
1133 fn dep_coverage_single_unbounded_version_covers_everything() {
1134 let mut ctx = Context::new();
1135 ctx.insert_spec(Arc::new(make_spec("dep")), false).unwrap();
1136
1137 let gaps = ctx.dep_coverage_gaps("dep", None, None);
1138 assert!(gaps.is_empty());
1139
1140 let gaps = ctx.dep_coverage_gaps("dep", Some(&date(2025, 1, 1)), Some(&date(2025, 12, 1)));
1141 assert!(gaps.is_empty());
1142 }
1143
1144 #[test]
1145 fn dep_coverage_single_version_with_from_leaves_leading_gap() {
1146 let mut ctx = Context::new();
1147 ctx.insert_spec(
1148 Arc::new(make_spec_with_range("dep", Some(date(2025, 3, 1)))),
1149 false,
1150 )
1151 .unwrap();
1152
1153 let gaps = ctx.dep_coverage_gaps("dep", None, None);
1154 assert_eq!(gaps, vec![(None, Some(date(2025, 3, 1)))]);
1155 }
1156
1157 #[test]
1158 fn dep_coverage_continuous_versions_no_gaps() {
1159 let mut ctx = Context::new();
1160 ctx.insert_spec(
1161 Arc::new(make_spec_with_range("dep", Some(date(2025, 1, 1)))),
1162 false,
1163 )
1164 .unwrap();
1165 ctx.insert_spec(
1166 Arc::new(make_spec_with_range("dep", Some(date(2025, 6, 1)))),
1167 false,
1168 )
1169 .unwrap();
1170
1171 let gaps = ctx.dep_coverage_gaps("dep", Some(&date(2025, 1, 1)), Some(&date(2025, 12, 1)));
1172 assert!(gaps.is_empty());
1173 }
1174
1175 #[test]
1176 fn dep_coverage_dep_starts_after_required_start() {
1177 let mut ctx = Context::new();
1178 ctx.insert_spec(
1179 Arc::new(make_spec_with_range("dep", Some(date(2025, 6, 1)))),
1180 false,
1181 )
1182 .unwrap();
1183
1184 let gaps = ctx.dep_coverage_gaps("dep", Some(&date(2025, 1, 1)), Some(&date(2025, 12, 1)));
1185 assert_eq!(gaps, vec![(Some(date(2025, 1, 1)), Some(date(2025, 6, 1)))]);
1186 }
1187
1188 #[test]
1189 fn dep_coverage_unbounded_required_range() {
1190 let mut ctx = Context::new();
1191 ctx.insert_spec(
1192 Arc::new(make_spec_with_range("dep", Some(date(2025, 6, 1)))),
1193 false,
1194 )
1195 .unwrap();
1196
1197 let gaps = ctx.dep_coverage_gaps("dep", None, None);
1198 assert_eq!(gaps, vec![(None, Some(date(2025, 6, 1)))]);
1199 }
1200
1201 fn add_lemma_code_blocking(
1202 engine: &mut Engine,
1203 code: &str,
1204 source: &str,
1205 ) -> Result<(), Errors> {
1206 engine.load(code, SourceType::Labeled(source))
1207 }
1208
1209 #[test]
1210 fn get_spec_resolves_temporal_version_by_effective() {
1211 let mut engine = Engine::new();
1212 add_lemma_code_blocking(
1213 &mut engine,
1214 r#"
1215 spec pricing 2025-01-01
1216 fact x: 1
1217 rule r: x
1218 "#,
1219 "a.lemma",
1220 )
1221 .unwrap();
1222 add_lemma_code_blocking(
1223 &mut engine,
1224 r#"
1225 spec pricing 2025-06-01
1226 fact x: 2
1227 rule r: x
1228 "#,
1229 "b.lemma",
1230 )
1231 .unwrap();
1232
1233 let jan = DateTimeValue {
1234 year: 2025,
1235 month: 1,
1236 day: 15,
1237 hour: 0,
1238 minute: 0,
1239 second: 0,
1240 microsecond: 0,
1241 timezone: None,
1242 };
1243 let jul = DateTimeValue {
1244 year: 2025,
1245 month: 7,
1246 day: 1,
1247 hour: 0,
1248 minute: 0,
1249 second: 0,
1250 microsecond: 0,
1251 timezone: None,
1252 };
1253
1254 let v1 = DateTimeValue {
1255 year: 2025,
1256 month: 1,
1257 day: 1,
1258 hour: 0,
1259 minute: 0,
1260 second: 0,
1261 microsecond: 0,
1262 timezone: None,
1263 };
1264 let v2 = DateTimeValue {
1265 year: 2025,
1266 month: 6,
1267 day: 1,
1268 hour: 0,
1269 minute: 0,
1270 second: 0,
1271 microsecond: 0,
1272 timezone: None,
1273 };
1274
1275 let s_jan = engine.get_spec("pricing", Some(&jan)).expect("jan spec");
1276 let s_jul = engine.get_spec("pricing", Some(&jul)).expect("jul spec");
1277 assert_eq!(s_jan.effective_from(), Some(&v1));
1278 assert_eq!(s_jul.effective_from(), Some(&v2));
1279 }
1280
1281 #[test]
1282 fn test_evaluate_spec_all_rules() {
1283 let mut engine = Engine::new();
1284 add_lemma_code_blocking(
1285 &mut engine,
1286 r#"
1287 spec test
1288 fact x: 10
1289 fact y: 5
1290 rule sum: x + y
1291 rule product: x * y
1292 "#,
1293 "test.lemma",
1294 )
1295 .unwrap();
1296
1297 let now = DateTimeValue::now();
1298 let response = engine
1299 .run("test", Some(&now), HashMap::new(), false)
1300 .unwrap();
1301 assert_eq!(response.results.len(), 2);
1302
1303 let sum_result = response
1304 .results
1305 .values()
1306 .find(|r| r.rule.name == "sum")
1307 .unwrap();
1308 assert_eq!(
1309 sum_result.result,
1310 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1311 Decimal::from_str("15").unwrap()
1312 )))
1313 );
1314
1315 let product_result = response
1316 .results
1317 .values()
1318 .find(|r| r.rule.name == "product")
1319 .unwrap();
1320 assert_eq!(
1321 product_result.result,
1322 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1323 Decimal::from_str("50").unwrap()
1324 )))
1325 );
1326 }
1327
1328 #[test]
1329 fn test_evaluate_empty_facts() {
1330 let mut engine = Engine::new();
1331 add_lemma_code_blocking(
1332 &mut engine,
1333 r#"
1334 spec test
1335 fact price: 100
1336 rule total: price * 2
1337 "#,
1338 "test.lemma",
1339 )
1340 .unwrap();
1341
1342 let now = DateTimeValue::now();
1343 let response = engine
1344 .run("test", Some(&now), HashMap::new(), false)
1345 .unwrap();
1346 assert_eq!(response.results.len(), 1);
1347 assert_eq!(
1348 response.results.values().next().unwrap().result,
1349 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1350 Decimal::from_str("200").unwrap()
1351 )))
1352 );
1353 }
1354
1355 #[test]
1356 fn test_evaluate_boolean_rule() {
1357 let mut engine = Engine::new();
1358 add_lemma_code_blocking(
1359 &mut engine,
1360 r#"
1361 spec test
1362 fact age: 25
1363 rule is_adult: age >= 18
1364 "#,
1365 "test.lemma",
1366 )
1367 .unwrap();
1368
1369 let now = DateTimeValue::now();
1370 let response = engine
1371 .run("test", Some(&now), HashMap::new(), false)
1372 .unwrap();
1373 assert_eq!(
1374 response.results.values().next().unwrap().result,
1375 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::from_bool(true)))
1376 );
1377 }
1378
1379 #[test]
1380 fn test_evaluate_with_unless_clause() {
1381 let mut engine = Engine::new();
1382 add_lemma_code_blocking(
1383 &mut engine,
1384 r#"
1385 spec test
1386 fact quantity: 15
1387 rule discount: 0
1388 unless quantity >= 10 then 10
1389 "#,
1390 "test.lemma",
1391 )
1392 .unwrap();
1393
1394 let now = DateTimeValue::now();
1395 let response = engine
1396 .run("test", Some(&now), HashMap::new(), false)
1397 .unwrap();
1398 assert_eq!(
1399 response.results.values().next().unwrap().result,
1400 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1401 Decimal::from_str("10").unwrap()
1402 )))
1403 );
1404 }
1405
1406 #[test]
1407 fn test_spec_not_found() {
1408 let engine = Engine::new();
1409 let now = DateTimeValue::now();
1410 let result = engine.run("nonexistent", Some(&now), HashMap::new(), false);
1411 assert!(result.is_err());
1412 assert!(result.unwrap_err().to_string().contains("not found"));
1413 }
1414
1415 #[test]
1416 fn test_multiple_specs() {
1417 let mut engine = Engine::new();
1418 add_lemma_code_blocking(
1419 &mut engine,
1420 r#"
1421 spec spec1
1422 fact x: 10
1423 rule result: x * 2
1424 "#,
1425 "spec 1.lemma",
1426 )
1427 .unwrap();
1428
1429 add_lemma_code_blocking(
1430 &mut engine,
1431 r#"
1432 spec spec2
1433 fact y: 5
1434 rule result: y * 3
1435 "#,
1436 "spec 2.lemma",
1437 )
1438 .unwrap();
1439
1440 let now = DateTimeValue::now();
1441 let response1 = engine
1442 .run("spec1", Some(&now), HashMap::new(), false)
1443 .unwrap();
1444 assert_eq!(
1445 response1.results[0].result,
1446 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1447 Decimal::from_str("20").unwrap()
1448 )))
1449 );
1450
1451 let response2 = engine
1452 .run("spec2", Some(&now), HashMap::new(), false)
1453 .unwrap();
1454 assert_eq!(
1455 response2.results[0].result,
1456 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1457 Decimal::from_str("15").unwrap()
1458 )))
1459 );
1460 }
1461
1462 #[test]
1463 fn test_runtime_error_mapping() {
1464 let mut engine = Engine::new();
1465 add_lemma_code_blocking(
1466 &mut engine,
1467 r#"
1468 spec test
1469 fact numerator: 10
1470 fact denominator: 0
1471 rule division: numerator / denominator
1472 "#,
1473 "test.lemma",
1474 )
1475 .unwrap();
1476
1477 let now = DateTimeValue::now();
1478 let result = engine.run("test", Some(&now), HashMap::new(), false);
1479 assert!(result.is_ok(), "Evaluation should succeed");
1481 let response = result.unwrap();
1482 let division_result = response
1483 .results
1484 .values()
1485 .find(|r| r.rule.name == "division");
1486 assert!(
1487 division_result.is_some(),
1488 "Should have division rule result"
1489 );
1490 match &division_result.unwrap().result {
1491 crate::OperationResult::Veto(message) => {
1492 assert!(
1493 message
1494 .as_ref()
1495 .map(|m| m.contains("Division by zero"))
1496 .unwrap_or(false),
1497 "Veto message should mention division by zero: {:?}",
1498 message
1499 );
1500 }
1501 other => panic!("Expected Veto for division by zero, got {:?}", other),
1502 }
1503 }
1504
1505 #[test]
1506 fn test_rules_sorted_by_source_order() {
1507 let mut engine = Engine::new();
1508 add_lemma_code_blocking(
1509 &mut engine,
1510 r#"
1511 spec test
1512 fact a: 1
1513 fact b: 2
1514 rule z: a + b
1515 rule y: a * b
1516 rule x: a - b
1517 "#,
1518 "test.lemma",
1519 )
1520 .unwrap();
1521
1522 let now = DateTimeValue::now();
1523 let response = engine
1524 .run("test", Some(&now), HashMap::new(), false)
1525 .unwrap();
1526 assert_eq!(response.results.len(), 3);
1527
1528 let z_pos = response
1530 .results
1531 .values()
1532 .find(|r| r.rule.name == "z")
1533 .unwrap()
1534 .rule
1535 .source_location
1536 .span
1537 .start;
1538 let y_pos = response
1539 .results
1540 .values()
1541 .find(|r| r.rule.name == "y")
1542 .unwrap()
1543 .rule
1544 .source_location
1545 .span
1546 .start;
1547 let x_pos = response
1548 .results
1549 .values()
1550 .find(|r| r.rule.name == "x")
1551 .unwrap()
1552 .rule
1553 .source_location
1554 .span
1555 .start;
1556
1557 assert!(z_pos < y_pos);
1558 assert!(y_pos < x_pos);
1559 }
1560
1561 #[test]
1562 fn test_rule_filtering_evaluates_dependencies() {
1563 let mut engine = Engine::new();
1564 add_lemma_code_blocking(
1565 &mut engine,
1566 r#"
1567 spec test
1568 fact base: 100
1569 rule subtotal: base * 2
1570 rule tax: subtotal * 10%
1571 rule total: subtotal + tax
1572 "#,
1573 "test.lemma",
1574 )
1575 .unwrap();
1576
1577 let now = DateTimeValue::now();
1579 let rules = vec!["total".to_string()];
1580 let mut response = engine
1581 .run("test", Some(&now), HashMap::new(), false)
1582 .unwrap();
1583 response.filter_rules(&rules);
1584
1585 assert_eq!(response.results.len(), 1);
1586 assert_eq!(response.results.keys().next().unwrap(), "total");
1587
1588 let total = response.results.values().next().unwrap();
1590 assert_eq!(
1591 total.result,
1592 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1593 Decimal::from_str("220").unwrap()
1594 )))
1595 );
1596 }
1597
1598 use crate::parsing::ast::DateTimeValue;
1603
1604 #[test]
1605 fn pre_resolved_deps_in_file_map_evaluates_external_spec() {
1606 let mut engine = Engine::new();
1607
1608 engine
1609 .load(
1610 "spec @org/project/helper\nfact quantity: 42",
1611 SourceType::Dependency("deps/org_project_helper.lemma"),
1612 )
1613 .expect("should load dependency files");
1614
1615 engine
1616 .load(
1617 r#"spec main_spec
1618fact external: spec @org/project/helper
1619rule value: external.quantity"#,
1620 SourceType::Labeled("main.lemma"),
1621 )
1622 .expect("should succeed with pre-resolved deps");
1623
1624 let now = DateTimeValue::now();
1625 let response = engine
1626 .run("main_spec", Some(&now), HashMap::new(), false)
1627 .expect("evaluate should succeed");
1628
1629 let value_result = response
1630 .results
1631 .get("value")
1632 .expect("rule 'value' should exist");
1633 assert_eq!(
1634 value_result.result,
1635 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1636 Decimal::from_str("42").unwrap()
1637 )))
1638 );
1639 }
1640
1641 #[test]
1642 fn load_no_external_refs_works() {
1643 let mut engine = Engine::new();
1644
1645 add_lemma_code_blocking(
1646 &mut engine,
1647 r#"spec local_only
1648fact price: 100
1649rule doubled: price * 2"#,
1650 "local.lemma",
1651 )
1652 .expect("should succeed when there are no @... references");
1653
1654 let now = DateTimeValue::now();
1655 let response = engine
1656 .run("local_only", Some(&now), HashMap::new(), false)
1657 .expect("evaluate should succeed");
1658
1659 assert!(response.results.contains_key("doubled"));
1660 }
1661
1662 #[test]
1663 fn unresolved_external_ref_without_deps_fails() {
1664 let mut engine = Engine::new();
1665
1666 let result = add_lemma_code_blocking(
1667 &mut engine,
1668 r#"spec main_spec
1669fact external: spec @org/project/missing
1670rule value: external.quantity"#,
1671 "main.lemma",
1672 );
1673
1674 assert!(
1675 result.is_err(),
1676 "Should fail when @... dep is not in file map"
1677 );
1678 }
1679
1680 #[test]
1681 fn pre_resolved_deps_with_spec_and_type_refs() {
1682 let mut engine = Engine::new();
1683
1684 let mut deps = HashMap::new();
1685 deps.insert(
1686 "deps/helper.lemma".to_string(),
1687 "spec @org/example/helper\nfact value: 42".to_string(),
1688 );
1689 deps.insert(
1690 "deps/finance.lemma".to_string(),
1691 "spec @lemma/std/finance\ntype money: scale\n -> unit eur 1.00\n -> decimals 2"
1692 .to_string(),
1693 );
1694 engine
1695 .load(
1696 "spec @org/example/helper\nfact value: 42",
1697 SourceType::Dependency("deps/helper.lemma"),
1698 )
1699 .expect("should load helper file");
1700
1701 engine
1702 .load(
1703 "spec @lemma/std/finance\ntype money: scale\n -> unit eur 1.00\n -> decimals 2",
1704 SourceType::Dependency("deps/finance.lemma"),
1705 )
1706 .expect("should load finance file");
1707
1708 engine
1709 .load(
1710 r#"spec registry_demo
1711type money from @lemma/std/finance
1712fact unit_price: 5 eur
1713fact helper: spec @org/example/helper
1714rule helper_value: helper.value
1715rule line_total: unit_price * 2
1716rule formatted: helper_value + 0"#,
1717 SourceType::Labeled("main.lemma"),
1718 )
1719 .expect("should succeed with pre-resolved spec and type deps");
1720
1721 let now = DateTimeValue::now();
1722 let response = engine
1723 .run("registry_demo", Some(&now), HashMap::new(), false)
1724 .expect("evaluate should succeed");
1725
1726 assert!(response.results.contains_key("helper_value"));
1727 assert!(response.results.contains_key("formatted"));
1728 }
1729
1730 #[test]
1731 fn load_empty_labeled_source_is_error() {
1732 let mut engine = Engine::new();
1733 let err = engine
1734 .load("spec x\nfact a: 1", SourceType::Labeled(" "))
1735 .unwrap_err();
1736 assert!(err.errors.iter().any(|e| e.message().contains("non-empty")));
1737 }
1738
1739 #[test]
1740 fn load_inline_source_succeeds() {
1741 let mut engine = Engine::new();
1742 engine
1743 .load("spec x\nfact a: 1", SourceType::Inline)
1744 .expect("inline load");
1745 }
1746
1747 #[test]
1748 fn load_rejects_registry_spec_definitions() {
1749 let mut engine = Engine::new();
1750 let result = engine.load(
1751 "spec @org/example/helper\nfact x: 1",
1752 SourceType::Labeled("bad.lemma"),
1753 );
1754 assert!(result.is_err(), "should reject @-prefixed spec in load");
1755 let errors = result.unwrap_err();
1756 assert!(
1757 errors
1758 .errors
1759 .iter()
1760 .any(|e| e.message().contains("registry prefix")),
1761 "error should mention registry prefix, got: {:?}",
1762 errors
1763 );
1764 }
1765
1766 #[test]
1767 fn add_dependency_files_accepts_registry_spec_definitions() {
1768 let mut engine = Engine::new();
1769 let mut files = HashMap::new();
1770 files.insert(
1771 "deps/helper.lemma".to_string(),
1772 "spec @org/my/helper\nfact x: 1".to_string(),
1773 );
1774 engine
1775 .load(
1776 "spec @org/my/helper\nfact x: 1",
1777 SourceType::Dependency("helper.lemma"),
1778 )
1779 .expect("add_dependency_files should accept @-prefixed specs");
1780 }
1781
1782 #[test]
1783 fn add_dependency_files_rejects_bare_named_spec_in_registry_bundle() {
1784 let mut engine = Engine::new();
1785 let result = engine.load(
1786 "spec local_looking_name\nfact x: 1",
1787 SourceType::Dependency("bundle.lemma"),
1788 );
1789 assert!(
1790 result.is_err(),
1791 "should reject non-@-prefixed spec in registry bundle"
1792 );
1793 let errors = result.unwrap_err();
1794 assert!(
1795 errors
1796 .errors
1797 .iter()
1798 .any(|e| e.message().contains("without '@' prefix")),
1799 "error should mention missing @ prefix, got: {:?}",
1800 errors
1801 );
1802 }
1803
1804 #[test]
1805 fn add_dependency_files_rejects_spec_with_bare_spec_reference() {
1806 let mut engine = Engine::new();
1807 let result = engine.load(
1808 "spec @org/billing\nfact rates: spec local_rates",
1809 SourceType::Dependency("billing.lemma"),
1810 );
1811 assert!(
1812 result.is_err(),
1813 "should reject registry spec referencing non-@ spec"
1814 );
1815 let errors = result.unwrap_err();
1816 assert!(
1817 errors
1818 .errors
1819 .iter()
1820 .any(|e| e.message().contains("local_rates")),
1821 "error should mention bare ref name, got: {:?}",
1822 errors
1823 );
1824 }
1825
1826 #[test]
1827 fn add_dependency_files_rejects_spec_with_bare_type_import() {
1828 let mut engine = Engine::new();
1829 let result = engine.load(
1830 "spec @org/billing\ntype money from local_finance",
1831 SourceType::Dependency("billing.lemma"),
1832 );
1833 assert!(
1834 result.is_err(),
1835 "should reject registry spec importing type from non-@ spec"
1836 );
1837 let errors = result.unwrap_err();
1838 assert!(
1839 errors
1840 .errors
1841 .iter()
1842 .any(|e| e.message().contains("local_finance")),
1843 "error should mention bare ref name, got: {:?}",
1844 errors
1845 );
1846 }
1847
1848 #[test]
1849 fn add_dependency_files_accepts_fully_qualified_references() {
1850 let mut engine = Engine::new();
1851 let mut files = HashMap::new();
1852 files.insert(
1853 "deps/bundle.lemma".to_string(),
1854 r#"spec @org/billing
1855fact rates: spec @org/rates
1856
1857spec @org/rates
1858fact rate: 10"#
1859 .to_string(),
1860 );
1861 engine
1862 .load(
1863 r#"spec @org/billing
1864fact rates: spec @org/rates
1865
1866spec @org/rates
1867fact rate: 10"#,
1868 SourceType::Dependency("bundle.lemma"),
1869 )
1870 .expect("fully @-prefixed bundle should be accepted");
1871 }
1872
1873 #[test]
1874 fn load_returns_all_errors_not_just_first() {
1875 let mut engine = Engine::new();
1876
1877 let result = add_lemma_code_blocking(
1878 &mut engine,
1879 r#"spec demo
1880type money from nonexistent_type_source
1881fact helper: spec nonexistent_spec
1882fact price: 10
1883rule total: helper.value + price"#,
1884 "test.lemma",
1885 );
1886
1887 assert!(result.is_err(), "Should fail with multiple errors");
1888 let load_err = result.unwrap_err();
1889 assert!(
1890 load_err.errors.len() >= 2,
1891 "expected at least 2 errors (type + spec ref), got {}",
1892 load_err.errors.len()
1893 );
1894 let error_message = load_err
1895 .errors
1896 .iter()
1897 .map(ToString::to_string)
1898 .collect::<Vec<_>>()
1899 .join("; ");
1900
1901 assert!(
1902 error_message.contains("nonexistent_type_source"),
1903 "Should mention type import source spec. Got:\n{}",
1904 error_message
1905 );
1906 assert!(
1907 error_message.contains("nonexistent_spec"),
1908 "Should mention spec reference error about 'nonexistent_spec'. Got:\n{}",
1909 error_message
1910 );
1911 }
1912
1913 #[test]
1919 fn planning_rejects_invalid_number_default() {
1920 let mut engine = Engine::new();
1921 let result = add_lemma_code_blocking(
1922 &mut engine,
1923 "spec t\nfact x: [number -> default \"10 $$\"]\nrule r: x",
1924 "t.lemma",
1925 );
1926 assert!(
1927 result.is_err(),
1928 "must reject non-numeric default on number type"
1929 );
1930 }
1931
1932 #[test]
1933 fn planning_rejects_text_literal_as_number_default() {
1934 let mut engine = Engine::new();
1939 let result = add_lemma_code_blocking(
1940 &mut engine,
1941 "spec t\nfact x: [number -> default \"10\"]\nrule r: x",
1942 "t.lemma",
1943 );
1944 assert!(
1945 result.is_err(),
1946 "must reject text literal \"10\" as default for number type"
1947 );
1948 }
1949
1950 #[test]
1951 fn planning_rejects_invalid_boolean_default() {
1952 let mut engine = Engine::new();
1953 let result = add_lemma_code_blocking(
1954 &mut engine,
1955 "spec t\nfact x: [boolean -> default \"maybe\"]\nrule r: x",
1956 "t.lemma",
1957 );
1958 assert!(
1959 result.is_err(),
1960 "must reject non-boolean default on boolean type"
1961 );
1962 }
1963
1964 #[test]
1965 fn planning_rejects_invalid_named_type_default() {
1966 let mut engine = Engine::new();
1968 let result = add_lemma_code_blocking(
1969 &mut engine,
1970 "spec t\ntype custom: number -> minimum 0\nfact x: [custom -> default \"abc\"]\nrule r: x",
1971 "t.lemma",
1972 );
1973 assert!(
1974 result.is_err(),
1975 "must reject non-numeric default on named number type"
1976 );
1977 }
1978
1979 #[test]
1980 fn planning_accepts_valid_number_default() {
1981 let mut engine = Engine::new();
1982 let result = add_lemma_code_blocking(
1983 &mut engine,
1984 "spec t\nfact x: [number -> default 10]\nrule r: x",
1985 "t.lemma",
1986 );
1987 assert!(result.is_ok(), "must accept valid number default");
1988 }
1989
1990 #[test]
1991 fn planning_accepts_valid_boolean_default() {
1992 let mut engine = Engine::new();
1993 let result = add_lemma_code_blocking(
1994 &mut engine,
1995 "spec t\nfact x: [boolean -> default true]\nrule r: x",
1996 "t.lemma",
1997 );
1998 assert!(result.is_ok(), "must accept valid boolean default");
1999 }
2000
2001 #[test]
2002 fn planning_accepts_valid_text_default() {
2003 let mut engine = Engine::new();
2004 let result = add_lemma_code_blocking(
2005 &mut engine,
2006 "spec t\nfact x: [text -> default \"hello\"]\nrule r: x",
2007 "t.lemma",
2008 );
2009 assert!(result.is_ok(), "must accept valid text default");
2010 }
2011}