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 #[test]
1202 fn get_spec_resolves_temporal_version_by_effective() {
1203 let mut engine = Engine::new();
1204 engine
1205 .load(
1206 r#"
1207 spec pricing 2025-01-01
1208 fact x: 1
1209 rule r: x
1210 "#,
1211 SourceType::Labeled("a.lemma"),
1212 )
1213 .unwrap();
1214 engine
1215 .load(
1216 r#"
1217 spec pricing 2025-06-01
1218 fact x: 2
1219 rule r: x
1220 "#,
1221 SourceType::Labeled("b.lemma"),
1222 )
1223 .unwrap();
1224
1225 let jan = DateTimeValue {
1226 year: 2025,
1227 month: 1,
1228 day: 15,
1229 hour: 0,
1230 minute: 0,
1231 second: 0,
1232 microsecond: 0,
1233 timezone: None,
1234 };
1235 let jul = DateTimeValue {
1236 year: 2025,
1237 month: 7,
1238 day: 1,
1239 hour: 0,
1240 minute: 0,
1241 second: 0,
1242 microsecond: 0,
1243 timezone: None,
1244 };
1245
1246 let v1 = DateTimeValue {
1247 year: 2025,
1248 month: 1,
1249 day: 1,
1250 hour: 0,
1251 minute: 0,
1252 second: 0,
1253 microsecond: 0,
1254 timezone: None,
1255 };
1256 let v2 = DateTimeValue {
1257 year: 2025,
1258 month: 6,
1259 day: 1,
1260 hour: 0,
1261 minute: 0,
1262 second: 0,
1263 microsecond: 0,
1264 timezone: None,
1265 };
1266
1267 let s_jan = engine.get_spec("pricing", Some(&jan)).expect("jan spec");
1268 let s_jul = engine.get_spec("pricing", Some(&jul)).expect("jul spec");
1269 assert_eq!(s_jan.effective_from(), Some(&v1));
1270 assert_eq!(s_jul.effective_from(), Some(&v2));
1271 }
1272
1273 #[test]
1274 fn test_evaluate_spec_all_rules() {
1275 let mut engine = Engine::new();
1276 engine
1277 .load(
1278 r#"
1279 spec test
1280 fact x: 10
1281 fact y: 5
1282 rule sum: x + y
1283 rule product: x * y
1284 "#,
1285 SourceType::Labeled("test.lemma"),
1286 )
1287 .unwrap();
1288
1289 let now = DateTimeValue::now();
1290 let response = engine
1291 .run("test", Some(&now), HashMap::new(), false)
1292 .unwrap();
1293 assert_eq!(response.results.len(), 2);
1294
1295 let sum_result = response
1296 .results
1297 .values()
1298 .find(|r| r.rule.name == "sum")
1299 .unwrap();
1300 assert_eq!(
1301 sum_result.result,
1302 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1303 Decimal::from_str("15").unwrap()
1304 )))
1305 );
1306
1307 let product_result = response
1308 .results
1309 .values()
1310 .find(|r| r.rule.name == "product")
1311 .unwrap();
1312 assert_eq!(
1313 product_result.result,
1314 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1315 Decimal::from_str("50").unwrap()
1316 )))
1317 );
1318 }
1319
1320 #[test]
1321 fn test_evaluate_empty_facts() {
1322 let mut engine = Engine::new();
1323 engine
1324 .load(
1325 r#"
1326 spec test
1327 fact price: 100
1328 rule total: price * 2
1329 "#,
1330 SourceType::Labeled("test.lemma"),
1331 )
1332 .unwrap();
1333
1334 let now = DateTimeValue::now();
1335 let response = engine
1336 .run("test", Some(&now), HashMap::new(), false)
1337 .unwrap();
1338 assert_eq!(response.results.len(), 1);
1339 assert_eq!(
1340 response.results.values().next().unwrap().result,
1341 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1342 Decimal::from_str("200").unwrap()
1343 )))
1344 );
1345 }
1346
1347 #[test]
1348 fn test_evaluate_boolean_rule() {
1349 let mut engine = Engine::new();
1350 engine
1351 .load(
1352 r#"
1353 spec test
1354 fact age: 25
1355 rule is_adult: age >= 18
1356 "#,
1357 SourceType::Labeled("test.lemma"),
1358 )
1359 .unwrap();
1360
1361 let now = DateTimeValue::now();
1362 let response = engine
1363 .run("test", Some(&now), HashMap::new(), false)
1364 .unwrap();
1365 assert_eq!(
1366 response.results.values().next().unwrap().result,
1367 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::from_bool(true)))
1368 );
1369 }
1370
1371 #[test]
1372 fn test_evaluate_with_unless_clause() {
1373 let mut engine = Engine::new();
1374 engine
1375 .load(
1376 r#"
1377 spec test
1378 fact quantity: 15
1379 rule discount: 0
1380 unless quantity >= 10 then 10
1381 "#,
1382 SourceType::Labeled("test.lemma"),
1383 )
1384 .unwrap();
1385
1386 let now = DateTimeValue::now();
1387 let response = engine
1388 .run("test", Some(&now), HashMap::new(), false)
1389 .unwrap();
1390 assert_eq!(
1391 response.results.values().next().unwrap().result,
1392 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1393 Decimal::from_str("10").unwrap()
1394 )))
1395 );
1396 }
1397
1398 #[test]
1399 fn test_spec_not_found() {
1400 let engine = Engine::new();
1401 let now = DateTimeValue::now();
1402 let result = engine.run("nonexistent", Some(&now), HashMap::new(), false);
1403 assert!(result.is_err());
1404 assert!(result.unwrap_err().to_string().contains("not found"));
1405 }
1406
1407 #[test]
1408 fn test_multiple_specs() {
1409 let mut engine = Engine::new();
1410 engine
1411 .load(
1412 r#"
1413 spec spec1
1414 fact x: 10
1415 rule result: x * 2
1416 "#,
1417 SourceType::Labeled("spec 1.lemma"),
1418 )
1419 .unwrap();
1420
1421 engine
1422 .load(
1423 r#"
1424 spec spec2
1425 fact y: 5
1426 rule result: y * 3
1427 "#,
1428 SourceType::Labeled("spec 2.lemma"),
1429 )
1430 .unwrap();
1431
1432 let now = DateTimeValue::now();
1433 let response1 = engine
1434 .run("spec1", Some(&now), HashMap::new(), false)
1435 .unwrap();
1436 assert_eq!(
1437 response1.results[0].result,
1438 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1439 Decimal::from_str("20").unwrap()
1440 )))
1441 );
1442
1443 let response2 = engine
1444 .run("spec2", Some(&now), HashMap::new(), false)
1445 .unwrap();
1446 assert_eq!(
1447 response2.results[0].result,
1448 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1449 Decimal::from_str("15").unwrap()
1450 )))
1451 );
1452 }
1453
1454 #[test]
1455 fn test_runtime_error_mapping() {
1456 let mut engine = Engine::new();
1457 engine
1458 .load(
1459 r#"
1460 spec test
1461 fact numerator: 10
1462 fact denominator: 0
1463 rule division: numerator / denominator
1464 "#,
1465 SourceType::Labeled("test.lemma"),
1466 )
1467 .unwrap();
1468
1469 let now = DateTimeValue::now();
1470 let result = engine.run("test", Some(&now), HashMap::new(), false);
1471 assert!(result.is_ok(), "Evaluation should succeed");
1473 let response = result.unwrap();
1474 let division_result = response
1475 .results
1476 .values()
1477 .find(|r| r.rule.name == "division");
1478 assert!(
1479 division_result.is_some(),
1480 "Should have division rule result"
1481 );
1482 match &division_result.unwrap().result {
1483 crate::OperationResult::Veto(message) => {
1484 assert!(
1485 message
1486 .as_ref()
1487 .map(|m| m.contains("Division by zero"))
1488 .unwrap_or(false),
1489 "Veto message should mention division by zero: {:?}",
1490 message
1491 );
1492 }
1493 other => panic!("Expected Veto for division by zero, got {:?}", other),
1494 }
1495 }
1496
1497 #[test]
1498 fn test_rules_sorted_by_source_order() {
1499 let mut engine = Engine::new();
1500 engine
1501 .load(
1502 r#"
1503 spec test
1504 fact a: 1
1505 fact b: 2
1506 rule z: a + b
1507 rule y: a * b
1508 rule x: a - b
1509 "#,
1510 SourceType::Labeled("test.lemma"),
1511 )
1512 .unwrap();
1513
1514 let now = DateTimeValue::now();
1515 let response = engine
1516 .run("test", Some(&now), HashMap::new(), false)
1517 .unwrap();
1518 assert_eq!(response.results.len(), 3);
1519
1520 let z_pos = response
1522 .results
1523 .values()
1524 .find(|r| r.rule.name == "z")
1525 .unwrap()
1526 .rule
1527 .source_location
1528 .span
1529 .start;
1530 let y_pos = response
1531 .results
1532 .values()
1533 .find(|r| r.rule.name == "y")
1534 .unwrap()
1535 .rule
1536 .source_location
1537 .span
1538 .start;
1539 let x_pos = response
1540 .results
1541 .values()
1542 .find(|r| r.rule.name == "x")
1543 .unwrap()
1544 .rule
1545 .source_location
1546 .span
1547 .start;
1548
1549 assert!(z_pos < y_pos);
1550 assert!(y_pos < x_pos);
1551 }
1552
1553 #[test]
1554 fn test_rule_filtering_evaluates_dependencies() {
1555 let mut engine = Engine::new();
1556 engine
1557 .load(
1558 r#"
1559 spec test
1560 fact base: 100
1561 rule subtotal: base * 2
1562 rule tax: subtotal * 10%
1563 rule total: subtotal + tax
1564 "#,
1565 SourceType::Labeled("test.lemma"),
1566 )
1567 .unwrap();
1568
1569 let now = DateTimeValue::now();
1571 let rules = vec!["total".to_string()];
1572 let mut response = engine
1573 .run("test", Some(&now), HashMap::new(), false)
1574 .unwrap();
1575 response.filter_rules(&rules);
1576
1577 assert_eq!(response.results.len(), 1);
1578 assert_eq!(response.results.keys().next().unwrap(), "total");
1579
1580 let total = response.results.values().next().unwrap();
1582 assert_eq!(
1583 total.result,
1584 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1585 Decimal::from_str("220").unwrap()
1586 )))
1587 );
1588 }
1589
1590 use crate::parsing::ast::DateTimeValue;
1595
1596 #[test]
1597 fn pre_resolved_deps_in_file_map_evaluates_external_spec() {
1598 let mut engine = Engine::new();
1599
1600 engine
1601 .load(
1602 "spec @org/project/helper\nfact quantity: 42",
1603 SourceType::Dependency("deps/org_project_helper.lemma"),
1604 )
1605 .expect("should load dependency files");
1606
1607 engine
1608 .load(
1609 r#"spec main_spec
1610fact external: spec @org/project/helper
1611rule value: external.quantity"#,
1612 SourceType::Labeled("main.lemma"),
1613 )
1614 .expect("should succeed with pre-resolved deps");
1615
1616 let now = DateTimeValue::now();
1617 let response = engine
1618 .run("main_spec", Some(&now), HashMap::new(), false)
1619 .expect("evaluate should succeed");
1620
1621 let value_result = response
1622 .results
1623 .get("value")
1624 .expect("rule 'value' should exist");
1625 assert_eq!(
1626 value_result.result,
1627 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1628 Decimal::from_str("42").unwrap()
1629 )))
1630 );
1631 }
1632
1633 #[test]
1634 fn load_no_external_refs_works() {
1635 let mut engine = Engine::new();
1636
1637 engine
1638 .load(
1639 r#"spec local_only
1640fact price: 100
1641rule doubled: price * 2"#,
1642 SourceType::Labeled("local.lemma"),
1643 )
1644 .expect("should succeed when there are no @... references");
1645
1646 let now = DateTimeValue::now();
1647 let response = engine
1648 .run("local_only", Some(&now), HashMap::new(), false)
1649 .expect("evaluate should succeed");
1650
1651 let doubled = response
1652 .results
1653 .get("doubled")
1654 .expect("doubled rule")
1655 .result
1656 .value()
1657 .expect("value");
1658 assert_eq!(doubled.to_string(), "200");
1659 }
1660
1661 #[test]
1662 fn unresolved_external_ref_without_deps_fails() {
1663 let mut engine = Engine::new();
1664
1665 let result = engine.load(
1666 r#"spec main_spec
1667fact external: spec @org/project/missing
1668rule value: external.quantity"#,
1669 SourceType::Labeled("main.lemma"),
1670 );
1671
1672 let errs = result.expect_err("Should fail when @... dep is not in file map");
1673 let msg = errs
1674 .iter()
1675 .map(|e| e.to_string())
1676 .collect::<Vec<_>>()
1677 .join(" ");
1678 assert!(
1679 msg.contains("missing") || msg.contains("not found") || msg.contains("Unknown"),
1680 "error should indicate missing dep: {msg}"
1681 );
1682 }
1683
1684 #[test]
1685 fn pre_resolved_deps_with_spec_and_type_refs() {
1686 let mut engine = Engine::new();
1687
1688 let mut deps = HashMap::new();
1689 deps.insert(
1690 "deps/helper.lemma".to_string(),
1691 "spec @org/example/helper\nfact value: 42".to_string(),
1692 );
1693 deps.insert(
1694 "deps/finance.lemma".to_string(),
1695 "spec @lemma/std/finance\ntype money: scale\n -> unit eur 1.00\n -> decimals 2"
1696 .to_string(),
1697 );
1698 engine
1699 .load(
1700 "spec @org/example/helper\nfact value: 42",
1701 SourceType::Dependency("deps/helper.lemma"),
1702 )
1703 .expect("should load helper file");
1704
1705 engine
1706 .load(
1707 "spec @lemma/std/finance\ntype money: scale\n -> unit eur 1.00\n -> decimals 2",
1708 SourceType::Dependency("deps/finance.lemma"),
1709 )
1710 .expect("should load finance file");
1711
1712 engine
1713 .load(
1714 r#"spec registry_demo
1715type money from @lemma/std/finance
1716fact unit_price: 5 eur
1717fact helper: spec @org/example/helper
1718rule helper_value: helper.value
1719rule line_total: unit_price * 2
1720rule formatted: helper_value + 0"#,
1721 SourceType::Labeled("main.lemma"),
1722 )
1723 .expect("should succeed with pre-resolved spec and type deps");
1724
1725 let now = DateTimeValue::now();
1726 let response = engine
1727 .run("registry_demo", Some(&now), HashMap::new(), false)
1728 .expect("evaluate should succeed");
1729
1730 assert_eq!(
1731 response
1732 .results
1733 .get("helper_value")
1734 .expect("helper_value")
1735 .result
1736 .value()
1737 .expect("value")
1738 .to_string(),
1739 "42"
1740 );
1741 let line = response
1742 .results
1743 .get("line_total")
1744 .expect("line_total")
1745 .result
1746 .value()
1747 .expect("value")
1748 .to_string();
1749 assert!(
1750 line.contains("10") && line.to_lowercase().contains("eur"),
1751 "5 eur * 2 => ~10 eur, got {line}"
1752 );
1753 assert_eq!(
1754 response
1755 .results
1756 .get("formatted")
1757 .expect("formatted")
1758 .result
1759 .value()
1760 .expect("value")
1761 .to_string(),
1762 "42"
1763 );
1764 }
1765
1766 #[test]
1767 fn load_empty_labeled_source_is_error() {
1768 let mut engine = Engine::new();
1769 let err = engine
1770 .load("spec x\nfact a: 1", SourceType::Labeled(" "))
1771 .unwrap_err();
1772 assert!(err.errors.iter().any(|e| e.message().contains("non-empty")));
1773 }
1774
1775 #[test]
1776 fn load_inline_source_succeeds() {
1777 let mut engine = Engine::new();
1778 engine
1779 .load("spec x\nfact a: 1", SourceType::Inline)
1780 .expect("inline load");
1781 }
1782
1783 #[test]
1784 fn load_rejects_registry_spec_definitions() {
1785 let mut engine = Engine::new();
1786 let result = engine.load(
1787 "spec @org/example/helper\nfact x: 1",
1788 SourceType::Labeled("bad.lemma"),
1789 );
1790 assert!(result.is_err(), "should reject @-prefixed spec in load");
1791 let errors = result.unwrap_err();
1792 assert!(
1793 errors
1794 .errors
1795 .iter()
1796 .any(|e| e.message().contains("registry prefix")),
1797 "error should mention registry prefix, got: {:?}",
1798 errors
1799 );
1800 }
1801
1802 #[test]
1803 fn add_dependency_files_accepts_registry_spec_definitions() {
1804 let mut engine = Engine::new();
1805 let mut files = HashMap::new();
1806 files.insert(
1807 "deps/helper.lemma".to_string(),
1808 "spec @org/my/helper\nfact x: 1".to_string(),
1809 );
1810 engine
1811 .load(
1812 "spec @org/my/helper\nfact x: 1",
1813 SourceType::Dependency("helper.lemma"),
1814 )
1815 .expect("add_dependency_files should accept @-prefixed specs");
1816 }
1817
1818 #[test]
1819 fn add_dependency_files_rejects_bare_named_spec_in_registry_bundle() {
1820 let mut engine = Engine::new();
1821 let result = engine.load(
1822 "spec local_looking_name\nfact x: 1",
1823 SourceType::Dependency("bundle.lemma"),
1824 );
1825 assert!(
1826 result.is_err(),
1827 "should reject non-@-prefixed spec in registry bundle"
1828 );
1829 let errors = result.unwrap_err();
1830 assert!(
1831 errors
1832 .errors
1833 .iter()
1834 .any(|e| e.message().contains("without '@' prefix")),
1835 "error should mention missing @ prefix, got: {:?}",
1836 errors
1837 );
1838 }
1839
1840 #[test]
1841 fn add_dependency_files_rejects_spec_with_bare_spec_reference() {
1842 let mut engine = Engine::new();
1843 let result = engine.load(
1844 "spec @org/billing\nfact rates: spec local_rates",
1845 SourceType::Dependency("billing.lemma"),
1846 );
1847 assert!(
1848 result.is_err(),
1849 "should reject registry spec referencing non-@ spec"
1850 );
1851 let errors = result.unwrap_err();
1852 assert!(
1853 errors
1854 .errors
1855 .iter()
1856 .any(|e| e.message().contains("local_rates")),
1857 "error should mention bare ref name, got: {:?}",
1858 errors
1859 );
1860 }
1861
1862 #[test]
1863 fn add_dependency_files_rejects_spec_with_bare_type_import() {
1864 let mut engine = Engine::new();
1865 let result = engine.load(
1866 "spec @org/billing\ntype money from local_finance",
1867 SourceType::Dependency("billing.lemma"),
1868 );
1869 assert!(
1870 result.is_err(),
1871 "should reject registry spec importing type from non-@ spec"
1872 );
1873 let errors = result.unwrap_err();
1874 assert!(
1875 errors
1876 .errors
1877 .iter()
1878 .any(|e| e.message().contains("local_finance")),
1879 "error should mention bare ref name, got: {:?}",
1880 errors
1881 );
1882 }
1883
1884 #[test]
1885 fn add_dependency_files_accepts_fully_qualified_references() {
1886 let mut engine = Engine::new();
1887 let mut files = HashMap::new();
1888 files.insert(
1889 "deps/bundle.lemma".to_string(),
1890 r#"spec @org/billing
1891fact rates: spec @org/rates
1892
1893spec @org/rates
1894fact rate: 10"#
1895 .to_string(),
1896 );
1897 engine
1898 .load(
1899 r#"spec @org/billing
1900fact rates: spec @org/rates
1901
1902spec @org/rates
1903fact rate: 10"#,
1904 SourceType::Dependency("bundle.lemma"),
1905 )
1906 .expect("fully @-prefixed bundle should be accepted");
1907 }
1908
1909 #[test]
1910 fn load_returns_all_errors_not_just_first() {
1911 let mut engine = Engine::new();
1912
1913 let result = engine.load(
1914 r#"spec demo
1915type money from nonexistent_type_source
1916fact helper: spec nonexistent_spec
1917fact price: 10
1918rule total: helper.value + price"#,
1919 SourceType::Labeled("test.lemma"),
1920 );
1921
1922 assert!(result.is_err(), "Should fail with multiple errors");
1923 let load_err = result.unwrap_err();
1924 assert!(
1925 load_err.errors.len() >= 2,
1926 "expected at least 2 errors (type + spec ref), got {}",
1927 load_err.errors.len()
1928 );
1929 let error_message = load_err
1930 .errors
1931 .iter()
1932 .map(ToString::to_string)
1933 .collect::<Vec<_>>()
1934 .join("; ");
1935
1936 assert!(
1937 error_message.contains("nonexistent_type_source"),
1938 "Should mention type import source spec. Got:\n{}",
1939 error_message
1940 );
1941 assert!(
1942 error_message.contains("nonexistent_spec"),
1943 "Should mention spec reference error about 'nonexistent_spec'. Got:\n{}",
1944 error_message
1945 );
1946 }
1947
1948 #[test]
1954 fn planning_rejects_invalid_number_default() {
1955 let mut engine = Engine::new();
1956 let result = engine.load(
1957 "spec t\nfact x: [number -> default \"10 $$\"]\nrule r: x",
1958 SourceType::Labeled("t.lemma"),
1959 );
1960 assert!(
1961 result.is_err(),
1962 "must reject non-numeric default on number type"
1963 );
1964 }
1965
1966 #[test]
1967 fn planning_rejects_text_literal_as_number_default() {
1968 let mut engine = Engine::new();
1973 let result = engine.load(
1974 "spec t\nfact x: [number -> default \"10\"]\nrule r: x",
1975 SourceType::Labeled("t.lemma"),
1976 );
1977 assert!(
1978 result.is_err(),
1979 "must reject text literal \"10\" as default for number type"
1980 );
1981 }
1982
1983 #[test]
1984 fn planning_rejects_invalid_boolean_default() {
1985 let mut engine = Engine::new();
1986 let result = engine.load(
1987 "spec t\nfact x: [boolean -> default \"maybe\"]\nrule r: x",
1988 SourceType::Labeled("t.lemma"),
1989 );
1990 assert!(
1991 result.is_err(),
1992 "must reject non-boolean default on boolean type"
1993 );
1994 }
1995
1996 #[test]
1997 fn planning_rejects_invalid_named_type_default() {
1998 let mut engine = Engine::new();
2000 let result = engine.load("spec t\ntype custom: number -> minimum 0\nfact x: [custom -> default \"abc\"]\nrule r: x", SourceType::Labeled("t.lemma",));
2001 assert!(
2002 result.is_err(),
2003 "must reject non-numeric default on named number type"
2004 );
2005 }
2006
2007 #[test]
2008 fn planning_accepts_valid_number_default() {
2009 let mut engine = Engine::new();
2010 let result = engine.load(
2011 "spec t\nfact x: [number -> default 10]\nrule r: x",
2012 SourceType::Labeled("t.lemma"),
2013 );
2014 assert!(result.is_ok(), "must accept valid number default");
2015 }
2016
2017 #[test]
2018 fn planning_accepts_valid_boolean_default() {
2019 let mut engine = Engine::new();
2020 let result = engine.load(
2021 "spec t\nfact x: [boolean -> default true]\nrule r: x",
2022 SourceType::Labeled("t.lemma"),
2023 );
2024 assert!(result.is_ok(), "must accept valid boolean default");
2025 }
2026
2027 #[test]
2028 fn planning_accepts_valid_text_default() {
2029 let mut engine = Engine::new();
2030 let result = engine.load(
2031 "spec t\nfact x: [text -> default \"hello\"]\nrule r: x",
2032 SourceType::Labeled("t.lemma"),
2033 );
2034 assert!(result.is_ok(), "must accept valid text default");
2035 }
2036}