1use crate::evaluation::Evaluator;
2use crate::evaluation::{EvaluateData, EvaluateDataValue};
3use crate::literals::{DateGranularity, TimezoneValue};
4use crate::parsing::ast::{DateTimeValue, LemmaRepository, LemmaSpec};
5use crate::parsing::source::SourceType;
6use crate::parsing::{parse, EffectiveDate};
7use crate::planning::execution_plan::{ExecutionPlan, Show, ShowData};
8use crate::planning::semantics::{DataDefinition, SpecIdentity};
9use crate::planning::{LemmaSpecSet, PlanStore};
10use crate::{Error, ResourceLimits, Response};
11use indexmap::IndexMap;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::sync::Arc;
15
16#[derive(Debug, Clone)]
18pub struct Errors {
19 pub errors: Vec<Error>,
20 pub sources: HashMap<SourceType, String>,
21}
22
23impl Errors {
24 pub fn iter(&self) -> std::slice::Iter<'_, Error> {
26 self.errors.iter()
27 }
28}
29
30pub fn resolve_effective(raw: Option<&str>) -> Result<DateTimeValue, Error> {
39 match raw {
40 Some(s) if !s.trim().is_empty() => {
41 let parsed = s.trim().parse::<DateTimeValue>().map_err(|_| {
42 Error::request(
43 format!(
44 "Invalid effective value '{}'. Expected: YYYY, YYYY-MM, YYYY-MM-DD, or ISO 8601 datetime",
45 s.trim()
46 ),
47 None::<String>,
48 )
49 })?;
50 Ok(promote_effective_to_instant(parsed))
51 }
52 _ => Ok(DateTimeValue::now()),
53 }
54}
55
56fn promote_effective_to_instant(mut dt: DateTimeValue) -> DateTimeValue {
59 if matches!(dt.granularity, DateGranularity::DateTime) {
60 return dt;
61 }
62 dt.hour = 0;
63 dt.minute = 0;
64 dt.second = 0;
65 dt.microsecond = 0;
66 dt.timezone = Some(TimezoneValue { offset_minutes: 0 });
67 dt.granularity = DateGranularity::DateTime;
68 dt
69}
70
71pub const EMBEDDED_STDLIB_REPOSITORY: &str = "lemma";
74
75#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
77pub struct SpecDependency {
78 #[serde(skip_serializing_if = "Option::is_none", default)]
79 pub repository: Option<String>,
80 pub name: String,
81 #[serde(skip_serializing_if = "Option::is_none", default)]
82 pub effective: Option<DateTimeValue>,
83}
84
85impl std::fmt::Display for SpecDependency {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 if let Some(repository) = &self.repository {
88 write!(f, "{repository} ")?;
89 }
90 write!(f, "{}", self.name)?;
91 if let Some(effective) = &self.effective {
92 write!(f, " {effective}")?;
93 }
94 Ok(())
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
100pub struct SpecVersion {
101 pub name: String,
102 #[serde(skip_serializing_if = "Option::is_none", default)]
103 pub effective_from: Option<DateTimeValue>,
104 #[serde(skip_serializing_if = "Option::is_none", default)]
105 pub effective_to: Option<DateTimeValue>,
106 #[serde(skip_serializing_if = "Vec::is_empty", default)]
107 pub dependencies: Vec<SpecDependency>,
108}
109
110pub(crate) fn write_effective_window(
114 f: &mut impl std::fmt::Write,
115 from: Option<&DateTimeValue>,
116 to: Option<&DateTimeValue>,
117) -> std::fmt::Result {
118 match (from, to) {
119 (Some(from), Some(to)) => write!(f, " {from}...{to}"),
120 (Some(from), None) => write!(f, " {from}"),
121 (None, Some(to)) => write!(f, " ...{to}"),
122 (None, None) => Ok(()),
123 }
124}
125
126impl std::fmt::Display for SpecVersion {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 f.write_str(&self.name)?;
129 write_effective_window(f, self.effective_from.as_ref(), self.effective_to.as_ref())
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
139pub struct Repository {
140 #[serde(skip_serializing_if = "Option::is_none", default)]
141 pub repository: Option<String>,
142 pub specs: Vec<SpecVersion>,
143 #[serde(skip)]
144 pub from_dependency: bool,
145}
146
147#[must_use]
158pub fn format_repositories(repositories: &[Repository], all: bool) -> String {
159 let mut output = String::new();
160 let groups = repositories
161 .iter()
162 .filter(|resolved| !resolved.specs.is_empty() && (all || !resolved.from_dependency));
163 for (index, resolved) in groups.enumerate() {
164 if index > 0 {
165 output.push('\n');
166 }
167 let mut versions: Vec<&SpecVersion> = resolved.specs.iter().collect();
168 versions.sort_by(|a, b| (&a.name, &a.effective_from).cmp(&(&b.name, &b.effective_from)));
169 let indent = match resolved.repository.as_deref() {
170 None => "",
171 Some(repository) => {
172 output.push_str(repository);
173 output.push('\n');
174 " "
175 }
176 };
177 for version in versions {
178 output.push_str(indent);
179 output.push_str(&version.to_string());
180 output.push('\n');
181 if !all {
182 for dependency in &version.dependencies {
183 output.push_str(indent);
184 output.push_str(" ");
185 output.push_str(&dependency.to_string());
186 output.push('\n');
187 }
188 }
189 }
190 }
191 output
192}
193
194fn uses_dependencies(spec: &LemmaSpec) -> Vec<SpecDependency> {
195 spec.data
196 .iter()
197 .filter_map(|data| {
198 data.value.uses_spec_ref().map(|spec_ref| SpecDependency {
199 repository: spec_ref.repository.as_ref().map(|q| q.name.clone()),
200 name: spec_ref.name.clone(),
201 effective: spec_ref.effective.clone(),
202 })
203 })
204 .collect()
205}
206
207#[derive(Debug, Serialize, Deserialize)]
219pub struct Context {
220 #[serde(with = "crate::snapshot::shared::arc_key_index_map")]
221 repositories: IndexMap<Arc<LemmaRepository>, IndexMap<String, LemmaSpecSet>>,
222 #[serde(with = "crate::snapshot::shared::arc")]
223 workspace: Arc<LemmaRepository>,
224}
225
226impl Default for Context {
227 fn default() -> Self {
228 Self::new()
229 }
230}
231
232impl Context {
233 pub fn new() -> Self {
235 let workspace = Arc::new(LemmaRepository::new(None));
236 let mut repositories = IndexMap::new();
237 repositories.insert(Arc::clone(&workspace), IndexMap::new());
238 Self {
239 repositories,
240 workspace,
241 }
242 }
243
244 #[must_use]
247 pub fn workspace(&self) -> Arc<LemmaRepository> {
248 Arc::clone(&self.workspace)
249 }
250
251 #[must_use]
253 pub fn find_repository(&self, name: &str) -> Option<Arc<LemmaRepository>> {
254 let probe = Arc::new(LemmaRepository::new(Some(name.to_string())));
255 self.repositories
256 .get_key_value(&probe)
257 .map(|(k, _)| Arc::clone(k))
258 }
259
260 #[must_use]
263 pub fn repositories(&self) -> &IndexMap<Arc<LemmaRepository>, IndexMap<String, LemmaSpecSet>> {
264 &self.repositories
265 }
266
267 pub fn iter(&self) -> impl Iterator<Item = &LemmaSpec> + '_ {
271 self.repositories
272 .values()
273 .flat_map(|m| m.values())
274 .flat_map(|ss| ss.iter_specs())
275 }
276
277 #[must_use]
280 pub fn spec_set(&self, repository: &Arc<LemmaRepository>, name: &str) -> Option<&LemmaSpecSet> {
281 let canonical_name = crate::parsing::ast::ascii_lowercase_logical_name(name.to_string());
282 self.repositories
283 .get(repository)
284 .and_then(|m| m.get(&canonical_name))
285 }
286
287 pub(crate) fn spec_sets_for(
290 &self,
291 repository: &Arc<LemmaRepository>,
292 ) -> impl Iterator<Item = &LemmaSpecSet> + '_ {
293 self.repositories
294 .get(repository)
295 .expect("BUG: repository not in context")
296 .values()
297 }
298
299 fn spec_declaration_source(spec: &LemmaSpec) -> crate::parsing::source::Source {
300 let source_type = spec
301 .source_type
302 .as_ref()
303 .expect("BUG: spec must carry source_type after parse");
304 crate::parsing::source::Source::new(
305 source_type.clone(),
306 crate::parsing::ast::Span {
307 start: 0,
308 end: 0,
309 line: spec.start_line,
310 col: 0,
311 },
312 )
313 }
314
315 fn duplicate_spec_path_line(spec: &LemmaSpec) -> (String, usize) {
316 let source_type = spec
317 .source_type
318 .as_ref()
319 .expect("BUG: spec must carry source_type after parse");
320 (source_type.to_string(), spec.start_line)
321 }
322
323 fn duplicate_spec_errors(name: &str, incoming: &LemmaSpec, existing: &LemmaSpec) -> Vec<Error> {
324 let (incoming_path, incoming_line) = Self::duplicate_spec_path_line(incoming);
325 let (existing_path, existing_line) = Self::duplicate_spec_path_line(existing);
326 vec![
327 Error::validation(
328 format!(
329 "Duplicate spec '{name}' (also declared in '{existing_path}':{existing_line})"
330 ),
331 Some(Self::spec_declaration_source(incoming)),
332 None::<String>,
333 ),
334 Error::validation(
335 format!(
336 "Duplicate spec '{name}' (also declared in '{incoming_path}':{incoming_line})"
337 ),
338 Some(Self::spec_declaration_source(existing)),
339 None::<String>,
340 ),
341 ]
342 }
343
344 pub fn insert_spec(
350 &mut self,
351 repository: Arc<LemmaRepository>,
352 spec: LemmaSpec,
353 ) -> Result<(), Vec<Error>> {
354 if let Some((existing_repo, _)) = self.repositories.get_key_value(&repository) {
355 if existing_repo.dependency != repository.dependency {
356 let repo_subject = match repository.name.as_deref() {
357 Some(name) => format!("Repository '{name}'"),
358 None => "The default repository".to_string(),
359 };
360 let existing_owner = match &existing_repo.dependency {
361 None => "a local source".to_string(),
362 Some(id) => format!("dependency '{id}'"),
363 };
364 let new_owner = match &repository.dependency {
365 None => "a local source".to_string(),
366 Some(id) => format!("dependency '{id}'"),
367 };
368 return Err(vec![Error::validation_with_context(
369 format!(
370 "{repo_subject} was introduced by {existing_owner} but {new_owner} also declares it"
371 ),
372 None,
373 Some("Each dependency's repositories must be unique across all loaded sources"),
374 Some(&spec),
375 None,
376 )]);
377 }
378 }
379
380 let entry = self
381 .repositories
382 .entry(Arc::clone(&repository))
383 .or_default();
384 if let Some(ss) = entry.get(&spec.name) {
385 if let Some(existing) = ss.get_exact(spec.effective_from()) {
386 return Err(Self::duplicate_spec_errors(&spec.name, &spec, existing));
387 }
388 }
389
390 let name = spec.name.clone();
391 if !entry
392 .entry(name.clone())
393 .or_insert_with(|| LemmaSpecSet::new(repository, name))
394 .insert(spec)
395 {
396 unreachable!("BUG: duplicate effective_from rejected above");
397 }
398 Ok(())
399 }
400
401 pub fn remove_spec(&mut self, repository: &Arc<LemmaRepository>, spec: &LemmaSpec) -> bool {
402 self.remove_spec_by_identity(repository, &spec.name, spec.effective_from())
403 }
404
405 pub fn remove_spec_by_identity(
407 &mut self,
408 repository: &Arc<LemmaRepository>,
409 name: &str,
410 effective_from: Option<&DateTimeValue>,
411 ) -> bool {
412 let Some(inner) = self.repositories.get_mut(repository) else {
413 return false;
414 };
415 let Some(ss) = inner.get_mut(name) else {
416 return false;
417 };
418 if !ss.remove(effective_from) {
419 return false;
420 }
421 if ss.is_empty() {
422 inner.shift_remove(name);
423 }
424 true
425 }
426}
427
428enum Mutation {
436 Remove {
437 repository: Option<String>,
438 spec: String,
439 effective_from: EffectiveDate,
440 },
441 Load {
442 source_type: SourceType,
443 code: String,
444 },
445 Replace {
448 repository: Option<String>,
449 source_type: SourceType,
450 code: String,
451 },
452}
453
454type StagedSpec = (SourceType, Arc<LemmaRepository>, LemmaSpec);
455
456#[derive(Serialize, Deserialize)]
465pub struct Engine {
466 pub(crate) context: Context,
467 pub(crate) plans: PlanStore,
468 limits: ResourceLimits,
469}
470
471impl Default for Engine {
472 fn default() -> Self {
473 Self::new()
474 }
475}
476
477impl Engine {
478 pub fn new() -> Self {
479 Self::with_limits(ResourceLimits::default())
480 }
481
482 pub fn with_limits(limits: ResourceLimits) -> Self {
483 let mut engine = Self {
484 context: Context::new(),
485 plans: PlanStore::new(),
486 limits,
487 };
488 engine
489 .apply(
490 vec![Mutation::Load {
491 source_type: SourceType::Dependency(EMBEDDED_STDLIB_REPOSITORY.to_string()),
492 code: crate::stdlib::UNITS_LEMMA.to_string(),
493 }],
494 true,
495 )
496 .expect("BUG: embedded stdlib must load");
497 engine
498 }
499
500 pub fn limits(&self) -> &ResourceLimits {
502 &self.limits
503 }
504
505 #[doc(hidden)]
510 pub fn debug_instance_list_row_counts(
511 &self,
512 repository: Option<&str>,
513 spec: &str,
514 ) -> Vec<(String, usize, usize)> {
515 let Some(plan) = self.plans.get_plan(
516 repository,
517 spec,
518 &EffectiveDate::DateTimeValue(DateTimeValue::now()),
519 ) else {
520 return Vec::new();
521 };
522 let mut rows: Vec<(String, usize, usize)> = plan
523 .instance_lists
524 .iter()
525 .map(|(path, source)| {
526 let label = if path.segments.is_empty() {
527 path.data.clone()
528 } else {
529 let mut parts: Vec<&str> = path
530 .segments
531 .iter()
532 .map(|segment| segment.uses.as_str())
533 .collect();
534 parts.push(path.data.as_str());
535 parts.join(".")
536 };
537 (
538 label,
539 source.rows.len(),
540 std::sync::Arc::as_ptr(&source.rows) as usize,
541 )
542 })
543 .collect();
544 rows.sort_by(|left, right| left.0.cmp(&right.0));
545 rows
546 }
547
548 #[doc(hidden)]
555 pub fn debug_row_template_arc_ptrs(
556 &self,
557 repository: Option<&str>,
558 spec: &str,
559 ) -> Vec<(String, String, usize)> {
560 let Some(plan) = self.plans.get_plan(
561 repository,
562 spec,
563 &EffectiveDate::DateTimeValue(DateTimeValue::now()),
564 ) else {
565 return Vec::new();
566 };
567 let mut rows = Vec::new();
568 for (identity, template) in &plan.row_templates {
569 let identity_label = identity.to_string();
570 rows.push((
571 String::new(),
572 identity_label.clone(),
573 std::sync::Arc::as_ptr(template) as usize,
574 ));
575 for (nested_id, nested) in &template.row_templates {
576 rows.push((
577 identity_label.clone(),
578 nested_id.to_string(),
579 std::sync::Arc::as_ptr(nested) as usize,
580 ));
581 }
582 }
583 rows.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
584 rows
585 }
586
587 pub fn snapshot(&self) -> Result<Vec<u8>, Error> {
595 crate::snapshot::encode(self)
596 }
597
598 pub fn from_snapshot(bytes: &[u8]) -> Result<Self, Error> {
604 crate::snapshot::decode(bytes)
605 }
606
607 pub fn load(
613 &mut self,
614 sources: impl IntoIterator<Item = (SourceType, impl Into<String>)>,
615 ) -> Result<(), Errors> {
616 let mutations = sources
617 .into_iter()
618 .map(|(source_type, code)| Mutation::Load {
619 source_type,
620 code: code.into(),
621 })
622 .collect();
623 self.apply(mutations, false)
624 }
625
626 pub fn update(
639 &mut self,
640 repository: Option<&str>,
641 code: String,
642 source_type: SourceType,
643 ) -> Result<(), Errors> {
644 self.apply(
645 vec![Mutation::Replace {
646 repository: repository.map(str::to_string),
647 source_type,
648 code,
649 }],
650 false,
651 )
652 }
653
654 pub fn remove(
656 &mut self,
657 repository: Option<&str>,
658 spec: &str,
659 effective: Option<&DateTimeValue>,
660 ) -> Result<(), Error> {
661 let resolved = self.get_spec(spec, repository, effective)?;
662 let effective_from = resolved.effective_from.clone();
663 self.apply(
664 vec![Mutation::Remove {
665 repository: repository.map(str::to_string),
666 spec: spec.to_string(),
667 effective_from,
668 }],
669 false,
670 )
671 .map_err(|errs| {
672 errs.errors
673 .into_iter()
674 .next()
675 .expect("BUG: apply Errors must contain at least one error")
676 })
677 }
678
679 #[must_use]
683 pub fn specs(&self) -> Vec<Repository> {
684 self.context
685 .repositories()
686 .iter()
687 .map(|(repo, inner)| {
688 let specs = inner
689 .values()
690 .flat_map(|spec_set| {
691 spec_set
692 .iter_with_ranges()
693 .map(|(spec, from, to)| SpecVersion {
694 name: spec.name.clone(),
695 effective_from: from,
696 effective_to: to,
697 dependencies: uses_dependencies(spec),
698 })
699 })
700 .collect();
701 Repository {
702 repository: repo.name.clone(),
703 specs,
704 from_dependency: repo.dependency.is_some(),
705 }
706 })
707 .collect()
708 }
709
710 pub fn show(
717 &self,
718 repository: Option<&str>,
719 spec: &str,
720 effective: Option<&DateTimeValue>,
721 ) -> Result<Show, Error> {
722 let effective_dt = self.effective_or_now(effective);
723 let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
724
725 let plan = match self.plans.get_plan(repository, spec, &instant) {
726 Some(plan) => plan,
727 None => {
728 let repository_arc = self.repository_for_lookup(repository)?;
731 let canonical_name =
732 crate::parsing::ast::ascii_lowercase_logical_name(spec.to_string());
733 let spec_set = self.context.spec_set(&repository_arc, &canonical_name);
734 return match spec_set.and_then(|ss| ss.spec_at(&instant)) {
735 None => Err(self.spec_not_found_in_repository_error(
736 &repository_arc,
737 spec,
738 &effective_dt,
739 )),
740 Some(_) => Err(Error::request_not_found(
741 format!(
742 "No execution plan slice for spec '{spec}' at effective {effective_dt}"
743 ),
744 Some("Ensure sources loaded and planning succeeded".to_string()),
745 )),
746 };
747 }
748 };
749 Ok(self.show_plan(plan, repository))
750 }
751
752 pub fn spec_declaration(
755 &self,
756 repository: Option<&str>,
757 spec: &str,
758 effective: Option<&DateTimeValue>,
759 ) -> Result<&LemmaSpec, Error> {
760 let effective_dt = self.effective_or_now(effective);
761 let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
762 let repository_arc = self.repository_for_lookup(repository)?;
763 let canonical_name = crate::parsing::ast::ascii_lowercase_logical_name(spec.to_string());
764 match self
765 .context
766 .spec_set(&repository_arc, &canonical_name)
767 .and_then(|spec_set| spec_set.spec_at(&instant))
768 {
769 Some(found) => Ok(found),
770 None => {
771 Err(self.spec_not_found_in_repository_error(&repository_arc, spec, &effective_dt))
772 }
773 }
774 }
775
776 pub fn show_spec(&self, identity: &SpecIdentity) -> Show {
779 let plan = self
780 .plans
781 .get_plan(
782 identity.repository.as_deref(),
783 &identity.spec,
784 &identity.effective,
785 )
786 .unwrap_or_else(|| panic!("BUG: no plan for resolved spec '{identity}'"));
787 self.show_plan(plan, identity.repository.as_deref())
788 }
789
790 pub(crate) fn plan_matching_show(
793 &self,
794 show: &crate::planning::execution_plan::Show,
795 ) -> Option<&crate::planning::execution_plan::ExecutionPlan> {
796 let plans = self
797 .plans
798 .get_plans(show.repository.as_deref(), &show.spec)?;
799 let mut matches = plans.values().filter(|plan| {
800 plan.spec_name == show.spec
801 && plan.start_line == show.start_line
802 && plan.effective_from == show.effective_from
803 && plan.effective_to == show.effective_to
804 && plan.effective == show.plan_effective
805 });
806 let first = matches.next()?;
807 if matches.next().is_some() {
808 panic!(
809 "BUG: multiple plans match Show '{}' (start_line {}, effective window, plan_effective)",
810 show.spec, show.start_line
811 );
812 }
813 Some(first)
814 }
815
816 fn show_plan(&self, plan: &ExecutionPlan, repository: Option<&str>) -> Show {
817 let mut type_entries: Vec<(usize, String, crate::planning::semantics::LemmaType)> =
818 Vec::new();
819 let mut data_entries: Vec<(usize, usize, String, ShowData)> = Vec::new();
820
821 for (path, definition) in plan.nested_input_keys() {
822 let Some(lemma_type) = definition.schema_type().cloned() else {
823 continue;
824 };
825 let input_key = path.input_key();
826 if path.segments.is_empty()
827 && matches!(
828 definition,
829 DataDefinition::Value { .. }
830 | DataDefinition::TypeDeclaration { .. }
831 | DataDefinition::Reference { .. }
832 )
833 {
834 type_entries.push((
835 definition.source().span.start,
836 input_key.clone(),
837 lemma_type.clone(),
838 ));
839 }
840 let rule_positions = plan.data_needed_by(&path);
841 if rule_positions.is_empty() {
842 continue;
843 }
844 let used_by: Vec<String> = rule_positions
845 .iter()
846 .map(|&rule_position| {
847 plan.rules
848 .get_index(rule_position as usize)
849 .expect("BUG: needed_by_rules position out of plan.rules range")
850 .1
851 .path
852 .input_key()
853 })
854 .collect();
855 let display = plan.data_display.get(&path);
856 data_entries.push((
857 path.segments.len(),
858 definition.source().span.start,
859 input_key.clone(),
860 ShowData {
861 lemma_type: lemma_type.clone(),
862 path: path.segments.clone(),
863 value: match definition {
864 DataDefinition::InstanceList { .. } => {
865 crate::planning::execution_plan::show_instance_list_value(plan, &path)
866 }
867 _ => {
868 if let Some(target) = plan.import_overlay_reference_target(&path) {
869 Some(crate::result_value::RuleResultValue {
872 reference: Some(target),
873 ..Default::default()
874 })
875 } else {
876 display.and_then(|d| {
877 d.value.as_ref().map(|bound| {
878 crate::planning::execution_plan::show_bound_result_value(
879 bound,
880 &lemma_type,
881 &input_key,
882 crate::planning::execution_plan::ShowBoundRole::Value,
883 )
884 })
885 })
886 }
887 }
888 },
889 suggestion: display.and_then(|d| {
890 d.suggestion.as_ref().map(|bound| {
891 crate::planning::execution_plan::show_bound_result_value(
892 bound,
893 &lemma_type,
894 &input_key,
895 crate::planning::execution_plan::ShowBoundRole::Suggestion,
896 )
897 })
898 }),
899 needed_by_rules: used_by,
900 },
901 ));
902 }
903 type_entries.sort_by_key(|(pos, _, _)| *pos);
904 data_entries.sort_by_key(|(depth, pos, _, _)| (*depth, *pos));
905
906 let rule_entries: Vec<(String, crate::planning::show_expression::ShowRule)> = plan
907 .show_rules
908 .iter()
909 .map(|(path, rule)| (path.input_key(), rule.clone()))
910 .collect();
911
912 let show_repository = match repository {
913 None => None,
914 Some(q) => {
915 let repo = self
916 .context
917 .find_repository(q)
918 .expect("BUG: get_plan succeeded so that repository must be loaded");
919 Some(
920 repo.name
921 .clone()
922 .expect("BUG: named repository cannot have name None"),
923 )
924 }
925 };
926
927 Show {
928 repository: show_repository,
929 spec: plan.spec_name.clone(),
930 commentary: plan.commentary.clone(),
931 effective_from: plan.effective_from.clone(),
932 effective_to: plan.effective_to.clone(),
933 plan_effective: plan.effective.clone(),
934 start_line: plan.start_line,
935 source_type: plan.source_type.clone(),
936 types: type_entries
937 .into_iter()
938 .map(|(_, name, lemma_type)| (name, lemma_type))
939 .collect(),
940 data: data_entries
941 .into_iter()
942 .map(|(_, _, name, entry)| (name, entry))
943 .collect(),
944 rules: rule_entries.into_iter().collect(),
945 meta: plan.meta.clone(),
946 }
947 }
948
949 pub fn source(
954 &self,
955 repository: Option<&str>,
956 spec: Option<&str>,
957 effective: Option<&DateTimeValue>,
958 ) -> Result<String, Error> {
959 match spec {
960 None => self.format_repository_source(repository),
961 Some(spec_name) => {
962 let effective_dt = self.effective_or_now(effective);
963 let resolved_spec = self.get_spec(spec_name, repository, Some(&effective_dt))?;
964 Ok(crate::formatting::format_spec_refs(&[resolved_spec]))
965 }
966 }
967 }
968
969 pub fn evaluate(
971 &self,
972 repository: Option<&str>,
973 spec: &str,
974 effective: Option<&DateTimeValue>,
975 data: IndexMap<String, EvaluateDataValue>,
976 rules: Option<&[String]>,
977 explain: bool,
978 ) -> Result<Response, Error> {
979 let effective = self.effective_or_now(effective);
980 let instant = EffectiveDate::DateTimeValue(effective.clone());
981
982 let plan = self
983 .plans
984 .get_plan(repository, spec, &instant)
985 .ok_or_else(|| {
986 Error::request_not_found(
987 format!("No execution plan for spec '{spec}' at effective {effective}"),
988 Some("Ensure sources loaded and planning succeeded".to_string()),
989 )
990 })?;
991
992 let response_rules = plan.validated_response_rule_paths(rules)?;
993 let evaluate_data = EvaluateData::resolve(plan, data, &self.limits)?;
994 let now_semantic = crate::planning::semantics::date_time_to_semantic(&effective);
995 let now_literal = crate::planning::semantics::LiteralValue::date(now_semantic);
996 let evaluator = Evaluator;
997 let mut response =
998 evaluator.evaluate(plan, &evaluate_data, now_literal, &response_rules, explain);
999
1000 response.spec_effective_from = plan.effective_from.clone();
1001 response.spec_effective_to = plan.effective_to.clone();
1002
1003 Ok(response)
1004 }
1005
1006 #[deprecated(note = "use evaluate")]
1008 pub fn run(
1009 &self,
1010 repository: Option<&str>,
1011 spec: &str,
1012 effective: Option<&DateTimeValue>,
1013 data: IndexMap<String, EvaluateDataValue>,
1014 rules: Option<&[String]>,
1015 explain: bool,
1016 ) -> Result<Response, Error> {
1017 self.evaluate(repository, spec, effective, data, rules, explain)
1018 }
1019
1020 fn format_repository_source(&self, repository: Option<&str>) -> Result<String, Error> {
1021 let repo_arc = self.resolve_repository(repository)?;
1022 let mut all_specs: Vec<&LemmaSpec> = self
1023 .context
1024 .spec_sets_for(&repo_arc)
1025 .flat_map(|ss| ss.iter_specs())
1026 .collect();
1027 all_specs.sort_by(|a, b| {
1028 a.name
1029 .cmp(&b.name)
1030 .then_with(|| a.effective_from.cmp(&b.effective_from))
1031 });
1032 let body = crate::formatting::format_spec_refs(&all_specs);
1033 let mut source_text = String::new();
1034 if let Some(name) = repo_arc.name.as_deref() {
1035 source_text.push_str("repo ");
1036 source_text.push_str(name);
1037 source_text.push_str("\n\n");
1038 }
1039 source_text.push_str(&body);
1040 Ok(source_text)
1041 }
1042
1043 fn resolve_repository(&self, repository: Option<&str>) -> Result<Arc<LemmaRepository>, Error> {
1044 match repository {
1045 None => Ok(self.context.workspace()),
1046 Some(qualifier) => {
1047 let q = qualifier.trim();
1048 if q.is_empty() {
1049 return Err(Error::request(
1050 "Repository qualifier cannot be empty",
1051 None::<String>,
1052 ));
1053 }
1054 self.context.find_repository(q).ok_or_else(|| {
1055 Error::request_not_found(
1056 format!("Repository '{qualifier}' not loaded"),
1057 Some(format!(
1058 "List repositories with `{}` after loading your workspace",
1059 "lemma specs"
1060 )),
1061 )
1062 })
1063 }
1064 }
1065 }
1066
1067 fn spec_not_found_in_repository_error(
1068 &self,
1069 repository: &LemmaRepository,
1070 spec_name: &str,
1071 effective: &DateTimeValue,
1072 ) -> Error {
1073 let message = match &repository.name {
1074 Some(n) => {
1075 format!("Spec '{spec_name}' not found in repository {n} at effective {effective}")
1076 }
1077 None => format!("Spec '{spec_name}' not found at effective {effective}"),
1078 };
1079 Error::request_not_found(message, Some("Try `lemma specs`"))
1080 }
1081
1082 fn repository_for_lookup(
1083 &self,
1084 repository: Option<&str>,
1085 ) -> Result<Arc<LemmaRepository>, Error> {
1086 match repository {
1087 Some(q) => self.context.find_repository(q).ok_or_else(|| {
1088 Error::request_not_found(
1089 format!("Repository '{q}' not loaded"),
1090 Some("List repositories with `lemma specs` after loading your workspace"),
1091 )
1092 }),
1093 None => Ok(self.context.workspace()),
1094 }
1095 }
1096
1097 #[must_use]
1099 fn effective_or_now(&self, effective: Option<&DateTimeValue>) -> DateTimeValue {
1100 effective.cloned().unwrap_or_else(DateTimeValue::now)
1101 }
1102
1103 fn reserved_stdlib_error(source: Option<crate::parsing::source::Source>) -> Error {
1104 Error::validation(
1105 format!(
1106 "Repository '{EMBEDDED_STDLIB_REPOSITORY}' is reserved for the embedded standard library and cannot be loaded via load; use @owner/repo qualifiers (e.g. '@iso/countries'), not the reserved 'lemma' repository"
1107 ),
1108 source,
1109 Some(
1110 "Load registry dependencies with @owner/repo qualifiers, not the reserved 'lemma' stdlib repository"
1111 .to_string(),
1112 ),
1113 )
1114 }
1115
1116 fn resource_limit_errors(
1117 name: &str,
1118 limit: impl ToString,
1119 actual: impl ToString,
1120 hint: &str,
1121 sources: IndexMap<SourceType, String>,
1122 ) -> Errors {
1123 Errors {
1124 errors: vec![Error::resource_limit_exceeded(
1125 name,
1126 limit.to_string(),
1127 actual.to_string(),
1128 hint,
1129 None::<crate::parsing::source::Source>,
1130 None,
1131 None,
1132 )],
1133 sources: sources.into_iter().collect(),
1134 }
1135 }
1136
1137 fn apply(&mut self, mutations: Vec<Mutation>, embedded_stdlib: bool) -> Result<(), Errors> {
1142 let mut sources: IndexMap<SourceType, String> = IndexMap::new();
1143 let mut to_restore: Vec<(Arc<LemmaRepository>, LemmaSpec)> = Vec::new();
1144 let mut replace: Option<(Option<String>, SourceType, String)> = None;
1145 let mut saw_load = false;
1146 let mut saw_remove = false;
1147
1148 for mutation in mutations {
1149 match mutation {
1150 Mutation::Remove {
1151 repository,
1152 spec,
1153 effective_from,
1154 } => {
1155 let repo_ref = repository.as_deref();
1156 let repository_arc = self.resolve_repository(repo_ref).unwrap_or_else(|e| {
1157 panic!(
1158 "BUG: Mutation::Remove repository must resolve after public remove validated it: {e}"
1159 )
1160 });
1161 let spec_to_remove = self
1162 .context
1163 .spec_set(&repository_arc, &spec)
1164 .and_then(|ss| ss.get_exact(effective_from.as_ref()))
1165 .unwrap_or_else(|| {
1166 panic!(
1167 "BUG: Mutation::Remove target '{spec}' must exist after public remove validated it"
1168 )
1169 });
1170 saw_remove = true;
1171 to_restore.push((Arc::clone(&repository_arc), spec_to_remove.clone()));
1172 }
1173 Mutation::Load { source_type, code } => {
1174 saw_load = true;
1175 if replace.is_some() {
1176 panic!("BUG: Load and Replace in one apply");
1177 }
1178 if sources.insert(source_type.clone(), code).is_some() {
1179 return Err(Errors {
1180 errors: vec![Error::request(
1181 format!("Duplicate source key: {source_type}"),
1182 None::<String>,
1183 )],
1184 sources: sources.into_iter().collect(),
1185 });
1186 }
1187 }
1188 Mutation::Replace {
1189 repository,
1190 source_type,
1191 code,
1192 } => {
1193 if saw_load || !sources.is_empty() {
1194 panic!("BUG: Load and Replace in one apply");
1195 }
1196 if replace.is_some() {
1197 panic!("BUG: multiple Replace mutations in one apply");
1198 }
1199 replace = Some((repository, source_type, code));
1200 }
1201 }
1202 }
1203
1204 if saw_remove && (saw_load || replace.is_some()) {
1205 panic!("BUG: Remove mixed with Load or Replace in one apply");
1206 }
1207
1208 if let Some((repo_constraint, source_type, code)) = replace {
1209 return self.apply_replace(
1210 repo_constraint.as_deref(),
1211 source_type,
1212 code,
1213 embedded_stdlib,
1214 );
1215 }
1216
1217 let sources_map: HashMap<SourceType, String> = sources.clone().into_iter().collect();
1218 for st in sources.keys() {
1219 if let Err(e) = Self::validate_source_type_key(st, embedded_stdlib) {
1220 return Err(Errors {
1221 errors: vec![e],
1222 sources: sources_map,
1223 });
1224 }
1225 }
1226 self.check_batch_limits(&sources, embedded_stdlib)?;
1227
1228 let parse_limits = if embedded_stdlib {
1229 &ResourceLimits::default()
1230 } else {
1231 &self.limits
1232 };
1233 let mut staged: Vec<StagedSpec> = Vec::new();
1234 let mut errors: Vec<Error> = Vec::new();
1235
1236 for (source_id, code) in &sources {
1237 match self.stage_parsed_source(source_id, code, parse_limits, embedded_stdlib) {
1238 Ok(chunk) => staged.extend(chunk),
1239 Err(es) => errors.extend(es),
1240 }
1241 }
1242
1243 if !errors.is_empty() {
1244 return Err(Errors {
1245 errors,
1246 sources: sources.into_iter().collect(),
1247 });
1248 }
1249
1250 self.commit_staged(to_restore, staged, sources.into_iter().collect())
1251 }
1252
1253 fn validate_source_type_key(
1254 source_type: &SourceType,
1255 embedded_stdlib: bool,
1256 ) -> Result<(), Error> {
1257 match source_type {
1258 SourceType::Path(p) if p.as_os_str().to_string_lossy().trim().is_empty() => Err(
1259 Error::request("Source path must be non-empty", None::<String>),
1260 ),
1261 SourceType::Dependency(id) if id.is_empty() => Err(Error::request(
1262 "Dependency source identifier must be non-empty",
1263 None::<String>,
1264 )),
1265 SourceType::Dependency(id) if !embedded_stdlib && id == EMBEDDED_STDLIB_REPOSITORY => {
1266 Err(Self::reserved_stdlib_error(None))
1267 }
1268 _ => Ok(()),
1269 }
1270 }
1271
1272 fn check_batch_limits(
1273 &self,
1274 sources: &IndexMap<SourceType, String>,
1275 embedded_stdlib: bool,
1276 ) -> Result<(), Errors> {
1277 if embedded_stdlib || sources.is_empty() {
1278 return Ok(());
1279 }
1280 let limits = &self.limits;
1281 if sources.len() > limits.max_sources {
1282 return Err(Self::resource_limit_errors(
1283 "max_sources",
1284 limits.max_sources,
1285 sources.len(),
1286 "Reduce the number of paths or sources in one load",
1287 sources.clone(),
1288 ));
1289 }
1290 let total_loaded_bytes: usize = sources.values().map(|s| s.len()).sum();
1291 if total_loaded_bytes > limits.max_loaded_bytes {
1292 return Err(Self::resource_limit_errors(
1293 "max_loaded_bytes",
1294 limits.max_loaded_bytes,
1295 total_loaded_bytes,
1296 "Load fewer or smaller sources",
1297 sources.clone(),
1298 ));
1299 }
1300 if let Some(code) = sources
1301 .values()
1302 .find(|code| code.len() > limits.max_source_size_bytes)
1303 {
1304 return Err(Self::resource_limit_errors(
1305 "max_source_size_bytes",
1306 limits.max_source_size_bytes,
1307 code.len(),
1308 "Use a smaller source text or increase limit",
1309 sources.clone(),
1310 ));
1311 }
1312 Ok(())
1313 }
1314
1315 fn stage_parsed_source(
1316 &self,
1317 source_id: &SourceType,
1318 code: &str,
1319 parse_limits: &ResourceLimits,
1320 embedded_stdlib: bool,
1321 ) -> Result<Vec<StagedSpec>, Vec<Error>> {
1322 let dependency = match source_id {
1323 SourceType::Dependency(id) => Some(id.as_str()),
1324 _ => None,
1325 };
1326 let result = parse(code, source_id.clone(), parse_limits).map_err(|e| vec![e])?;
1327 if result.repositories.is_empty() {
1328 return Ok(Vec::new());
1329 }
1330
1331 let mut staged = Vec::new();
1332 let mut errors = Vec::new();
1333 for (parsed_repo, specs) in result.repositories {
1334 let repository_arc = if let Some(dep_id) = dependency {
1335 let repo_name = parsed_repo
1336 .name
1337 .clone()
1338 .or_else(|| Some(dep_id.to_string()));
1339 Arc::new(
1340 LemmaRepository::new(repo_name)
1341 .with_dependency(dep_id)
1342 .with_start_line(parsed_repo.start_line),
1343 )
1344 } else {
1345 parsed_repo
1346 };
1347 if !embedded_stdlib
1348 && repository_arc.name.as_deref() == Some(EMBEDDED_STDLIB_REPOSITORY)
1349 {
1350 let source = crate::parsing::source::Source::new(
1351 source_id.clone(),
1352 crate::parsing::ast::Span {
1353 start: 0,
1354 end: 0,
1355 line: repository_arc.start_line,
1356 col: 0,
1357 },
1358 );
1359 errors.push(Self::reserved_stdlib_error(Some(source)));
1360 continue;
1361 }
1362 for spec in specs {
1363 staged.push((source_id.clone(), Arc::clone(&repository_arc), spec));
1364 }
1365 }
1366 if !errors.is_empty() {
1367 return Err(errors);
1368 }
1369 Ok(staged)
1370 }
1371
1372 fn apply_replace(
1373 &mut self,
1374 repository_constraint: Option<&str>,
1375 source_type: SourceType,
1376 code: String,
1377 embedded_stdlib: bool,
1378 ) -> Result<(), Errors> {
1379 let mut sources: IndexMap<SourceType, String> = IndexMap::new();
1380 sources.insert(source_type.clone(), code.clone());
1381
1382 if let Err(e) = Self::validate_source_type_key(&source_type, embedded_stdlib) {
1383 return Err(Errors {
1384 errors: vec![e],
1385 sources: sources.into_iter().collect(),
1386 });
1387 }
1388
1389 self.check_batch_limits(&sources, embedded_stdlib)?;
1390
1391 let parse_limits = if embedded_stdlib {
1392 &ResourceLimits::default()
1393 } else {
1394 &self.limits
1395 };
1396
1397 let staged =
1398 match self.stage_parsed_source(&source_type, &code, parse_limits, embedded_stdlib) {
1399 Ok(s) => s,
1400 Err(errors) => {
1401 return Err(Errors {
1402 errors,
1403 sources: sources.into_iter().collect(),
1404 });
1405 }
1406 };
1407
1408 let prune = matches!(source_type, SourceType::Path(_) | SourceType::Dependency(_));
1409 if staged.is_empty() && !prune {
1410 return Err(Errors {
1411 errors: vec![Error::request(
1412 "update requires at least one spec",
1413 None::<String>,
1414 )],
1415 sources: sources.into_iter().collect(),
1416 });
1417 }
1418
1419 if let Some(required) = repository_constraint {
1420 let required_canonical =
1421 crate::parsing::ast::ascii_lowercase_logical_name(required.to_string());
1422 for (_, repository_arc, _) in &staged {
1423 if repository_arc.name.as_deref() != Some(required_canonical.as_str()) {
1424 return Err(Errors {
1425 errors: vec![Error::request(
1426 match repository_arc.name.as_deref() {
1427 Some(name) => format!(
1428 "update repository '{required}' does not match staged repository '{name}'"
1429 ),
1430 None => format!(
1431 "update repository '{required}' does not match the default repository"
1432 ),
1433 },
1434 None::<String>,
1435 )],
1436 sources: sources.into_iter().collect(),
1437 });
1438 }
1439 }
1440 }
1441
1442 let mut staged_keys: std::collections::HashSet<(Option<String>, String, EffectiveDate)> =
1443 std::collections::HashSet::new();
1444 for (_, repository_arc, spec) in &staged {
1445 staged_keys.insert((
1446 repository_arc.name.clone(),
1447 spec.name.clone(),
1448 spec.effective_from.clone(),
1449 ));
1450 }
1451
1452 let mut to_restore: Vec<(Arc<LemmaRepository>, LemmaSpec)> = Vec::new();
1453 for (repository, by_name) in self.context.repositories() {
1454 for spec_set in by_name.values() {
1455 for spec in spec_set.iter_specs() {
1456 if spec.source_type.as_ref() != Some(&source_type) {
1457 continue;
1458 }
1459 let key = (
1460 repository.name.clone(),
1461 spec.name.clone(),
1462 spec.effective_from.clone(),
1463 );
1464 if prune || staged_keys.contains(&key) {
1465 to_restore.push((Arc::clone(repository), spec.clone()));
1466 }
1467 }
1468 }
1469 }
1470
1471 let staged_for_commit: Vec<StagedSpec> = staged
1472 .into_iter()
1473 .map(|(_, repo, spec)| (source_type.clone(), repo, spec))
1474 .collect();
1475
1476 self.commit_staged(to_restore, staged_for_commit, sources.into_iter().collect())
1477 }
1478
1479 fn commit_staged(
1480 &mut self,
1481 to_restore: Vec<(Arc<LemmaRepository>, LemmaSpec)>,
1482 staged: Vec<StagedSpec>,
1483 sources: HashMap<SourceType, String>,
1484 ) -> Result<(), Errors> {
1485 for (repo, spec) in &to_restore {
1486 if !self.context.remove_spec(repo, spec) {
1487 panic!("BUG: to_restore row must be live in context");
1488 }
1489 }
1490
1491 let mut inserted: Vec<(Arc<LemmaRepository>, String, EffectiveDate)> = Vec::new();
1492 let mut errors: Vec<Error> = Vec::new();
1493 for (_, repository_arc, spec) in staged {
1494 let name = spec.name.clone();
1495 let effective_from = spec.effective_from.clone();
1496 match self.context.insert_spec(Arc::clone(&repository_arc), spec) {
1497 Ok(()) => inserted.push((repository_arc, name, effective_from)),
1498 Err(es) => errors.extend(es),
1499 }
1500 }
1501 if !errors.is_empty() {
1502 self.rollback_apply(&inserted, &to_restore);
1503 return Err(Errors { errors, sources });
1504 }
1505
1506 let result = crate::planning::plan(&self.context, &self.limits, &self.plans);
1507 if !result.errors.is_empty() {
1508 self.rollback_apply(&inserted, &to_restore);
1509 return Err(Errors {
1510 errors: result.errors,
1511 sources,
1512 });
1513 }
1514
1515 self.plans = result.plans;
1516 Ok(())
1517 }
1518
1519 fn rollback_apply(
1520 &mut self,
1521 inserted: &[(Arc<LemmaRepository>, String, EffectiveDate)],
1522 removed: &[(Arc<LemmaRepository>, LemmaSpec)],
1523 ) {
1524 for (repo, inserted_name, inserted_effective) in inserted.iter().rev() {
1525 if !self.context.remove_spec_by_identity(
1526 repo,
1527 inserted_name,
1528 inserted_effective.as_ref(),
1529 ) {
1530 panic!("BUG: inserted spec must be removable on rollback");
1531 }
1532 }
1533 for (repo, spec) in removed.iter().rev() {
1534 self.context
1535 .insert_spec(Arc::clone(repo), spec.clone())
1536 .expect("BUG: restore removed spec for rollback");
1537 }
1538 }
1539
1540 pub(crate) fn get_spec(
1544 &self,
1545 name: &str,
1546 repository: Option<&str>,
1547 effective: Option<&DateTimeValue>,
1548 ) -> Result<&LemmaSpec, Error> {
1549 let effective_dt = self.effective_or_now(effective);
1550 let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
1551 let repository_arc = match repository {
1552 Some(q) => self.context.find_repository(q).ok_or_else(|| {
1553 Error::request_not_found(
1554 format!("Repository '{q}' not loaded"),
1555 Some("List repositories with `lemma specs` after loading your workspace"),
1556 )
1557 })?,
1558 None => self.context.workspace(),
1559 };
1560 let spec_set = self
1561 .context
1562 .spec_set(&repository_arc, name)
1563 .ok_or_else(|| {
1564 self.spec_not_found_in_repository_error(&repository_arc, name, &effective_dt)
1565 })?;
1566 spec_set.spec_at(&instant).ok_or_else(|| {
1567 self.spec_not_found_in_repository_error(&repository_arc, name, &effective_dt)
1568 })
1569 }
1570}
1571#[cfg(test)]
1572mod tests {
1573 use super::*;
1574
1575 fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
1576 DateTimeValue {
1577 year,
1578 month,
1579 day,
1580 hour: 0,
1581 minute: 0,
1582 second: 0,
1583 microsecond: 0,
1584 timezone: None,
1585 granularity: crate::literals::DateGranularity::Full,
1586 }
1587 }
1588
1589 #[test]
1590 fn format_repositories_prints_each_temporal_version() {
1591 let text = format_repositories(
1592 &[
1593 Repository {
1594 repository: None,
1595 from_dependency: false,
1596 specs: vec![
1597 SpecVersion {
1598 name: "legacy".into(),
1599 effective_from: None,
1600 effective_to: Some(date(2026, 1, 1)),
1601 dependencies: Vec::new(),
1602 },
1603 SpecVersion {
1604 name: "taxes".into(),
1605 effective_from: None,
1606 effective_to: None,
1607 dependencies: Vec::new(),
1608 },
1609 SpecVersion {
1610 name: "pricing".into(),
1611 effective_from: Some(date(2026, 1, 1)),
1612 effective_to: None,
1613 dependencies: Vec::new(),
1614 },
1615 SpecVersion {
1616 name: "pricing".into(),
1617 effective_from: Some(date(2025, 1, 1)),
1618 effective_to: Some(date(2026, 1, 1)),
1619 dependencies: Vec::new(),
1620 },
1621 ],
1622 },
1623 Repository {
1624 repository: Some("@iso/countries".into()),
1625 from_dependency: true,
1626 specs: vec![SpecVersion {
1627 name: "alpha2".into(),
1628 effective_from: Some(date(2011, 8, 9)),
1629 effective_to: None,
1630 dependencies: Vec::new(),
1631 }],
1632 },
1633 ],
1634 true,
1635 );
1636 assert_eq!(
1637 text,
1638 "legacy ...2026-01-01\npricing 2025-01-01...2026-01-01\npricing 2026-01-01\ntaxes\n\n@iso/countries\n alpha2 2011-08-09\n"
1639 );
1640 }
1641
1642 #[test]
1643 fn spec_version_display_omits_arrow_without_an_end() {
1644 assert_eq!(
1645 SpecVersion {
1646 name: "pricing".into(),
1647 effective_from: Some(date(2025, 1, 1)),
1648 effective_to: Some(date(2026, 1, 1)),
1649 dependencies: Vec::new(),
1650 }
1651 .to_string(),
1652 "pricing 2025-01-01...2026-01-01"
1653 );
1654 assert_eq!(
1655 SpecVersion {
1656 name: "pricing".into(),
1657 effective_from: Some(date(2026, 1, 1)),
1658 effective_to: None,
1659 dependencies: Vec::new(),
1660 }
1661 .to_string(),
1662 "pricing 2026-01-01"
1663 );
1664 assert_eq!(
1665 SpecVersion {
1666 name: "pricing".into(),
1667 effective_from: None,
1668 effective_to: Some(date(2026, 1, 1)),
1669 dependencies: Vec::new(),
1670 }
1671 .to_string(),
1672 "pricing ...2026-01-01"
1673 );
1674 assert_eq!(
1675 SpecVersion {
1676 name: "taxes".into(),
1677 effective_from: None,
1678 effective_to: None,
1679 dependencies: Vec::new(),
1680 }
1681 .to_string(),
1682 "taxes"
1683 );
1684 }
1685
1686 #[test]
1687 fn format_local_specs_prints_uses_and_omits_dependency_repositories() {
1688 let text = format_repositories(
1689 &[
1690 Repository {
1691 repository: None,
1692 from_dependency: false,
1693 specs: vec![SpecVersion {
1694 name: "outer".into(),
1695 effective_from: None,
1696 effective_to: None,
1697 dependencies: vec![
1698 SpecDependency {
1699 repository: None,
1700 name: "inner".into(),
1701 effective: None,
1702 },
1703 SpecDependency {
1704 repository: Some("lemma".into()),
1705 name: "units".into(),
1706 effective: None,
1707 },
1708 ],
1709 }],
1710 },
1711 Repository {
1712 repository: Some("lemma".into()),
1713 from_dependency: true,
1714 specs: vec![SpecVersion {
1715 name: "units".into(),
1716 effective_from: None,
1717 effective_to: None,
1718 dependencies: Vec::new(),
1719 }],
1720 },
1721 ],
1722 false,
1723 );
1724 assert_eq!(text, "outer\n inner\n lemma units\n");
1725 }
1726
1727 #[test]
1728 fn specs_rows_carry_uses_targets() {
1729 let mut engine = Engine::new();
1730 engine
1731 .load([(
1732 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1733 r#"
1734spec inner 2024-01-01
1735data x: 1
1736
1737spec outer
1738uses inner 2024-01-01
1739uses u: lemma units
1740rule r: inner.x
1741"#
1742 .to_string(),
1743 )])
1744 .expect("load");
1745 let workspace = engine
1746 .specs()
1747 .into_iter()
1748 .find(|repo| repo.repository.is_none())
1749 .expect("workspace");
1750 let outer = workspace
1751 .specs
1752 .iter()
1753 .find(|version| version.name == "outer")
1754 .expect("outer");
1755 assert_eq!(
1756 outer.dependencies,
1757 vec![
1758 SpecDependency {
1759 repository: None,
1760 name: "inner".into(),
1761 effective: Some(date(2024, 1, 1)),
1762 },
1763 SpecDependency {
1764 repository: Some("lemma".into()),
1765 name: "units".into(),
1766 effective: None,
1767 },
1768 ]
1769 );
1770 assert!(engine
1771 .specs()
1772 .iter()
1773 .any(|repo| repo.repository.as_deref() == Some("lemma") && repo.from_dependency));
1774 }
1775
1776 fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
1777 let mut spec = LemmaSpec::new(name.to_string());
1778 spec.effective_from = crate::parsing::ast::EffectiveDate::from_option(effective_from);
1779 spec
1780 }
1781
1782 #[test]
1785 fn list_order_is_name_then_effective_from_ascending() {
1786 let mut ctx = Context::new();
1787 let repository = ctx.workspace();
1788 let s_2026 = make_spec_with_range("mortgage", Some(date(2026, 1, 1)));
1789 let s_2025 = make_spec_with_range("mortgage", Some(date(2025, 1, 1)));
1790 ctx.insert_spec(Arc::clone(&repository), s_2026).unwrap();
1791 ctx.insert_spec(Arc::clone(&repository), s_2025).unwrap();
1792 let listed: Vec<_> = ctx
1793 .spec_set(&repository, "mortgage")
1794 .expect("mortgage set")
1795 .iter_specs()
1796 .collect();
1797 assert_eq!(listed.len(), 2);
1798 assert_eq!(listed[0].effective_from(), Some(&date(2025, 1, 1)));
1799 assert_eq!(listed[1].effective_from(), Some(&date(2026, 1, 1)));
1800 }
1801
1802 #[test]
1803 fn get_spec_resolves_temporal_version_by_effective() {
1804 let mut engine = Engine::new();
1805 engine
1806 .load([(
1807 SourceType::Path(Arc::new(std::path::PathBuf::from("a.lemma"))),
1808 r#"
1809 spec pricing 2025-01-01
1810 data x: 1
1811 rule r: x
1812 "#
1813 .to_string(),
1814 )])
1815 .unwrap();
1816 engine
1817 .load([(
1818 SourceType::Path(Arc::new(std::path::PathBuf::from("b.lemma"))),
1819 r#"
1820 spec pricing 2025-06-01
1821 data x: 2
1822 rule r: x
1823 "#
1824 .to_string(),
1825 )])
1826 .unwrap();
1827
1828 let jan = DateTimeValue {
1829 year: 2025,
1830 month: 1,
1831 day: 15,
1832 hour: 0,
1833 minute: 0,
1834 second: 0,
1835 microsecond: 0,
1836 timezone: None,
1837 granularity: crate::literals::DateGranularity::Full,
1838 };
1839 let jul = DateTimeValue {
1840 year: 2025,
1841 month: 7,
1842 day: 1,
1843 hour: 0,
1844 minute: 0,
1845 second: 0,
1846 microsecond: 0,
1847 timezone: None,
1848 granularity: crate::literals::DateGranularity::Full,
1849 };
1850
1851 let v1 = DateTimeValue {
1852 year: 2025,
1853 month: 1,
1854 day: 1,
1855 hour: 0,
1856 minute: 0,
1857 second: 0,
1858 microsecond: 0,
1859 timezone: None,
1860 granularity: crate::literals::DateGranularity::Full,
1861 };
1862 let v2 = DateTimeValue {
1863 year: 2025,
1864 month: 6,
1865 day: 1,
1866 hour: 0,
1867 minute: 0,
1868 second: 0,
1869 microsecond: 0,
1870 timezone: None,
1871 granularity: crate::literals::DateGranularity::Full,
1872 };
1873
1874 let s_jan = engine
1875 .get_spec("pricing", None, Some(&jan))
1876 .expect("jan spec");
1877 let s_jul = engine
1878 .get_spec("pricing", None, Some(&jul))
1879 .expect("jul spec");
1880 assert_eq!(s_jan.effective_from(), Some(&v1));
1881 assert_eq!(s_jul.effective_from(), Some(&v2));
1882 }
1883
1884 #[test]
1889 fn list_returns_half_open_ranges_per_temporal_version() {
1890 let mut engine = Engine::new();
1891 engine
1892 .load([(
1893 SourceType::Path(Arc::new(std::path::PathBuf::from("a.lemma"))),
1894 r#"
1895 spec pricing 2025-01-01
1896 data x: 1
1897 rule r: x
1898 "#
1899 .to_string(),
1900 )])
1901 .unwrap();
1902 engine
1903 .load([(
1904 SourceType::Path(Arc::new(std::path::PathBuf::from("b.lemma"))),
1905 r#"
1906 spec pricing 2025-06-01
1907 data x: 2
1908 rule r: x
1909 "#
1910 .to_string(),
1911 )])
1912 .unwrap();
1913
1914 let january = date(2025, 1, 1);
1915 let june = date(2025, 6, 1);
1916
1917 let workspace = engine
1918 .specs()
1919 .into_iter()
1920 .find(|r| r.repository.is_none())
1921 .expect("workspace");
1922 let mut pricing_rows: Vec<_> = workspace
1923 .specs
1924 .iter()
1925 .filter(|ls| ls.name == "pricing")
1926 .map(|ls| (ls.effective_from.clone(), ls.effective_to.clone()))
1927 .collect();
1928 pricing_rows.sort_by(|a, b| match (&a.0, &b.0) {
1929 (Some(x), Some(y)) => x.cmp(y),
1930 (None, Some(_)) => std::cmp::Ordering::Less,
1931 (Some(_), None) => std::cmp::Ordering::Greater,
1932 (None, None) => std::cmp::Ordering::Equal,
1933 });
1934 assert_eq!(pricing_rows.len(), 2);
1935 assert_eq!(
1936 pricing_rows[0],
1937 (Some(january.clone()), Some(june.clone())),
1938 "earlier row ends at the next row's effective_from"
1939 );
1940 assert_eq!(
1941 pricing_rows[1],
1942 (Some(june.clone()), None),
1943 "latest row has no successor; effective_to is None"
1944 );
1945
1946 assert!(
1947 !engine
1948 .specs()
1949 .into_iter()
1950 .find(|r| r.repository.is_none())
1951 .expect("workspace")
1952 .specs
1953 .iter()
1954 .any(|ls| ls.name == "unknown"),
1955 "no rows for unknown spec"
1956 );
1957 }
1958
1959 #[test]
1962 fn get_workspace_specs_with_half_open_ranges() {
1963 let mut engine = Engine::new();
1964 engine
1965 .load([(
1966 SourceType::Path(Arc::new(std::path::PathBuf::from("pricing_v1.lemma"))),
1967 r#"
1968 spec pricing 2025-01-01
1969 data x: 1
1970 rule r: x
1971 "#
1972 .to_string(),
1973 )])
1974 .unwrap();
1975 engine
1976 .load([(
1977 SourceType::Path(Arc::new(std::path::PathBuf::from("pricing_v2.lemma"))),
1978 r#"
1979 spec pricing 2026-01-01
1980 data x: 2
1981 rule r: x
1982 "#
1983 .to_string(),
1984 )])
1985 .unwrap();
1986 engine
1987 .load([(
1988 SourceType::Path(Arc::new(std::path::PathBuf::from("taxes.lemma"))),
1989 r#"
1990 spec taxes
1991 data rate: 0.21
1992 rule amount: rate
1993 "#
1994 .to_string(),
1995 )])
1996 .unwrap();
1997
1998 let workspace = engine
1999 .specs()
2000 .into_iter()
2001 .find(|r| r.repository.is_none())
2002 .expect("workspace");
2003 let unique_names: std::collections::BTreeSet<&str> =
2004 workspace.specs.iter().map(|ls| ls.name.as_str()).collect();
2005 assert_eq!(
2006 unique_names.len(),
2007 2,
2008 "two unique spec names: pricing and taxes"
2009 );
2010
2011 let pricing_rows: Vec<_> = workspace
2012 .specs
2013 .iter()
2014 .filter(|ls| ls.name == "pricing")
2015 .collect();
2016 assert_eq!(pricing_rows.len(), 2);
2017 assert_eq!(pricing_rows[0].effective_from, Some(date(2025, 1, 1)));
2018 assert_eq!(
2019 pricing_rows[0].effective_to,
2020 Some(date(2026, 1, 1)),
2021 "earlier pricing row ends at the next pricing row's effective_from"
2022 );
2023 assert_eq!(pricing_rows[1].effective_from, Some(date(2026, 1, 1)));
2024 assert_eq!(
2025 pricing_rows[1].effective_to, None,
2026 "latest pricing row has no successor; effective_to is None"
2027 );
2028
2029 let tax_rows: Vec<_> = workspace
2030 .specs
2031 .iter()
2032 .filter(|ls| ls.name == "taxes")
2033 .collect();
2034 assert_eq!(tax_rows.len(), 1);
2035 assert_eq!(
2036 tax_rows[0].effective_from, None,
2037 "unversioned spec has no declared effective_from"
2038 );
2039 assert_eq!(
2040 tax_rows[0].effective_to, None,
2041 "unversioned spec has no successor; effective_to is None"
2042 );
2043 }
2044
2045 #[test]
2046 fn test_evaluate_spec_all_rules() {
2047 let mut engine = Engine::new();
2048 engine
2049 .load([(
2050 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
2051 r#"
2052 spec test
2053 data x: 10
2054 data y: 5
2055 rule added: x + y
2056 rule multiplied: x * y
2057 "#
2058 .to_string(),
2059 )])
2060 .unwrap();
2061
2062 let now = DateTimeValue::now();
2063 let response = engine
2064 .evaluate(None, "test", Some(&now), IndexMap::new(), None, false)
2065 .unwrap();
2066 assert_eq!(response.results.len(), 2);
2067
2068 let sum_result = response
2069 .results
2070 .values()
2071 .find(|r| r.rule.name == "added")
2072 .unwrap();
2073 assert_eq!(sum_result.result().expect("result").to_string(), "15");
2074
2075 let product_result = response
2076 .results
2077 .values()
2078 .find(|r| r.rule.name == "multiplied")
2079 .unwrap();
2080 assert_eq!(product_result.result().expect("result").to_string(), "50");
2081 }
2082
2083 #[test]
2084 fn test_evaluate_empty_data() {
2085 let mut engine = Engine::new();
2086 engine
2087 .load([(
2088 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
2089 r#"
2090 spec test
2091 data price: 100
2092 rule total: price * 2
2093 "#
2094 .to_string(),
2095 )])
2096 .unwrap();
2097
2098 let now = DateTimeValue::now();
2099 let response = engine
2100 .evaluate(None, "test", Some(&now), IndexMap::new(), None, false)
2101 .unwrap();
2102 assert_eq!(response.results.len(), 1);
2103 assert_eq!(
2104 response
2105 .results
2106 .values()
2107 .next()
2108 .unwrap()
2109 .result()
2110 .expect("result"),
2111 "200"
2112 );
2113 }
2114
2115 #[test]
2116 fn test_evaluate_boolean_rule() {
2117 let mut engine = Engine::new();
2118 engine
2119 .load([(
2120 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
2121 r#"
2122 spec test
2123 data age: 25
2124 rule is_adult: age >= 18
2125 "#
2126 .to_string(),
2127 )])
2128 .unwrap();
2129
2130 let now = DateTimeValue::now();
2131 let response = engine
2132 .evaluate(None, "test", Some(&now), IndexMap::new(), None, false)
2133 .unwrap();
2134 assert_eq!(
2135 response
2136 .results
2137 .values()
2138 .next()
2139 .unwrap()
2140 .result
2141 .as_ref()
2142 .unwrap()
2143 .boolean,
2144 Some(true)
2145 );
2146 }
2147
2148 #[test]
2149 fn test_evaluate_with_unless_clause() {
2150 let mut engine = Engine::new();
2151 engine
2152 .load([(
2153 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
2154 r#"
2155 spec test
2156 data quantity: 15
2157 rule discount: 0
2158 unless quantity >= 10 then 10
2159 "#
2160 .to_string(),
2161 )])
2162 .unwrap();
2163
2164 let now = DateTimeValue::now();
2165 let response = engine
2166 .evaluate(None, "test", Some(&now), IndexMap::new(), None, false)
2167 .unwrap();
2168 assert_eq!(
2169 response
2170 .results
2171 .values()
2172 .next()
2173 .unwrap()
2174 .result()
2175 .expect("result"),
2176 "10"
2177 );
2178 }
2179
2180 #[test]
2181 fn test_spec_not_found() {
2182 let engine = Engine::new();
2183 let now = DateTimeValue::now();
2184 let result = engine.evaluate(
2185 None,
2186 "nonexistent",
2187 Some(&now),
2188 IndexMap::new(),
2189 None,
2190 false,
2191 );
2192 assert!(result.is_err());
2193 let msg = result.unwrap_err().to_string();
2194 assert!(
2195 msg.contains("No execution plan") && msg.contains("nonexistent"),
2196 "missing spec must report no plan, got: {msg}"
2197 );
2198 }
2199
2200 #[test]
2201 fn test_multiple_specs() {
2202 let mut engine = Engine::new();
2203 engine
2204 .load([(
2205 SourceType::Path(Arc::new(std::path::PathBuf::from("spec 1.lemma"))),
2206 r#"
2207 spec spec1
2208 data x: 10
2209 rule result: x * 2
2210 "#
2211 .to_string(),
2212 )])
2213 .unwrap();
2214
2215 engine
2216 .load([(
2217 SourceType::Path(Arc::new(std::path::PathBuf::from("spec 2.lemma"))),
2218 r#"
2219 spec spec2
2220 data y: 5
2221 rule result: y * 3
2222 "#
2223 .to_string(),
2224 )])
2225 .unwrap();
2226
2227 let now = DateTimeValue::now();
2228 let response1 = engine
2229 .evaluate(None, "spec1", Some(&now), IndexMap::new(), None, false)
2230 .unwrap();
2231 assert_eq!(
2232 response1.results[0].result().expect("result").to_string(),
2233 "20"
2234 );
2235 let response2 = engine
2236 .evaluate(None, "spec2", Some(&now), IndexMap::new(), None, false)
2237 .unwrap();
2238 assert_eq!(
2239 response2.results[0].result().expect("result").to_string(),
2240 "15"
2241 );
2242 }
2243
2244 #[test]
2245 fn test_runtime_error_mapping() {
2246 let mut engine = Engine::new();
2247 engine
2248 .load([(
2249 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
2250 r#"
2251 spec test
2252 data numerator: 10
2253 data denominator: 0
2254 rule division: numerator / denominator
2255 "#
2256 .to_string(),
2257 )])
2258 .unwrap();
2259
2260 let now = DateTimeValue::now();
2261 let result = engine.evaluate(None, "test", Some(&now), IndexMap::new(), None, false);
2262 assert!(result.is_ok(), "Evaluation should succeed");
2264 let response = result.unwrap();
2265 let division_result = response
2266 .results
2267 .values()
2268 .find(|r| r.rule.name == "division");
2269 assert!(
2270 division_result.is_some(),
2271 "Should have division rule result"
2272 );
2273 let division = division_result.unwrap();
2274 assert!(division.vetoed);
2275 assert!(
2276 division
2277 .veto_reason
2278 .as_deref()
2279 .unwrap()
2280 .contains("Division by zero"),
2281 "Veto message should mention division by zero: {:?}",
2282 division.veto_reason
2283 );
2284 }
2285
2286 #[test]
2287 fn test_rules_sorted_by_source_order() {
2288 let mut engine = Engine::new();
2289 engine
2290 .load([(
2291 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
2292 r#"
2293 spec test
2294 data a: 1
2295 data b: 2
2296 rule z: a + b
2297 rule y: a * b
2298 rule x: a - b
2299 "#
2300 .to_string(),
2301 )])
2302 .unwrap();
2303
2304 let now = DateTimeValue::now();
2305 let response = engine
2306 .evaluate(None, "test", Some(&now), IndexMap::new(), None, false)
2307 .unwrap();
2308 assert_eq!(response.results.len(), 3);
2309
2310 let z_pos = response
2312 .results
2313 .values()
2314 .find(|r| r.rule.name == "z")
2315 .unwrap()
2316 .rule
2317 .source_location
2318 .span
2319 .start;
2320 let y_pos = response
2321 .results
2322 .values()
2323 .find(|r| r.rule.name == "y")
2324 .unwrap()
2325 .rule
2326 .source_location
2327 .span
2328 .start;
2329 let x_pos = response
2330 .results
2331 .values()
2332 .find(|r| r.rule.name == "x")
2333 .unwrap()
2334 .rule
2335 .source_location
2336 .span
2337 .start;
2338
2339 assert!(z_pos < y_pos);
2340 assert!(y_pos < x_pos);
2341 }
2342
2343 #[test]
2344 fn test_rule_filtering_evaluates_dependencies() {
2345 let mut engine = Engine::new();
2346 engine
2347 .load([(
2348 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
2349 r#"
2350 spec test
2351 data base: 100
2352 rule subtotal: base * 2
2353 rule tax: subtotal * 10%
2354 rule total: subtotal + tax
2355 "#
2356 .to_string(),
2357 )])
2358 .unwrap();
2359
2360 let now = DateTimeValue::now();
2361 let response = engine
2362 .evaluate(
2363 None,
2364 "test",
2365 Some(&now),
2366 IndexMap::new(),
2367 Some(&["total".to_string()]),
2368 false,
2369 )
2370 .unwrap();
2371
2372 assert_eq!(response.results.len(), 1);
2373 assert_eq!(response.results.keys().next().unwrap(), "total");
2374
2375 let total = response.results.values().next().unwrap();
2377 assert_eq!(total.result().expect("result").to_string(), "220");
2378 }
2379
2380 use crate::parsing::ast::DateTimeValue;
2385
2386 #[test]
2387 fn pre_resolved_deps_in_file_map_evaluates_external_spec() {
2388 let mut engine = Engine::new();
2389
2390 engine
2391 .load([(
2392 SourceType::Dependency("@org/project".to_string()),
2393 "repo @org/project\nspec helper\ndata quantity: 42".to_string(),
2394 )])
2395 .expect("should load dependency files");
2396
2397 engine
2398 .load([(
2399 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
2400 r#"spec main_spec
2401uses external: @org/project helper
2402rule value: external.quantity"#
2403 .to_string(),
2404 )])
2405 .expect("should succeed with pre-resolved deps");
2406
2407 let now = DateTimeValue::now();
2408 let response = engine
2409 .evaluate(None, "main_spec", Some(&now), IndexMap::new(), None, false)
2410 .expect("evaluate should succeed");
2411
2412 let value_result = response
2413 .results
2414 .get("value")
2415 .expect("rule 'value' should exist");
2416 assert_eq!(value_result.result().expect("result").to_string(), "42");
2417 }
2418
2419 #[test]
2420 fn show_with_repo_resolves_registry_spec() {
2421 let mut engine = Engine::new();
2422 engine
2423 .load([(
2424 SourceType::Dependency("@org/project".to_string()),
2425 "repo @org/project\nspec helper\ndata quantity: 42\nrule expose: quantity"
2426 .to_string(),
2427 )])
2428 .expect("registry bundle loads");
2429
2430 engine
2431 .load([(
2432 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
2433 r#"spec main_spec
2434data x: 1"#
2435 .to_string(),
2436 )])
2437 .expect("main loads");
2438
2439 let now = DateTimeValue::now();
2440 let view = engine
2441 .show(Some("@org/project"), "helper", Some(&now))
2442 .expect("show for registry spec");
2443 assert!(view.data.contains_key("quantity"));
2444 }
2445
2446 #[test]
2447 fn load_no_external_refs_works() {
2448 let mut engine = Engine::new();
2449
2450 engine
2451 .load([(
2452 SourceType::Path(Arc::new(std::path::PathBuf::from("local.lemma"))),
2453 r#"spec local_only
2454data price: 100
2455rule doubled: price * 2"#
2456 .to_string(),
2457 )])
2458 .expect("should succeed when there are no @... references");
2459
2460 let now = DateTimeValue::now();
2461 let response = engine
2462 .evaluate(None, "local_only", Some(&now), IndexMap::new(), None, false)
2463 .expect("evaluate should succeed");
2464
2465 let doubled = response.results.get("doubled").expect("doubled rule");
2466 assert_eq!(doubled.result().expect("result").to_string(), "200");
2467 }
2468
2469 #[test]
2470 fn unresolved_external_ref_without_deps_fails() {
2471 let mut engine = Engine::new();
2472
2473 let result = engine.load([(
2474 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
2475 r#"spec main_spec
2476uses external: @org/project missing
2477rule value: external.quantity"#
2478 .to_string(),
2479 )]);
2480
2481 let errs = result.expect_err("Should fail when registry dep is not loaded");
2482 assert!(
2483 errs.iter()
2484 .any(|e| e.kind() == crate::ErrorKind::MissingRepository),
2485 "expected MissingRepository, got: {:?}",
2486 errs.iter().map(|e| e.kind()).collect::<Vec<_>>()
2487 );
2488 }
2489
2490 #[test]
2491 fn pre_resolved_deps_with_spec_and_type_refs() {
2492 let mut engine = Engine::new();
2493
2494 engine
2495 .load([(
2496 SourceType::Dependency("@org/example".to_string()),
2497 "repo @org/example\nspec helper\ndata value: 42".to_string(),
2498 )])
2499 .expect("should load helper file");
2500
2501 engine
2502 .load([(
2503 SourceType::Dependency("@iso/countries".to_string()),
2504 "repo @iso/countries\nspec alpha2\ndata code: text\n -> option \"NL\"\n -> option \"BE\"".to_string(),
2505 )])
2506 .expect("should load alpha2 file");
2507
2508 engine
2509 .load([(
2510 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
2511 r#"spec registry_demo
2512uses @iso/countries alpha2
2513data country: alpha2.code
2514data unit_count: 5
2515uses @org/example helper
2516rule helper_value: helper.value
2517rule line_total: unit_count * 2
2518rule formatted: helper_value + 0"#
2519 .to_string(),
2520 )])
2521 .expect("should succeed with pre-resolved spec and type deps");
2522
2523 let now = DateTimeValue::now();
2524 let response = engine
2525 .evaluate(
2526 None,
2527 "registry_demo",
2528 Some(&now),
2529 IndexMap::new(),
2530 None,
2531 false,
2532 )
2533 .expect("evaluate should succeed");
2534
2535 assert_eq!(
2536 response
2537 .results
2538 .get("helper_value")
2539 .expect("helper_value")
2540 .result()
2541 .expect("result"),
2542 "42"
2543 );
2544 let line = response
2545 .results
2546 .get("line_total")
2547 .expect("line_total")
2548 .result()
2549 .expect("result");
2550 assert_eq!(line, "10");
2551 assert_eq!(
2552 response
2553 .results
2554 .get("formatted")
2555 .expect("formatted")
2556 .result()
2557 .expect("result"),
2558 "42"
2559 );
2560 }
2561
2562 #[test]
2563 fn load_empty_labeled_source_is_error() {
2564 let mut engine = Engine::new();
2565 let err = engine
2566 .load([(
2567 SourceType::Path(Arc::new(std::path::PathBuf::from(" "))),
2568 "spec x\ndata a: 1".to_string(),
2569 )])
2570 .unwrap_err();
2571 assert!(err.errors.iter().any(|e| e.message().contains("non-empty")));
2572 }
2573
2574 #[test]
2575 fn add_dependency_files_accepts_registry_bundle_specs() {
2576 let mut engine = Engine::new();
2577 engine
2578 .load([(
2579 SourceType::Dependency("@org/my".to_string()),
2580 "repo @org/my\nspec helper\ndata x: 1".to_string(),
2581 )])
2582 .expect("dependency bundle specs should be accepted");
2583 }
2584
2585 #[test]
2586 fn user_load_rejects_reserved_embedded_stdlib_repository() {
2587 let mut engine = Engine::new();
2588 let batch = engine.load([(
2589 SourceType::Dependency(EMBEDDED_STDLIB_REPOSITORY.to_string()),
2590 "spec finance\ndata money: ratio -> decimals 2".to_string(),
2591 )]);
2592 assert!(
2593 batch.is_err(),
2594 "load must not write reserved lemma stdlib repo"
2595 );
2596 let msg = batch
2597 .unwrap_err()
2598 .errors
2599 .iter()
2600 .map(ToString::to_string)
2601 .collect::<Vec<_>>()
2602 .join("\n");
2603 assert!(
2604 msg.contains(EMBEDDED_STDLIB_REPOSITORY) && msg.contains("reserved"),
2605 "expected reserved-repo error, got: {msg}"
2606 );
2607
2608 let workspace = engine.load([(
2609 SourceType::Volatile,
2610 "repo lemma\nspec x\ndata a: 1".to_string(),
2611 )]);
2612 assert!(workspace.is_err(), "workspace repo lemma must be rejected");
2613 let msg = workspace
2614 .unwrap_err()
2615 .errors
2616 .iter()
2617 .map(ToString::to_string)
2618 .collect::<Vec<_>>()
2619 .join("\n");
2620 assert!(
2621 msg.contains(EMBEDDED_STDLIB_REPOSITORY) && msg.contains("reserved"),
2622 "expected reserved-repo error, got: {msg}"
2623 );
2624 }
2625
2626 #[test]
2627 fn load_returns_all_errors_not_just_first() {
2628 let mut engine = Engine::new();
2629
2630 let result = engine.load([(
2631 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
2632 r#"spec demo
2633uses type_src: nonexistent_type_source
2634 -> with amount: 10
2635uses helper: nonexistent_spec
2636data price: 10
2637rule total: helper.value + price"#
2638 .to_string(),
2639 )]);
2640
2641 assert!(result.is_err(), "Should fail with multiple errors");
2642 let load_err = result.unwrap_err();
2643 assert!(
2644 load_err.errors.len() >= 2,
2645 "expected at least 2 errors (type + spec ref), got {}",
2646 load_err.errors.len()
2647 );
2648 let error_message = load_err
2649 .errors
2650 .iter()
2651 .map(ToString::to_string)
2652 .collect::<Vec<_>>()
2653 .join("; ");
2654
2655 assert!(
2656 error_message.contains("nonexistent_type_source"),
2657 "Should mention data import source spec. Got:\n{}",
2658 error_message
2659 );
2660 assert!(
2661 error_message.contains("nonexistent_spec"),
2662 "Should mention spec reference error about 'nonexistent_spec'. Got:\n{}",
2663 error_message
2664 );
2665 }
2666
2667 #[test]
2673 fn planning_rejects_invalid_number_default() {
2674 let mut engine = Engine::new();
2675 let result = engine.load([(
2676 SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
2677 "spec t\ndata x: number -> suggest \"10 $$\"]\nrule r: x".to_string(),
2678 )]);
2679 assert!(
2680 result.is_err(),
2681 "must reject non-numeric suggestion on number type"
2682 );
2683 }
2684
2685 #[test]
2686 fn planning_rejects_text_literal_as_number_default() {
2687 let mut engine = Engine::new();
2692 let result = engine.load([(
2693 SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
2694 "spec t\ndata x: number -> suggest \"10\"]\nrule r: x".to_string(),
2695 )]);
2696 assert!(
2697 result.is_err(),
2698 "must reject text literal \"10\" as suggestion for number type"
2699 );
2700 }
2701
2702 #[test]
2703 fn planning_rejects_invalid_boolean_default() {
2704 let mut engine = Engine::new();
2705 let result = engine.load([(
2706 SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
2707 "spec t\ndata x: [boolean -> suggest \"maybe\"]\nrule r: x".to_string(),
2708 )]);
2709 assert!(
2710 result.is_err(),
2711 "must reject non-boolean suggestion on boolean type"
2712 );
2713 }
2714
2715 #[test]
2716 fn planning_rejects_invalid_named_type_default() {
2717 let mut engine = Engine::new();
2719 let result = engine.load([(SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))), "spec t\ndata custom: number -> minimum 0\ndata x: [custom -> suggest \"abc\"]\nrule r: x".to_string())]);
2720 assert!(
2721 result.is_err(),
2722 "must reject non-numeric suggestion on named number type"
2723 );
2724 }
2725
2726 #[test]
2727 fn context_merges_cross_file_repo_identities() {
2728 let mut engine = Engine::new();
2729
2730 engine
2732 .load([(
2733 SourceType::Path(Arc::new(std::path::PathBuf::from("file1.lemma"))),
2734 "repo shared\nspec a\ndata x: 1".to_string(),
2735 )])
2736 .expect("first file should load");
2737
2738 engine
2739 .load([(
2740 SourceType::Path(Arc::new(std::path::PathBuf::from("file2.lemma"))),
2741 "repo shared\nspec b\ndata y: 2".to_string(),
2742 )])
2743 .expect("second file should load");
2744
2745 assert_eq!(
2748 engine.context.repositories().len(),
2749 3,
2750 "should have workspace, stdlib repository, and one named user repository"
2751 );
2752
2753 let shared_repo = engine
2754 .context
2755 .find_repository("shared")
2756 .expect("shared repo should exist");
2757 let shared_specs = engine.context.repositories().get(&shared_repo).unwrap();
2758 assert_eq!(
2759 shared_specs.len(),
2760 2,
2761 "shared repo should contain both specs"
2762 );
2763 assert!(shared_specs.contains_key("a"));
2764 assert!(shared_specs.contains_key("b"));
2765
2766 let dependency_result = engine.load([(
2768 SourceType::Dependency("@some/dep".to_string()),
2769 "repo shared\nspec c\ndata z: 3".to_string(),
2770 )]);
2771 assert!(
2772 dependency_result.is_err(),
2773 "dependency with an existing repo name must be rejected"
2774 );
2775
2776 let result = engine.load([(
2777 SourceType::Path(Arc::new(std::path::PathBuf::from("file2.lemma"))),
2778 "repo shared\nspec a\ndata y: 2".to_string(),
2779 )]);
2780
2781 assert!(
2782 result.is_err(),
2783 "should reject duplicate spec name in same repo"
2784 );
2785 let load_err = result.unwrap_err();
2786 assert_eq!(
2787 load_err.errors.len(),
2788 2,
2789 "duplicate spec must error on both declaring sources, got: {:?}",
2790 load_err.errors
2791 );
2792 let joined = load_err
2793 .errors
2794 .iter()
2795 .map(|e| e.to_string())
2796 .collect::<Vec<_>>()
2797 .join("\n");
2798 assert!(
2799 joined.contains("Duplicate spec 'a'"),
2800 "error should mention duplicate spec, got: {joined}"
2801 );
2802 let paths: Vec<String> = load_err
2803 .errors
2804 .iter()
2805 .map(|err| {
2806 err.location()
2807 .expect("duplicate errors must have source")
2808 .source_type
2809 .to_string()
2810 })
2811 .collect();
2812 assert!(
2813 paths.iter().any(|p| p == "file1.lemma"),
2814 "first declaring file must get conflict diagnostic, got paths: {paths:?}"
2815 );
2816 assert!(
2817 paths.iter().any(|p| p == "file2.lemma"),
2818 "incoming file must get conflict diagnostic, got paths: {paths:?}"
2819 );
2820 }
2821
2822 #[test]
2823 fn test_list_structure() {
2824 let mut engine = Engine::new();
2825 engine
2826 .load([(
2827 SourceType::Path(Arc::new(std::path::PathBuf::from("file1.lemma"))),
2828 "repo shared\nspec a\ndata x: 1\nrule r: x".to_string(),
2829 )])
2830 .expect("file should load");
2831
2832 let repos = engine.specs();
2833 let shared_repo = repos
2834 .iter()
2835 .find(|r| r.repository.as_deref() == Some("shared"))
2836 .expect("shared repo in list");
2837 assert_eq!(shared_repo.specs.len(), 1);
2838 assert_eq!(shared_repo.specs[0].name, "a");
2839 }
2840
2841 fn path_st(name: &str) -> SourceType {
2842 SourceType::Path(Arc::new(std::path::PathBuf::from(name)))
2843 }
2844
2845 #[test]
2846 fn remove_none_removes_version_active_at_now() {
2847 let mut engine = Engine::new();
2848 engine
2849 .load([(
2850 path_st("t.lemma"),
2851 "spec t\ndata v: 1\nrule r: v\n\nspec t 2099-01-01\ndata v: 2\nrule r: v\n"
2852 .to_string(),
2853 )])
2854 .expect("load");
2855 engine
2856 .remove(None, "t", None)
2857 .expect("remove active-at now (origin while before 2099)");
2858 let workspace = engine
2859 .specs()
2860 .into_iter()
2861 .find(|r| r.repository.is_none())
2862 .expect("workspace");
2863 assert_eq!(workspace.specs.iter().filter(|s| s.name == "t").count(), 1);
2864 assert!(
2865 workspace
2866 .specs
2867 .iter()
2868 .any(|s| s.name == "t" && s.effective_from.is_some()),
2869 "dated version must remain"
2870 );
2871 }
2872
2873 #[test]
2874 fn remove_none_errors_when_no_version_active_at_now() {
2875 let mut engine = Engine::new();
2876 engine
2877 .load([(
2878 path_st("t.lemma"),
2879 "spec t 2099-01-01\ndata v: 1\nrule r: v\n".to_string(),
2880 )])
2881 .expect("load");
2882 let err = engine
2883 .remove(None, "t", None)
2884 .expect_err("no version active at now");
2885 assert_eq!(err.kind(), crate::ErrorKind::Request);
2886 assert!(
2887 engine.show(None, "t", Some(&date(2099, 1, 1))).is_ok(),
2888 "future version must remain"
2889 );
2890 }
2891
2892 #[test]
2893 fn update_origin_code_keeps_later_version() {
2894 let mut engine = Engine::new();
2895 let st = path_st("t.lemma");
2896 engine
2897 .load([(
2898 st.clone(),
2899 "spec t\ndata v: 1\nrule r: v\n\nspec t 2099-01-01\ndata v: 2\nrule r: v\n"
2900 .to_string(),
2901 )])
2902 .expect("load");
2903 engine
2904 .update(
2905 None,
2906 "spec t\ndata v: 9\nrule r: v\n\nspec t 2099-01-01\ndata v: 2\nrule r: v\n"
2907 .to_string(),
2908 st,
2909 )
2910 .expect("update origin body while keeping later version in buffer");
2911 let workspace = engine
2912 .specs()
2913 .into_iter()
2914 .find(|r| r.repository.is_none())
2915 .expect("workspace");
2916 assert_eq!(workspace.specs.iter().filter(|s| s.name == "t").count(), 2);
2917 let now = DateTimeValue::now();
2918 let response = engine
2919 .evaluate(None, "t", Some(&now), IndexMap::new(), None, false)
2920 .expect("evaluate origin");
2921 assert_eq!(
2922 response.results.get("r").and_then(|r| r.result()),
2923 Some("9")
2924 );
2925 }
2926
2927 #[test]
2928 fn update_path_prunes_dropped_version() {
2929 let mut engine = Engine::new();
2930 let st = path_st("t.lemma");
2931 engine
2932 .load([(
2933 st.clone(),
2934 "spec t\ndata v: 1\nrule r: v\n\nspec t 2025-06-01\ndata v: 2\nrule r: v\n"
2935 .to_string(),
2936 )])
2937 .expect("load");
2938 engine
2939 .update(None, "spec t\ndata v: 1\nrule r: v\n".to_string(), st)
2940 .expect("prune second version");
2941 let workspace = engine
2942 .specs()
2943 .into_iter()
2944 .find(|r| r.repository.is_none())
2945 .expect("workspace");
2946 assert_eq!(workspace.specs.iter().filter(|s| s.name == "t").count(), 1);
2947 assert!(workspace
2948 .specs
2949 .iter()
2950 .any(|s| s.name == "t" && s.effective_from.is_none()));
2951 }
2952
2953 #[test]
2954 fn update_volatile_does_not_prune_sibling() {
2955 let mut engine = Engine::new();
2956 engine
2957 .load([(
2958 SourceType::Volatile,
2959 "spec a\ndata v: 1\nrule r: v\n\nspec b\ndata v: 2\nrule r: v\n".to_string(),
2960 )])
2961 .expect("load");
2962 engine
2963 .update(
2964 None,
2965 "spec a\ndata v: 3\nrule r: v\n".to_string(),
2966 SourceType::Volatile,
2967 )
2968 .expect("update a");
2969 let workspace = engine
2970 .specs()
2971 .into_iter()
2972 .find(|r| r.repository.is_none())
2973 .expect("workspace");
2974 assert!(workspace.specs.iter().any(|s| s.name == "a"));
2975 assert!(workspace.specs.iter().any(|s| s.name == "b"));
2976 }
2977
2978 #[test]
2979 fn update_upserts_new_identity() {
2980 let mut engine = Engine::new();
2981 let st = path_st("t.lemma");
2982 engine
2983 .load([(st.clone(), "spec a\ndata v: 1\nrule r: v\n".to_string())])
2984 .expect("load");
2985 engine
2986 .update(
2987 None,
2988 "spec a\ndata v: 1\nrule r: v\n\nspec b\ndata v: 2\nrule r: v\n".to_string(),
2989 st,
2990 )
2991 .expect("upsert b");
2992 let workspace = engine
2993 .specs()
2994 .into_iter()
2995 .find(|r| r.repository.is_none())
2996 .expect("workspace");
2997 assert!(workspace.specs.iter().any(|s| s.name == "a"));
2998 assert!(workspace.specs.iter().any(|s| s.name == "b"));
2999 }
3000
3001 #[test]
3002 fn update_cross_path_identity_is_error() {
3003 let mut engine = Engine::new();
3004 engine
3005 .load([(
3006 path_st("a.lemma"),
3007 "spec conflict\ndata v: 1\nrule r: v\n".to_string(),
3008 )])
3009 .expect("load a");
3010 let err = engine
3011 .update(
3012 None,
3013 "spec conflict\ndata v: 2\nrule r: v\n".to_string(),
3014 path_st("b.lemma"),
3015 )
3016 .expect_err("cross-path identity");
3017 let joined: String = err
3018 .errors
3019 .iter()
3020 .map(|e| e.to_string())
3021 .collect::<Vec<_>>()
3022 .join("\n");
3023 assert!(
3024 joined.contains("Duplicate spec") && joined.contains("also declared"),
3025 "expected duplicate across paths, got: {joined}"
3026 );
3027 assert!(
3028 engine.show(None, "conflict", None).is_ok(),
3029 "failed cross-path update must leave the original identity loaded"
3030 );
3031 }
3032
3033 #[test]
3034 fn update_empty_dependency_prunes_all_specs_of_source() {
3035 let mut engine = Engine::new();
3036 let st = SourceType::Dependency("@org/dep".to_string());
3037 engine
3038 .load([(
3039 st.clone(),
3040 "repo @org/dep\n\nspec a\ndata v: 1\nrule r: v\n\nspec b\ndata v: 2\nrule r: v\n"
3041 .to_string(),
3042 )])
3043 .expect("load");
3044 engine
3045 .update(None, " \n".to_string(), st)
3046 .expect("empty dependency update prunes");
3047 let listed = engine.specs();
3048 assert!(
3049 !listed.iter().any(|r| {
3050 r.repository.as_deref() == Some("@org/dep")
3051 && r.specs.iter().any(|s| s.name == "a" || s.name == "b")
3052 }),
3053 "empty dependency update must remove every live row of that source"
3054 );
3055 }
3056
3057 #[test]
3058 fn failed_apply_leaves_list_source_and_run_unchanged() {
3059 let mut engine = Engine::new();
3060 engine
3061 .load([(
3062 SourceType::Volatile,
3063 "spec price\ndata amount: number\nrule total: amount\n".to_string(),
3064 )])
3065 .expect("load");
3066 let before_repositories = engine.specs();
3067 let before_source = engine.source(None, Some("price"), None).expect("source");
3068 let now = DateTimeValue::now();
3069 let before = engine
3070 .evaluate(
3071 None,
3072 "price",
3073 Some(&now),
3074 [("amount".to_string(), EvaluateDataValue::from("4"))]
3075 .into_iter()
3076 .collect(),
3077 None,
3078 false,
3079 )
3080 .expect("evaluate");
3081 let failed = engine.load([(
3082 SourceType::Volatile,
3083 "spec price\ndata amount: number\nrule total: amount +\n".to_string(),
3084 )]);
3085 assert!(failed.is_err(), "invalid spec must fail planning");
3086 assert_eq!(engine.specs(), before_repositories);
3087 assert_eq!(
3088 engine.source(None, Some("price"), None).expect("source"),
3089 before_source
3090 );
3091 let after = engine
3092 .evaluate(
3093 None,
3094 "price",
3095 Some(&now),
3096 [("amount".to_string(), EvaluateDataValue::from("4"))]
3097 .into_iter()
3098 .collect(),
3099 None,
3100 false,
3101 )
3102 .expect("evaluate after failed load");
3103 assert_eq!(
3104 after.results.get("total").expect("total").result,
3105 before.results.get("total").expect("total").result
3106 );
3107 }
3108
3109 #[test]
3110 fn update_empty_path_prunes_all_specs_of_source() {
3111 let mut engine = Engine::new();
3112 let st = path_st("t.lemma");
3113 engine
3114 .load([(
3115 st.clone(),
3116 "spec a\ndata v: 1\nrule r: v\n\nspec b\ndata v: 2\nrule r: v\n".to_string(),
3117 )])
3118 .expect("load");
3119 engine
3120 .update(None, " \n".to_string(), st)
3121 .expect("empty path update prunes");
3122 let workspace = engine
3123 .specs()
3124 .into_iter()
3125 .find(|r| r.repository.is_none())
3126 .expect("workspace");
3127 assert!(
3128 !workspace
3129 .specs
3130 .iter()
3131 .any(|s| s.name == "a" || s.name == "b"),
3132 "empty path update must remove every live row of that source"
3133 );
3134 }
3135
3136 #[test]
3137 fn update_empty_volatile_is_error() {
3138 let mut engine = Engine::new();
3139 engine
3140 .load([(
3141 SourceType::Volatile,
3142 "spec a\ndata v: 1\nrule r: v\n".to_string(),
3143 )])
3144 .expect("load");
3145 let err = engine
3146 .update(None, " \n".to_string(), SourceType::Volatile)
3147 .expect_err("empty volatile update");
3148 assert!(
3149 err.errors
3150 .iter()
3151 .any(|e| e.to_string().contains("at least one spec")),
3152 "got: {:?}",
3153 err.errors
3154 );
3155 assert!(
3156 engine.show(None, "a", None).is_ok(),
3157 "failed empty volatile update must leave the original identity loaded"
3158 );
3159 }
3160
3161 #[test]
3162 fn update_repository_param_mismatch_is_error() {
3163 let mut engine = Engine::new();
3164 let st = path_st("t.lemma");
3165 engine
3166 .load([(
3167 st.clone(),
3168 "repo other\nspec a\ndata v: 1\nrule r: v\n".to_string(),
3169 )])
3170 .expect("load");
3171 let err = engine
3172 .update(
3173 Some("expected"),
3174 "repo other\nspec a\ndata v: 2\nrule r: v\n".to_string(),
3175 st,
3176 )
3177 .expect_err("mismatch");
3178 assert!(
3179 err.errors
3180 .iter()
3181 .any(|e| e.to_string().contains("does not match")),
3182 "got: {:?}",
3183 err.errors
3184 );
3185 }
3186
3187 #[test]
3188 fn update_rollback_restores_pruned_and_replaced() {
3189 let mut engine = Engine::new();
3190 let dep = path_st("dep.lemma");
3191 let consumer = path_st("consumer.lemma");
3192 engine
3193 .load([
3194 (
3195 dep.clone(),
3196 "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: 2\nrule r: v\n"
3197 .to_string(),
3198 ),
3199 (
3200 consumer,
3201 "spec consumer\nuses d: dep\nrule out: d.r\n".to_string(),
3202 ),
3203 ])
3204 .expect("load");
3205 let before = engine.specs();
3206 let err = engine
3207 .update(
3208 None,
3209 "spec dep\ndata other: 5\nrule unrelated: other\n".to_string(),
3210 dep,
3211 )
3212 .expect_err("consumer must break");
3213 assert!(!err.errors.is_empty());
3214 let after = engine.specs();
3215 assert_eq!(after.len(), before.len(), "repository count must match");
3216 for (before_repo, after_repo) in before.iter().zip(after.iter()) {
3217 assert_eq!(before_repo.repository, after_repo.repository);
3218 let mut before_specs = before_repo.specs.clone();
3219 let mut after_specs = after_repo.specs.clone();
3220 before_specs
3221 .sort_by(|a, b| (&a.name, &a.effective_from).cmp(&(&b.name, &b.effective_from)));
3222 after_specs
3223 .sort_by(|a, b| (&a.name, &a.effective_from).cmp(&(&b.name, &b.effective_from)));
3224 assert_eq!(
3225 before_specs, after_specs,
3226 "failed update must restore spec versions for {:?}",
3227 before_repo.repository
3228 );
3229 }
3230 let now = DateTimeValue::now();
3231 engine
3232 .evaluate(None, "consumer", Some(&now), IndexMap::new(), None, false)
3233 .expect("consumer still runs after rollback");
3234 assert!(
3235 engine.show(None, "dep", Some(&date(2025, 6, 1))).is_ok(),
3236 "pruned later dep version must be restored"
3237 );
3238 }
3239
3240 fn finance_result(engine: &Engine) -> String {
3241 let now = DateTimeValue::now();
3242 engine
3243 .evaluate(None, "finance", Some(&now), IndexMap::new(), None, false)
3244 .expect("finance evaluates")
3245 .results
3246 .values()
3247 .next()
3248 .expect("one result")
3249 .result()
3250 .expect("result")
3251 .to_string()
3252 }
3253
3254 #[test]
3255 fn update_pasted_duplicate_identity_is_error() {
3256 let mut engine = Engine::new();
3257 let st = path_st("finance.lemma");
3258 engine
3259 .load([(
3260 st.clone(),
3261 "spec finance\ndata v: 1\nrule r: 1\n".to_string(),
3262 )])
3263 .expect("load");
3264 let err = engine
3265 .update(
3266 None,
3267 "spec finance\ndata v: 1\nrule r: 1\n\nspec finance\ndata v: 2\nrule r: 2\n"
3268 .to_string(),
3269 st,
3270 )
3271 .expect_err("pasted duplicate identity must be rejected");
3272 let joined: String = err
3273 .errors
3274 .iter()
3275 .map(ToString::to_string)
3276 .collect::<Vec<_>>()
3277 .join("\n");
3278 assert!(
3279 joined.contains("Duplicate spec 'finance'"),
3280 "expected duplicate error, got: {joined}"
3281 );
3282 assert_eq!(
3283 finance_result(&engine),
3284 "1",
3285 "original spec must stay active"
3286 );
3287 }
3288
3289 #[test]
3290 fn update_duplicate_identity_both_changed_is_error_not_panic() {
3291 let mut engine = Engine::new();
3292 let st = path_st("finance.lemma");
3293 engine
3294 .load([(
3295 st.clone(),
3296 "spec finance\ndata v: 1\nrule r: 1\n".to_string(),
3297 )])
3298 .expect("load");
3299 let err = engine
3300 .update(
3301 None,
3302 "spec finance\ndata v: 2\nrule r: 2\n\nspec finance\ndata v: 3\nrule r: 3\n"
3303 .to_string(),
3304 st,
3305 )
3306 .expect_err("duplicate identity must be rejected");
3307 let joined: String = err
3308 .errors
3309 .iter()
3310 .map(ToString::to_string)
3311 .collect::<Vec<_>>()
3312 .join("\n");
3313 assert!(
3314 joined.contains("Duplicate spec 'finance'"),
3315 "expected duplicate error, got: {joined}"
3316 );
3317 assert_eq!(
3318 finance_result(&engine),
3319 "1",
3320 "original spec must stay active"
3321 );
3322 }
3323
3324 #[test]
3325 fn update_reports_every_duplicate_identity() {
3326 let mut engine = Engine::new();
3327 let err = engine
3328 .update(
3329 None,
3330 "spec finance\ndata v: 1\nrule r: 1\n\nspec finance\ndata v: 2\nrule r: 2\n\nspec policies\ndata v: 1\nrule r: 1\n\nspec policies\ndata v: 2\nrule r: 2\n"
3331 .to_string(),
3332 path_st("policies.lemma"),
3333 )
3334 .expect_err("duplicate identities must be rejected");
3335 let joined: String = err
3336 .errors
3337 .iter()
3338 .map(ToString::to_string)
3339 .collect::<Vec<_>>()
3340 .join("\n");
3341 assert!(
3342 joined.contains("Duplicate spec 'finance'"),
3343 "expected finance duplicate, got: {joined}"
3344 );
3345 assert!(
3346 joined.contains("Duplicate spec 'policies'"),
3347 "expected policies duplicate, got: {joined}"
3348 );
3349 }
3350
3351 #[test]
3352 fn update_identical_bytes_succeeds() {
3353 let mut engine = Engine::new();
3354 let st = path_st("t.lemma");
3355 let code = "spec t\ndata v: 1\nrule r: v\n".to_string();
3356 engine.load([(st.clone(), code.clone())]).expect("load");
3357 let before = engine.show(None, "t", None).expect("show before");
3358 engine.update(None, code, st).expect("identical update");
3359 let after = engine.show(None, "t", None).expect("show after");
3360 assert_eq!(before.meta, after.meta);
3361 assert_eq!(
3362 before.rules.keys().collect::<Vec<_>>(),
3363 after.rules.keys().collect::<Vec<_>>()
3364 );
3365 }
3366
3367 #[test]
3370 fn update_slice_mode_keeps_consumer_overlapping_sibling_version() {
3371 let mut engine = Engine::new();
3372 let dep = path_st("dep.lemma");
3373 let consumer = path_st("consumer.lemma");
3374 engine
3375 .load([
3376 (
3377 dep.clone(),
3378 "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: 2\nrule r: v\n"
3379 .to_string(),
3380 ),
3381 (
3382 consumer,
3383 "spec consumer\nuses d: dep\nrule out: d.r\n\nspec consumer 2025-06-01\nuses d: dep\nrule out: d.r\n"
3384 .to_string(),
3385 ),
3386 ])
3387 .expect("load");
3388 engine
3389 .update(
3390 None,
3391 "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: 9\nrule r: v\n"
3392 .to_string(),
3393 dep,
3394 )
3395 .expect("body-only edit of later dep version must keep consumers valid");
3396 let before_breakpoint = date(2025, 1, 15);
3397 let response = engine
3398 .evaluate(
3399 None,
3400 "consumer",
3401 Some(&before_breakpoint),
3402 IndexMap::new(),
3403 None,
3404 false,
3405 )
3406 .expect("origin consumer still runs against non-dirty dep version");
3407 assert_eq!(
3408 response.results.get("out").and_then(|r| r.result()),
3409 Some("1")
3410 );
3411 let after_breakpoint = date(2025, 7, 1);
3412 let response = engine
3413 .evaluate(
3414 None,
3415 "consumer",
3416 Some(&after_breakpoint),
3417 IndexMap::new(),
3418 None,
3419 false,
3420 )
3421 .expect("later consumer runs against dirty dep version");
3422 assert_eq!(
3423 response.results.get("out").and_then(|r| r.result()),
3424 Some("9")
3425 );
3426 }
3427
3428 #[test]
3431 fn update_slice_mode_rejects_interface_drift_vs_sibling_version() {
3432 let mut engine = Engine::new();
3433 let dep = path_st("dep.lemma");
3434 let consumer = path_st("consumer.lemma");
3435 engine
3436 .load([
3437 (
3438 dep.clone(),
3439 "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: 2\nrule r: v\n"
3440 .to_string(),
3441 ),
3442 (
3443 consumer.clone(),
3444 "spec consumer\nuses d: dep\nrule out: d.r\n".to_string(),
3445 ),
3446 ])
3447 .expect("load");
3448 let err = engine
3449 .update(
3450 None,
3451 "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: \"x\"\nrule r: v\n"
3452 .to_string(),
3453 dep.clone(),
3454 )
3455 .expect_err("drift between dep versions must fail incremental update");
3456 assert!(
3457 err.errors.iter().any(|e| {
3458 let msg = e.to_string();
3459 msg.contains("interface") || msg.contains("changed")
3460 }),
3461 "expected interface-drift error, got: {:?}",
3462 err.errors
3463 );
3464
3465 let mut cold = Engine::new();
3466 let cold_err = cold
3467 .load([
3468 (
3469 dep,
3470 "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: \"x\"\nrule r: v\n"
3471 .to_string(),
3472 ),
3473 (
3474 consumer,
3475 "spec consumer\nuses d: dep\nrule out: d.r\n".to_string(),
3476 ),
3477 ])
3478 .expect_err("cold load must also reject the drift");
3479 assert!(
3480 cold_err.errors.iter().any(|e| {
3481 let msg = e.to_string();
3482 msg.contains("interface") || msg.contains("changed")
3483 }),
3484 "expected interface-drift error on cold load, got: {:?}",
3485 cold_err.errors
3486 );
3487 }
3488
3489 #[test]
3492 fn update_slice_mode_planning_error_on_dirty_version_is_error_not_panic() {
3493 let mut engine = Engine::new();
3494 let dep = path_st("dep.lemma");
3495 let consumer = path_st("consumer.lemma");
3496 engine
3497 .load([
3498 (
3499 dep.clone(),
3500 "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: 2\nrule r: v\n"
3501 .to_string(),
3502 ),
3503 (
3504 consumer,
3505 "spec consumer\nuses d: dep\nrule out: d.r\n".to_string(),
3506 ),
3507 ])
3508 .expect("load");
3509 let err = engine
3510 .update(
3511 None,
3512 "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: 2\nrule r: v + nope\n"
3513 .to_string(),
3514 dep,
3515 )
3516 .expect_err("planning error on dirty version must be Err, not panic");
3517 assert!(!err.errors.is_empty());
3518 let now = DateTimeValue::now();
3519 engine
3520 .evaluate(None, "consumer", Some(&now), IndexMap::new(), None, false)
3521 .expect("failed update must leave previous engine state intact");
3522 }
3523}