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