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