1use crate::evaluation::Evaluator;
2use crate::evaluation::{RunData, RunDataValue};
3use crate::parsing::ast::{DateTimeValue, LemmaRepository, LemmaSpec};
4use crate::parsing::source::SourceType;
5use crate::parsing::{parse, EffectiveDate};
6use crate::planning::execution_plan::{Show, ShowData};
7use crate::planning::semantics::DataDefinition;
8use crate::planning::{LemmaSpecSet, PlanStore};
9use crate::{Error, ResourceLimits, Response};
10use indexmap::IndexMap;
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13use std::sync::Arc;
14
15#[derive(Debug, Clone)]
17pub struct Errors {
18 pub errors: Vec<Error>,
19 pub sources: HashMap<SourceType, String>,
20}
21
22impl Errors {
23 pub fn iter(&self) -> std::slice::Iter<'_, Error> {
25 self.errors.iter()
26 }
27}
28
29pub fn resolve_effective(raw: Option<&str>) -> Result<DateTimeValue, Error> {
34 match raw {
35 Some(s) if !s.trim().is_empty() => s.trim().parse::<DateTimeValue>().map_err(|_| {
36 Error::request(
37 format!(
38 "Invalid effective value '{}'. Expected: YYYY, YYYY-MM, YYYY-MM-DD, or ISO 8601 datetime",
39 s.trim()
40 ),
41 None::<String>,
42 )
43 }),
44 _ => Ok(DateTimeValue::now()),
45 }
46}
47
48pub const EMBEDDED_STDLIB_REPOSITORY: &str = "lemma";
51
52#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
54pub struct ListedSpec {
55 pub name: String,
56 #[serde(skip_serializing_if = "Option::is_none", default)]
57 pub effective_from: Option<DateTimeValue>,
58 #[serde(skip_serializing_if = "Option::is_none", default)]
59 pub effective_to: Option<DateTimeValue>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
66pub struct ResolvedRepository {
67 #[serde(skip_serializing_if = "Option::is_none", default)]
68 pub repository: Option<String>,
69 pub specs: Vec<ListedSpec>,
70}
71
72#[derive(Debug, Serialize, Deserialize)]
84pub struct Context {
85 repositories: IndexMap<Arc<LemmaRepository>, IndexMap<String, LemmaSpecSet>>,
86 workspace: Arc<LemmaRepository>,
87}
88
89impl Default for Context {
90 fn default() -> Self {
91 Self::new()
92 }
93}
94
95impl Context {
96 pub fn new() -> Self {
98 let workspace = Arc::new(LemmaRepository::new(None));
99 let mut repositories = IndexMap::new();
100 repositories.insert(Arc::clone(&workspace), IndexMap::new());
101 Self {
102 repositories,
103 workspace,
104 }
105 }
106
107 #[must_use]
110 pub fn workspace(&self) -> Arc<LemmaRepository> {
111 Arc::clone(&self.workspace)
112 }
113
114 #[must_use]
116 pub fn find_repository(&self, name: &str) -> Option<Arc<LemmaRepository>> {
117 let probe = Arc::new(LemmaRepository::new(Some(name.to_string())));
118 self.repositories
119 .get_key_value(&probe)
120 .map(|(k, _)| Arc::clone(k))
121 }
122
123 #[must_use]
126 pub fn repositories(&self) -> &IndexMap<Arc<LemmaRepository>, IndexMap<String, LemmaSpecSet>> {
127 &self.repositories
128 }
129
130 pub fn iter(&self) -> impl Iterator<Item = &LemmaSpec> + '_ {
134 self.repositories
135 .values()
136 .flat_map(|m| m.values())
137 .flat_map(|ss| ss.iter_specs())
138 }
139
140 #[must_use]
143 pub fn spec_set(&self, repository: &Arc<LemmaRepository>, name: &str) -> Option<&LemmaSpecSet> {
144 let canonical_name = crate::parsing::ast::ascii_lowercase_logical_name(name.to_string());
145 self.repositories
146 .get(repository)
147 .and_then(|m| m.get(&canonical_name))
148 }
149
150 pub(crate) fn spec_sets_for(
153 &self,
154 repository: &Arc<LemmaRepository>,
155 ) -> impl Iterator<Item = &LemmaSpecSet> + '_ {
156 self.repositories
157 .get(repository)
158 .expect("BUG: repository not in context")
159 .values()
160 }
161
162 fn spec_declaration_source(spec: &LemmaSpec) -> crate::parsing::source::Source {
163 let source_type = spec
164 .source_type
165 .as_ref()
166 .expect("BUG: spec must carry source_type after parse");
167 crate::parsing::source::Source::new(
168 source_type.clone(),
169 crate::parsing::ast::Span {
170 start: 0,
171 end: 0,
172 line: spec.start_line,
173 col: 0,
174 },
175 )
176 }
177
178 fn duplicate_spec_path_line(spec: &LemmaSpec) -> (String, usize) {
179 let source_type = spec
180 .source_type
181 .as_ref()
182 .expect("BUG: spec must carry source_type after parse");
183 (source_type.to_string(), spec.start_line)
184 }
185
186 fn duplicate_spec_errors(name: &str, incoming: &LemmaSpec, existing: &LemmaSpec) -> Vec<Error> {
187 let (incoming_path, incoming_line) = Self::duplicate_spec_path_line(incoming);
188 let (existing_path, existing_line) = Self::duplicate_spec_path_line(existing);
189 vec![
190 Error::validation(
191 format!(
192 "Duplicate spec '{name}' (also declared in '{existing_path}':{existing_line})"
193 ),
194 Some(Self::spec_declaration_source(incoming)),
195 None::<String>,
196 ),
197 Error::validation(
198 format!(
199 "Duplicate spec '{name}' (also declared in '{incoming_path}':{incoming_line})"
200 ),
201 Some(Self::spec_declaration_source(existing)),
202 None::<String>,
203 ),
204 ]
205 }
206
207 pub fn insert_spec(
213 &mut self,
214 repository: Arc<LemmaRepository>,
215 spec: LemmaSpec,
216 ) -> Result<(), Vec<Error>> {
217 if let Some((existing_repo, _)) = self.repositories.get_key_value(&repository) {
218 if existing_repo.dependency != repository.dependency {
219 let repo_subject = match repository.name.as_deref() {
220 Some(name) => format!("Repository '{name}'"),
221 None => "The default repository".to_string(),
222 };
223 let existing_owner = match &existing_repo.dependency {
224 None => "a local source".to_string(),
225 Some(id) => format!("dependency '{id}'"),
226 };
227 let new_owner = match &repository.dependency {
228 None => "a local source".to_string(),
229 Some(id) => format!("dependency '{id}'"),
230 };
231 return Err(vec![Error::validation_with_context(
232 format!(
233 "{repo_subject} was introduced by {existing_owner} but {new_owner} also declares it"
234 ),
235 None,
236 Some("Each dependency's repositories must be unique across all loaded sources"),
237 Some(&spec),
238 None,
239 )]);
240 }
241 }
242
243 let entry = self
244 .repositories
245 .entry(Arc::clone(&repository))
246 .or_default();
247 if let Some(ss) = entry.get(&spec.name) {
248 if let Some(existing) = ss.get_exact(spec.effective_from()) {
249 return Err(Self::duplicate_spec_errors(&spec.name, &spec, existing));
250 }
251 }
252
253 let name = spec.name.clone();
254 if !entry
255 .entry(name.clone())
256 .or_insert_with(|| LemmaSpecSet::new(repository, name))
257 .insert(spec)
258 {
259 unreachable!("BUG: duplicate effective_from rejected above");
260 }
261 Ok(())
262 }
263
264 pub fn remove_spec(&mut self, repository: &Arc<LemmaRepository>, spec: &LemmaSpec) -> bool {
265 self.remove_spec_by_identity(repository, &spec.name, spec.effective_from())
266 }
267
268 pub fn remove_spec_by_identity(
270 &mut self,
271 repository: &Arc<LemmaRepository>,
272 name: &str,
273 effective_from: Option<&DateTimeValue>,
274 ) -> bool {
275 let Some(inner) = self.repositories.get_mut(repository) else {
276 return false;
277 };
278 let Some(ss) = inner.get_mut(name) else {
279 return false;
280 };
281 if !ss.remove(effective_from) {
282 return false;
283 }
284 if ss.is_empty() {
285 inner.shift_remove(name);
286 }
287 true
288 }
289}
290
291enum Mutation {
299 Remove {
300 repository: Option<String>,
301 spec: String,
302 effective_from: EffectiveDate,
303 },
304 Load {
305 source_type: SourceType,
306 code: String,
307 },
308 Replace {
311 repository: Option<String>,
312 source_type: SourceType,
313 code: String,
314 },
315}
316
317type StagedSpec = (SourceType, Arc<LemmaRepository>, LemmaSpec);
318
319#[derive(Serialize, Deserialize)]
328pub struct Engine {
329 pub(crate) context: Context,
330 pub(crate) plans: PlanStore,
331 limits: ResourceLimits,
332}
333
334impl Default for Engine {
335 fn default() -> Self {
336 Self::new()
337 }
338}
339
340impl Engine {
341 pub fn new() -> Self {
342 Self::with_limits(ResourceLimits::default())
343 }
344
345 pub fn with_limits(limits: ResourceLimits) -> Self {
346 let mut engine = Self {
347 context: Context::new(),
348 plans: PlanStore::new(),
349 limits,
350 };
351 engine
352 .apply(
353 vec![Mutation::Load {
354 source_type: SourceType::Dependency(EMBEDDED_STDLIB_REPOSITORY.to_string()),
355 code: crate::stdlib::UNITS_LEMMA.to_string(),
356 }],
357 true,
358 )
359 .expect("BUG: embedded stdlib must load");
360 engine
361 }
362
363 pub fn limits(&self) -> &ResourceLimits {
365 &self.limits
366 }
367
368 pub fn snapshot(&self) -> Result<Vec<u8>, Error> {
376 crate::snapshot::encode(self)
377 }
378
379 pub fn from_snapshot(bytes: &[u8]) -> Result<Self, Error> {
385 crate::snapshot::decode(bytes)
386 }
387
388 pub fn load(
394 &mut self,
395 sources: impl IntoIterator<Item = (SourceType, impl Into<String>)>,
396 ) -> Result<(), Errors> {
397 let mutations = sources
398 .into_iter()
399 .map(|(source_type, code)| Mutation::Load {
400 source_type,
401 code: code.into(),
402 })
403 .collect();
404 self.apply(mutations, false)
405 }
406
407 pub fn update(
420 &mut self,
421 repository: Option<&str>,
422 code: String,
423 source_type: SourceType,
424 ) -> Result<(), Errors> {
425 self.apply(
426 vec![Mutation::Replace {
427 repository: repository.map(str::to_string),
428 source_type,
429 code,
430 }],
431 false,
432 )
433 }
434
435 pub fn remove(
437 &mut self,
438 repository: Option<&str>,
439 spec: &str,
440 effective: Option<&DateTimeValue>,
441 ) -> Result<(), Error> {
442 let resolved = self.get_spec(spec, repository, effective)?;
443 let effective_from = resolved.effective_from.clone();
444 self.apply(
445 vec![Mutation::Remove {
446 repository: repository.map(str::to_string),
447 spec: spec.to_string(),
448 effective_from,
449 }],
450 false,
451 )
452 .map_err(|errs| {
453 errs.errors
454 .into_iter()
455 .next()
456 .expect("BUG: apply Errors must contain at least one error")
457 })
458 }
459
460 #[must_use]
464 pub fn list(&self) -> Vec<ResolvedRepository> {
465 self.context
466 .repositories()
467 .iter()
468 .map(|(repo, inner)| {
469 let specs = inner
470 .values()
471 .flat_map(|spec_set| {
472 spec_set
473 .iter_with_ranges()
474 .map(|(spec, from, to)| ListedSpec {
475 name: spec.name.clone(),
476 effective_from: from,
477 effective_to: to,
478 })
479 })
480 .collect();
481 ResolvedRepository {
482 repository: repo.name.clone(),
483 specs,
484 }
485 })
486 .collect()
487 }
488
489 pub fn show(
496 &self,
497 repository: Option<&str>,
498 spec: &str,
499 effective: Option<&DateTimeValue>,
500 ) -> Result<Show, Error> {
501 let effective_dt = self.effective_or_now(effective);
502 let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
503
504 let plan = match self.plans.get_plan(repository, spec, &instant) {
505 Some(plan) => plan,
506 None => {
507 let repository_arc = match repository {
510 Some(q) => self.context.find_repository(q).ok_or_else(|| {
511 Error::request_not_found(
512 format!("Repository '{q}' not loaded"),
513 Some(
514 "List repositories with `lemma list` after loading your workspace",
515 ),
516 )
517 })?,
518 None => self.context.workspace(),
519 };
520 let canonical_name =
521 crate::parsing::ast::ascii_lowercase_logical_name(spec.to_string());
522 let spec_set = self.context.spec_set(&repository_arc, &canonical_name);
523 return match spec_set.and_then(|ss| ss.spec_at(&instant)) {
524 None => Err(self.spec_not_found_in_repository_error(
525 &repository_arc,
526 spec,
527 &effective_dt,
528 )),
529 Some(_) => Err(Error::request_not_found(
530 format!(
531 "No execution plan slice for spec '{spec}' at effective {effective_dt}"
532 ),
533 Some("Ensure sources loaded and planning succeeded".to_string()),
534 )),
535 };
536 }
537 };
538
539 let mut data_entries: Vec<(usize, usize, String, ShowData)> = plan
540 .data
541 .iter()
542 .enumerate()
543 .filter(|(_, (_, data))| {
544 data.schema_type().is_some() && !matches!(data, DataDefinition::Reference { .. })
545 })
546 .map(|(position, (path, data))| {
547 let input_key = path.input_key();
548 let used_by = plan.needed_by_rules.get(position).map_or_else(
549 || {
550 panic!(
551 "BUG: needed_by_rules len {} < data position {position}",
552 plan.needed_by_rules.len()
553 )
554 },
555 |ids| {
556 ids.iter()
557 .map(|&rule_position| {
558 plan.rules
559 .get_index(rule_position as usize)
560 .expect("BUG: needed_by_rules position out of plan.rules range")
561 .1
562 .path
563 .input_key()
564 })
565 .collect()
566 },
567 );
568 let lemma_type = data
569 .schema_type()
570 .expect("BUG: filter above ensured lemma_type is Some")
571 .clone();
572 let display = plan.data_display.get(path);
573 (
574 path.segments.len(),
575 data.source().span.start,
576 input_key.clone(),
577 ShowData {
578 lemma_type: lemma_type.clone(),
579 path: path.segments.clone(),
580 fill: display.and_then(|d| {
581 d.fill.as_ref().map(|bound| {
582 crate::planning::execution_plan::show_bound_result_value(
583 bound,
584 &lemma_type,
585 &input_key,
586 crate::planning::execution_plan::ShowBoundRole::Fill,
587 )
588 })
589 }),
590 suggestion: display.and_then(|d| {
591 d.suggestion.as_ref().map(|bound| {
592 crate::planning::execution_plan::show_bound_result_value(
593 bound,
594 &lemma_type,
595 &input_key,
596 crate::planning::execution_plan::ShowBoundRole::Suggestion,
597 )
598 })
599 }),
600 needed_by_rules: used_by,
601 },
602 )
603 })
604 .collect();
605 data_entries.sort_by_key(|(depth, pos, _, _)| (*depth, *pos));
606
607 let rule_entries: Vec<(String, crate::planning::show_expression::ShowRule)> = plan
608 .show_rules
609 .iter()
610 .map(|(path, rule)| (path.input_key(), rule.clone()))
611 .collect();
612
613 let show_repository = match repository {
614 None => None,
615 Some(q) => {
616 let repo = self
617 .context
618 .find_repository(q)
619 .expect("BUG: get_plan succeeded so that repository must be loaded");
620 Some(
621 repo.name
622 .clone()
623 .expect("BUG: named repository cannot have name None"),
624 )
625 }
626 };
627
628 Ok(Show {
629 repository: show_repository,
630 spec: plan.spec_name.clone(),
631 commentary: plan.commentary.clone(),
632 effective_from: plan.effective_from.clone(),
633 effective_to: plan.effective_to.clone(),
634 versions: plan.versions.to_vec(),
635 start_line: plan.start_line,
636 source_type: plan.source_type.clone(),
637 data: data_entries
638 .into_iter()
639 .map(|(_, _, name, entry)| (name, entry))
640 .collect(),
641 rules: rule_entries.into_iter().collect(),
642 meta: plan.meta.clone(),
643 })
644 }
645
646 pub fn source(
651 &self,
652 repository: Option<&str>,
653 spec: Option<&str>,
654 effective: Option<&DateTimeValue>,
655 ) -> Result<String, Error> {
656 match spec {
657 None => self.format_repository_source(repository),
658 Some(spec_name) => {
659 let effective_dt = self.effective_or_now(effective);
660 let resolved_spec = self.get_spec(spec_name, repository, Some(&effective_dt))?;
661 Ok(crate::formatting::format_spec_refs(&[resolved_spec]))
662 }
663 }
664 }
665
666 pub fn run(
668 &self,
669 repository: Option<&str>,
670 spec: &str,
671 effective: Option<&DateTimeValue>,
672 data: HashMap<String, String>,
673 rules: Option<&[String]>,
674 explain: bool,
675 ) -> Result<Response, Error> {
676 let effective = self.effective_or_now(effective);
677 let instant = EffectiveDate::DateTimeValue(effective.clone());
678
679 let plan = self
680 .plans
681 .get_plan(repository, spec, &instant)
682 .ok_or_else(|| {
683 Error::request_not_found(
684 format!("No execution plan for spec '{spec}' at effective {effective}"),
685 Some("Ensure sources loaded and planning succeeded".to_string()),
686 )
687 })?;
688
689 let response_rules = plan.validated_response_rule_paths(rules)?;
690 let data_values: HashMap<String, RunDataValue> = data
691 .into_iter()
692 .map(|(key, value)| (key, RunDataValue::string(value)))
693 .collect();
694 let run_data = RunData::resolve(plan, data_values, &self.limits)?;
695 let now_semantic = crate::planning::semantics::date_time_to_semantic(&effective);
696 let now_literal = crate::planning::semantics::LiteralValue::date(now_semantic);
697 let evaluator = Evaluator;
698 let mut response =
699 evaluator.evaluate(plan, &run_data, now_literal, &response_rules, explain);
700
701 response.spec_effective_from = plan.effective_from.clone();
702 response.spec_effective_to = plan.effective_to.clone();
703
704 Ok(response)
705 }
706
707 fn format_repository_source(&self, repository: Option<&str>) -> Result<String, Error> {
708 let repo_arc = self.resolve_repository(repository)?;
709 let mut all_specs: Vec<&LemmaSpec> = self
710 .context
711 .spec_sets_for(&repo_arc)
712 .flat_map(|ss| ss.iter_specs())
713 .collect();
714 all_specs.sort_by(|a, b| {
715 a.name
716 .cmp(&b.name)
717 .then_with(|| a.effective_from.cmp(&b.effective_from))
718 });
719 let body = crate::formatting::format_spec_refs(&all_specs);
720 let mut source_text = String::new();
721 if let Some(name) = repo_arc.name.as_deref() {
722 source_text.push_str("repo ");
723 source_text.push_str(name);
724 source_text.push_str("\n\n");
725 }
726 source_text.push_str(&body);
727 Ok(source_text)
728 }
729
730 fn resolve_repository(&self, repository: Option<&str>) -> Result<Arc<LemmaRepository>, Error> {
731 match repository {
732 None => Ok(self.context.workspace()),
733 Some(qualifier) => {
734 let q = qualifier.trim();
735 if q.is_empty() {
736 return Err(Error::request(
737 "Repository qualifier cannot be empty",
738 None::<String>,
739 ));
740 }
741 self.context.find_repository(q).ok_or_else(|| {
742 Error::request_not_found(
743 format!("Repository '{qualifier}' not loaded"),
744 Some(format!(
745 "List repositories with `{}` after loading your workspace",
746 "lemma list"
747 )),
748 )
749 })
750 }
751 }
752 }
753
754 fn spec_not_found_in_repository_error(
755 &self,
756 repository: &LemmaRepository,
757 spec_name: &str,
758 effective: &DateTimeValue,
759 ) -> Error {
760 let message = match &repository.name {
761 Some(n) => {
762 format!("Spec '{spec_name}' not found in repository {n} at effective {effective}")
763 }
764 None => format!("Spec '{spec_name}' not found at effective {effective}"),
765 };
766 Error::request_not_found(message, Some("Try `lemma list`"))
767 }
768
769 #[must_use]
771 fn effective_or_now(&self, effective: Option<&DateTimeValue>) -> DateTimeValue {
772 effective.cloned().unwrap_or_else(DateTimeValue::now)
773 }
774
775 fn reserved_stdlib_error(source: Option<crate::parsing::source::Source>) -> Error {
776 Error::validation(
777 format!(
778 "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"
779 ),
780 source,
781 Some(
782 "Load registry dependencies with @owner/repo qualifiers, not the reserved 'lemma' stdlib repository"
783 .to_string(),
784 ),
785 )
786 }
787
788 fn resource_limit_errors(
789 name: &str,
790 limit: impl ToString,
791 actual: impl ToString,
792 hint: &str,
793 sources: IndexMap<SourceType, String>,
794 ) -> Errors {
795 Errors {
796 errors: vec![Error::resource_limit_exceeded(
797 name,
798 limit.to_string(),
799 actual.to_string(),
800 hint,
801 None::<crate::parsing::source::Source>,
802 None,
803 None,
804 )],
805 sources: sources.into_iter().collect(),
806 }
807 }
808
809 fn apply(&mut self, mutations: Vec<Mutation>, embedded_stdlib: bool) -> Result<(), Errors> {
814 let mut sources: IndexMap<SourceType, String> = IndexMap::new();
815 let mut to_restore: Vec<(Arc<LemmaRepository>, LemmaSpec)> = Vec::new();
816 let mut replace: Option<(Option<String>, SourceType, String)> = None;
817 let mut saw_load = false;
818
819 for mutation in mutations {
820 match mutation {
821 Mutation::Remove {
822 repository,
823 spec,
824 effective_from,
825 } => {
826 let repo_ref = repository.as_deref();
827 let repository_arc = self.resolve_repository(repo_ref).unwrap_or_else(|e| {
828 panic!(
829 "BUG: Mutation::Remove repository must resolve after public remove validated it: {e}"
830 )
831 });
832 let spec_to_remove = self
833 .context
834 .spec_set(&repository_arc, &spec)
835 .and_then(|ss| ss.get_exact(effective_from.as_ref()))
836 .unwrap_or_else(|| {
837 panic!(
838 "BUG: Mutation::Remove target '{spec}' must exist after public remove validated it"
839 )
840 });
841 to_restore.push((Arc::clone(&repository_arc), spec_to_remove.clone()));
842 }
843 Mutation::Load { source_type, code } => {
844 saw_load = true;
845 if replace.is_some() {
846 panic!("BUG: Load and Replace in one apply");
847 }
848 if sources.insert(source_type.clone(), code).is_some() {
849 return Err(Errors {
850 errors: vec![Error::request(
851 format!("Duplicate source key: {source_type}"),
852 None::<String>,
853 )],
854 sources: sources.into_iter().collect(),
855 });
856 }
857 }
858 Mutation::Replace {
859 repository,
860 source_type,
861 code,
862 } => {
863 if saw_load || !sources.is_empty() {
864 panic!("BUG: Load and Replace in one apply");
865 }
866 if replace.is_some() {
867 panic!("BUG: multiple Replace mutations in one apply");
868 }
869 replace = Some((repository, source_type, code));
870 }
871 }
872 }
873
874 if let Some((repo_constraint, source_type, code)) = replace {
875 return self.apply_replace(
876 repo_constraint.as_deref(),
877 source_type,
878 code,
879 to_restore,
880 embedded_stdlib,
881 );
882 }
883
884 let sources_map: HashMap<SourceType, String> = sources.clone().into_iter().collect();
885 for st in sources.keys() {
886 if let Err(e) = Self::validate_source_type_key(st, embedded_stdlib) {
887 return Err(Errors {
888 errors: vec![e],
889 sources: sources_map,
890 });
891 }
892 }
893 self.check_batch_limits(&sources, embedded_stdlib)?;
894
895 let parse_limits = if embedded_stdlib {
896 &ResourceLimits::default()
897 } else {
898 &self.limits
899 };
900 let mut staged: Vec<StagedSpec> = Vec::new();
901 let mut errors: Vec<Error> = Vec::new();
902
903 for (source_id, code) in &sources {
904 match self.stage_parsed_source(source_id, code, parse_limits, embedded_stdlib) {
905 Ok(chunk) => staged.extend(chunk),
906 Err(es) => errors.extend(es),
907 }
908 }
909
910 if !errors.is_empty() {
911 return Err(Errors {
912 errors,
913 sources: sources.into_iter().collect(),
914 });
915 }
916
917 self.commit_staged(to_restore, staged, sources.into_iter().collect())
918 }
919
920 fn validate_source_type_key(
921 source_type: &SourceType,
922 embedded_stdlib: bool,
923 ) -> Result<(), Error> {
924 match source_type {
925 SourceType::Path(p) if p.as_os_str().to_string_lossy().trim().is_empty() => Err(
926 Error::request("Source path must be non-empty", None::<String>),
927 ),
928 SourceType::Dependency(id) if id.is_empty() => Err(Error::request(
929 "Dependency source identifier must be non-empty",
930 None::<String>,
931 )),
932 SourceType::Dependency(id) if !embedded_stdlib && id == EMBEDDED_STDLIB_REPOSITORY => {
933 Err(Self::reserved_stdlib_error(None))
934 }
935 _ => Ok(()),
936 }
937 }
938
939 fn check_batch_limits(
940 &self,
941 sources: &IndexMap<SourceType, String>,
942 embedded_stdlib: bool,
943 ) -> Result<(), Errors> {
944 if embedded_stdlib || sources.is_empty() {
945 return Ok(());
946 }
947 let limits = &self.limits;
948 if sources.len() > limits.max_sources {
949 return Err(Self::resource_limit_errors(
950 "max_sources",
951 limits.max_sources,
952 sources.len(),
953 "Reduce the number of paths or sources in one load",
954 sources.clone(),
955 ));
956 }
957 let total_loaded_bytes: usize = sources.values().map(|s| s.len()).sum();
958 if total_loaded_bytes > limits.max_loaded_bytes {
959 return Err(Self::resource_limit_errors(
960 "max_loaded_bytes",
961 limits.max_loaded_bytes,
962 total_loaded_bytes,
963 "Load fewer or smaller sources",
964 sources.clone(),
965 ));
966 }
967 if let Some(code) = sources
968 .values()
969 .find(|code| code.len() > limits.max_source_size_bytes)
970 {
971 return Err(Self::resource_limit_errors(
972 "max_source_size_bytes",
973 limits.max_source_size_bytes,
974 code.len(),
975 "Use a smaller source text or increase limit",
976 sources.clone(),
977 ));
978 }
979 Ok(())
980 }
981
982 fn stage_parsed_source(
983 &self,
984 source_id: &SourceType,
985 code: &str,
986 parse_limits: &ResourceLimits,
987 embedded_stdlib: bool,
988 ) -> Result<Vec<StagedSpec>, Vec<Error>> {
989 let dependency = match source_id {
990 SourceType::Dependency(id) => Some(id.as_str()),
991 _ => None,
992 };
993 let result = parse(code, source_id.clone(), parse_limits).map_err(|e| vec![e])?;
994 if result.repositories.is_empty() {
995 return Ok(Vec::new());
996 }
997
998 let mut staged = Vec::new();
999 let mut errors = Vec::new();
1000 for (parsed_repo, specs) in result.repositories {
1001 let repository_arc = if let Some(dep_id) = dependency {
1002 let repo_name = parsed_repo
1003 .name
1004 .clone()
1005 .or_else(|| Some(dep_id.to_string()));
1006 Arc::new(
1007 LemmaRepository::new(repo_name)
1008 .with_dependency(dep_id)
1009 .with_start_line(parsed_repo.start_line),
1010 )
1011 } else {
1012 parsed_repo
1013 };
1014 if !embedded_stdlib
1015 && repository_arc.name.as_deref() == Some(EMBEDDED_STDLIB_REPOSITORY)
1016 {
1017 let source = crate::parsing::source::Source::new(
1018 source_id.clone(),
1019 crate::parsing::ast::Span {
1020 start: 0,
1021 end: 0,
1022 line: repository_arc.start_line,
1023 col: 0,
1024 },
1025 );
1026 errors.push(Self::reserved_stdlib_error(Some(source)));
1027 continue;
1028 }
1029 for spec in specs {
1030 staged.push((source_id.clone(), Arc::clone(&repository_arc), spec));
1031 }
1032 }
1033 if !errors.is_empty() {
1034 return Err(errors);
1035 }
1036 Ok(staged)
1037 }
1038
1039 fn apply_replace(
1040 &mut self,
1041 repository_constraint: Option<&str>,
1042 source_type: SourceType,
1043 code: String,
1044 mut to_restore: Vec<(Arc<LemmaRepository>, LemmaSpec)>,
1045 embedded_stdlib: bool,
1046 ) -> Result<(), Errors> {
1047 let mut sources: IndexMap<SourceType, String> = IndexMap::new();
1048 sources.insert(source_type.clone(), code.clone());
1049
1050 if let Err(e) = Self::validate_source_type_key(&source_type, embedded_stdlib) {
1051 return Err(Errors {
1052 errors: vec![e],
1053 sources: sources.into_iter().collect(),
1054 });
1055 }
1056
1057 self.check_batch_limits(&sources, embedded_stdlib)?;
1058
1059 let parse_limits = if embedded_stdlib {
1060 &ResourceLimits::default()
1061 } else {
1062 &self.limits
1063 };
1064
1065 let staged =
1066 match self.stage_parsed_source(&source_type, &code, parse_limits, embedded_stdlib) {
1067 Ok(s) => s,
1068 Err(errors) => {
1069 return Err(Errors {
1070 errors,
1071 sources: sources.into_iter().collect(),
1072 });
1073 }
1074 };
1075
1076 let prune = matches!(source_type, SourceType::Path(_) | SourceType::Dependency(_));
1077 if staged.is_empty() && !prune {
1078 return Err(Errors {
1079 errors: vec![Error::request(
1080 "update requires at least one spec",
1081 None::<String>,
1082 )],
1083 sources: sources.into_iter().collect(),
1084 });
1085 }
1086
1087 if let Some(required) = repository_constraint {
1088 let required_canonical =
1089 crate::parsing::ast::ascii_lowercase_logical_name(required.to_string());
1090 for (_, repository_arc, _) in &staged {
1091 if repository_arc.name.as_deref() != Some(required_canonical.as_str()) {
1092 return Err(Errors {
1093 errors: vec![Error::request(
1094 match repository_arc.name.as_deref() {
1095 Some(name) => format!(
1096 "update repository '{required}' does not match staged repository '{name}'"
1097 ),
1098 None => format!(
1099 "update repository '{required}' does not match the default repository"
1100 ),
1101 },
1102 None::<String>,
1103 )],
1104 sources: sources.into_iter().collect(),
1105 });
1106 }
1107 }
1108 }
1109
1110 let mut staged_keys: std::collections::HashSet<(Option<String>, String, EffectiveDate)> =
1111 std::collections::HashSet::new();
1112 for (_, repository_arc, spec) in &staged {
1113 staged_keys.insert((
1114 repository_arc.name.clone(),
1115 spec.name.clone(),
1116 spec.effective_from.clone(),
1117 ));
1118 }
1119
1120 if prune {
1121 for (repository, by_name) in self.context.repositories() {
1122 for spec_set in by_name.values() {
1123 for spec in spec_set.iter_specs() {
1124 if spec.source_type.as_ref() != Some(&source_type) {
1125 continue;
1126 }
1127 let key = (
1128 repository.name.clone(),
1129 spec.name.clone(),
1130 spec.effective_from.clone(),
1131 );
1132 if !staged_keys.contains(&key) {
1133 to_restore.push((Arc::clone(repository), spec.clone()));
1134 }
1135 }
1136 }
1137 }
1138 }
1139
1140 let mut to_insert: Vec<(Arc<LemmaRepository>, LemmaSpec)> = Vec::new();
1141 let mut cross_source_errors: Vec<Error> = Vec::new();
1142 for (_, repository_arc, staged_spec) in staged {
1143 match self
1144 .context
1145 .spec_set(&repository_arc, &staged_spec.name)
1146 .and_then(|ss| ss.get_exact(staged_spec.effective_from.as_ref()))
1147 {
1148 None => to_insert.push((repository_arc, staged_spec)),
1149 Some(old) if old == &staged_spec => {}
1150 Some(old) if old.source_type.as_ref() != Some(&source_type) => {
1151 cross_source_errors.extend(Context::duplicate_spec_errors(
1152 &staged_spec.name,
1153 &staged_spec,
1154 old,
1155 ));
1156 }
1157 Some(old) => {
1158 to_restore.push((Arc::clone(&repository_arc), old.clone()));
1159 to_insert.push((repository_arc, staged_spec));
1160 }
1161 }
1162 }
1163
1164 if !cross_source_errors.is_empty() {
1165 return Err(Errors {
1166 errors: cross_source_errors,
1167 sources: sources.into_iter().collect(),
1168 });
1169 }
1170
1171 let staged_for_commit: Vec<StagedSpec> = to_insert
1172 .into_iter()
1173 .map(|(repo, spec)| (source_type.clone(), repo, spec))
1174 .collect();
1175
1176 self.commit_staged(to_restore, staged_for_commit, sources.into_iter().collect())
1177 }
1178
1179 fn commit_staged(
1180 &mut self,
1181 to_restore: Vec<(Arc<LemmaRepository>, LemmaSpec)>,
1182 staged: Vec<StagedSpec>,
1183 sources: HashMap<SourceType, String>,
1184 ) -> Result<(), Errors> {
1185 let mut errors: Vec<Error> = Vec::new();
1186
1187 for (repo, spec) in &to_restore {
1188 self.context.remove_spec(repo, spec);
1189 }
1190
1191 let mut inserted: Vec<(Arc<LemmaRepository>, String, EffectiveDate)> = Vec::new();
1192 for (_, repository_arc, spec) in staged {
1193 let name = spec.name.clone();
1194 let effective_from = spec.effective_from.clone();
1195 match self.context.insert_spec(Arc::clone(&repository_arc), spec) {
1196 Ok(()) => inserted.push((repository_arc, name, effective_from)),
1197 Err(es) => {
1198 errors.extend(es);
1199 self.rollback_apply(&inserted, &to_restore);
1200 return Err(Errors { errors, sources });
1201 }
1202 }
1203 }
1204
1205 let mut changed: Vec<(Arc<LemmaRepository>, String, EffectiveDate)> =
1206 Vec::with_capacity(to_restore.len() + inserted.len());
1207 let mut restored_by_key: HashMap<crate::planning::SpecSetKey, HashSet<EffectiveDate>> =
1208 HashMap::new();
1209 let mut inserted_by_key: HashMap<crate::planning::SpecSetKey, HashSet<EffectiveDate>> =
1210 HashMap::new();
1211
1212 for (repository, spec) in &to_restore {
1213 let key = crate::planning::SpecSetKey::new(repository.name.as_deref(), &spec.name);
1214 restored_by_key
1215 .entry(key)
1216 .or_default()
1217 .insert(spec.effective_from.clone());
1218 changed.push((
1219 Arc::clone(repository),
1220 spec.name.clone(),
1221 spec.effective_from.clone(),
1222 ));
1223 }
1224 for (repository, name, effective_from) in &inserted {
1225 let key = crate::planning::SpecSetKey::new(repository.name.as_deref(), name);
1226 inserted_by_key
1227 .entry(key)
1228 .or_default()
1229 .insert(effective_from.clone());
1230 changed.push((Arc::clone(repository), name.clone(), effective_from.clone()));
1231 }
1232
1233 let mut whole_set: HashSet<crate::planning::SpecSetKey> = HashSet::new();
1234 let mut all_keys: HashSet<crate::planning::SpecSetKey> = HashSet::new();
1235 all_keys.extend(restored_by_key.keys().cloned());
1236 all_keys.extend(inserted_by_key.keys().cloned());
1237 for key in all_keys {
1238 let restored = restored_by_key.get(&key).cloned().unwrap_or_default();
1239 let inserted_effs = inserted_by_key.get(&key).cloned().unwrap_or_default();
1240 if restored != inserted_effs {
1241 whole_set.insert(key);
1242 }
1243 }
1244
1245 let scope = crate::planning::ReplanScope::from_changed(&self.context, changed, whole_set);
1246
1247 let result = crate::planning::plan(&self.context, &self.limits, &scope, &self.plans);
1248 if !result.errors.is_empty() {
1249 self.rollback_apply(&inserted, &to_restore);
1250 return Err(Errors {
1251 errors: result.errors,
1252 sources,
1253 });
1254 }
1255
1256 self.plans.commit(&self.context, &scope, result.plans);
1257 Ok(())
1258 }
1259
1260 fn rollback_apply(
1261 &mut self,
1262 inserted: &[(Arc<LemmaRepository>, String, EffectiveDate)],
1263 removed: &[(Arc<LemmaRepository>, LemmaSpec)],
1264 ) {
1265 for (repo, inserted_name, inserted_effective) in inserted.iter().rev() {
1266 self.context
1267 .remove_spec_by_identity(repo, inserted_name, inserted_effective.as_ref());
1268 }
1269 for (repo, spec) in removed.iter().rev() {
1270 self.context
1271 .insert_spec(Arc::clone(repo), spec.clone())
1272 .expect("BUG: restore removed spec for rollback");
1273 }
1274 }
1275
1276 pub(crate) fn get_spec(
1280 &self,
1281 name: &str,
1282 repository: Option<&str>,
1283 effective: Option<&DateTimeValue>,
1284 ) -> Result<&LemmaSpec, Error> {
1285 let effective_dt = self.effective_or_now(effective);
1286 let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
1287 let repository_arc = match repository {
1288 Some(q) => self.context.find_repository(q).ok_or_else(|| {
1289 Error::request_not_found(
1290 format!("Repository '{q}' not loaded"),
1291 Some("List repositories with `lemma list` after loading your workspace"),
1292 )
1293 })?,
1294 None => self.context.workspace(),
1295 };
1296 let spec_set = self
1297 .context
1298 .spec_set(&repository_arc, name)
1299 .ok_or_else(|| {
1300 self.spec_not_found_in_repository_error(&repository_arc, name, &effective_dt)
1301 })?;
1302 spec_set.spec_at(&instant).ok_or_else(|| {
1303 self.spec_not_found_in_repository_error(&repository_arc, name, &effective_dt)
1304 })
1305 }
1306}
1307#[cfg(test)]
1308mod tests {
1309 use super::*;
1310
1311 fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
1312 DateTimeValue {
1313 year,
1314 month,
1315 day,
1316 hour: 0,
1317 minute: 0,
1318 second: 0,
1319 microsecond: 0,
1320 timezone: None,
1321 granularity: crate::literals::DateGranularity::Full,
1322 }
1323 }
1324
1325 fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
1326 let mut spec = LemmaSpec::new(name.to_string());
1327 spec.effective_from = crate::parsing::ast::EffectiveDate::from_option(effective_from);
1328 spec
1329 }
1330
1331 #[test]
1334 fn list_order_is_name_then_effective_from_ascending() {
1335 let mut ctx = Context::new();
1336 let repository = ctx.workspace();
1337 let s_2026 = make_spec_with_range("mortgage", Some(date(2026, 1, 1)));
1338 let s_2025 = make_spec_with_range("mortgage", Some(date(2025, 1, 1)));
1339 ctx.insert_spec(Arc::clone(&repository), s_2026).unwrap();
1340 ctx.insert_spec(Arc::clone(&repository), s_2025).unwrap();
1341 let listed: Vec<_> = ctx
1342 .spec_set(&repository, "mortgage")
1343 .expect("mortgage set")
1344 .iter_specs()
1345 .collect();
1346 assert_eq!(listed.len(), 2);
1347 assert_eq!(listed[0].effective_from(), Some(&date(2025, 1, 1)));
1348 assert_eq!(listed[1].effective_from(), Some(&date(2026, 1, 1)));
1349 }
1350
1351 #[test]
1352 fn get_spec_resolves_temporal_version_by_effective() {
1353 let mut engine = Engine::new();
1354 engine
1355 .load([(
1356 SourceType::Path(Arc::new(std::path::PathBuf::from("a.lemma"))),
1357 r#"
1358 spec pricing 2025-01-01
1359 data x: 1
1360 rule r: x
1361 "#
1362 .to_string(),
1363 )])
1364 .unwrap();
1365 engine
1366 .load([(
1367 SourceType::Path(Arc::new(std::path::PathBuf::from("b.lemma"))),
1368 r#"
1369 spec pricing 2025-06-01
1370 data x: 2
1371 rule r: x
1372 "#
1373 .to_string(),
1374 )])
1375 .unwrap();
1376
1377 let jan = DateTimeValue {
1378 year: 2025,
1379 month: 1,
1380 day: 15,
1381 hour: 0,
1382 minute: 0,
1383 second: 0,
1384 microsecond: 0,
1385 timezone: None,
1386 granularity: crate::literals::DateGranularity::Full,
1387 };
1388 let jul = DateTimeValue {
1389 year: 2025,
1390 month: 7,
1391 day: 1,
1392 hour: 0,
1393 minute: 0,
1394 second: 0,
1395 microsecond: 0,
1396 timezone: None,
1397 granularity: crate::literals::DateGranularity::Full,
1398 };
1399
1400 let v1 = DateTimeValue {
1401 year: 2025,
1402 month: 1,
1403 day: 1,
1404 hour: 0,
1405 minute: 0,
1406 second: 0,
1407 microsecond: 0,
1408 timezone: None,
1409 granularity: crate::literals::DateGranularity::Full,
1410 };
1411 let v2 = DateTimeValue {
1412 year: 2025,
1413 month: 6,
1414 day: 1,
1415 hour: 0,
1416 minute: 0,
1417 second: 0,
1418 microsecond: 0,
1419 timezone: None,
1420 granularity: crate::literals::DateGranularity::Full,
1421 };
1422
1423 let s_jan = engine
1424 .get_spec("pricing", None, Some(&jan))
1425 .expect("jan spec");
1426 let s_jul = engine
1427 .get_spec("pricing", None, Some(&jul))
1428 .expect("jul spec");
1429 assert_eq!(s_jan.effective_from(), Some(&v1));
1430 assert_eq!(s_jul.effective_from(), Some(&v2));
1431 }
1432
1433 #[test]
1438 fn list_returns_half_open_ranges_per_temporal_version() {
1439 let mut engine = Engine::new();
1440 engine
1441 .load([(
1442 SourceType::Path(Arc::new(std::path::PathBuf::from("a.lemma"))),
1443 r#"
1444 spec pricing 2025-01-01
1445 data x: 1
1446 rule r: x
1447 "#
1448 .to_string(),
1449 )])
1450 .unwrap();
1451 engine
1452 .load([(
1453 SourceType::Path(Arc::new(std::path::PathBuf::from("b.lemma"))),
1454 r#"
1455 spec pricing 2025-06-01
1456 data x: 2
1457 rule r: x
1458 "#
1459 .to_string(),
1460 )])
1461 .unwrap();
1462
1463 let january = date(2025, 1, 1);
1464 let june = date(2025, 6, 1);
1465
1466 let workspace = engine
1467 .list()
1468 .into_iter()
1469 .find(|r| r.repository.is_none())
1470 .expect("workspace");
1471 let mut pricing_rows: Vec<_> = workspace
1472 .specs
1473 .iter()
1474 .filter(|ls| ls.name == "pricing")
1475 .map(|ls| (ls.effective_from.clone(), ls.effective_to.clone()))
1476 .collect();
1477 pricing_rows.sort_by(|a, b| match (&a.0, &b.0) {
1478 (Some(x), Some(y)) => x.cmp(y),
1479 (None, Some(_)) => std::cmp::Ordering::Less,
1480 (Some(_), None) => std::cmp::Ordering::Greater,
1481 (None, None) => std::cmp::Ordering::Equal,
1482 });
1483 assert_eq!(pricing_rows.len(), 2);
1484 assert_eq!(
1485 pricing_rows[0],
1486 (Some(january.clone()), Some(june.clone())),
1487 "earlier row ends at the next row's effective_from"
1488 );
1489 assert_eq!(
1490 pricing_rows[1],
1491 (Some(june.clone()), None),
1492 "latest row has no successor; effective_to is None"
1493 );
1494
1495 assert!(
1496 !engine
1497 .list()
1498 .into_iter()
1499 .find(|r| r.repository.is_none())
1500 .expect("workspace")
1501 .specs
1502 .iter()
1503 .any(|ls| ls.name == "unknown"),
1504 "no rows for unknown spec"
1505 );
1506 }
1507
1508 #[test]
1511 fn get_workspace_specs_with_half_open_ranges() {
1512 let mut engine = Engine::new();
1513 engine
1514 .load([(
1515 SourceType::Path(Arc::new(std::path::PathBuf::from("pricing_v1.lemma"))),
1516 r#"
1517 spec pricing 2025-01-01
1518 data x: 1
1519 rule r: x
1520 "#
1521 .to_string(),
1522 )])
1523 .unwrap();
1524 engine
1525 .load([(
1526 SourceType::Path(Arc::new(std::path::PathBuf::from("pricing_v2.lemma"))),
1527 r#"
1528 spec pricing 2026-01-01
1529 data x: 2
1530 rule r: x
1531 "#
1532 .to_string(),
1533 )])
1534 .unwrap();
1535 engine
1536 .load([(
1537 SourceType::Path(Arc::new(std::path::PathBuf::from("taxes.lemma"))),
1538 r#"
1539 spec taxes
1540 data rate: 0.21
1541 rule amount: rate
1542 "#
1543 .to_string(),
1544 )])
1545 .unwrap();
1546
1547 let workspace = engine
1548 .list()
1549 .into_iter()
1550 .find(|r| r.repository.is_none())
1551 .expect("workspace");
1552 let unique_names: std::collections::BTreeSet<&str> =
1553 workspace.specs.iter().map(|ls| ls.name.as_str()).collect();
1554 assert_eq!(
1555 unique_names.len(),
1556 2,
1557 "two unique spec names: pricing and taxes"
1558 );
1559
1560 let pricing_rows: Vec<_> = workspace
1561 .specs
1562 .iter()
1563 .filter(|ls| ls.name == "pricing")
1564 .collect();
1565 assert_eq!(pricing_rows.len(), 2);
1566 assert_eq!(pricing_rows[0].effective_from, Some(date(2025, 1, 1)));
1567 assert_eq!(
1568 pricing_rows[0].effective_to,
1569 Some(date(2026, 1, 1)),
1570 "earlier pricing row ends at the next pricing row's effective_from"
1571 );
1572 assert_eq!(pricing_rows[1].effective_from, Some(date(2026, 1, 1)));
1573 assert_eq!(
1574 pricing_rows[1].effective_to, None,
1575 "latest pricing row has no successor; effective_to is None"
1576 );
1577
1578 let tax_rows: Vec<_> = workspace
1579 .specs
1580 .iter()
1581 .filter(|ls| ls.name == "taxes")
1582 .collect();
1583 assert_eq!(tax_rows.len(), 1);
1584 assert_eq!(
1585 tax_rows[0].effective_from, None,
1586 "unversioned spec has no declared effective_from"
1587 );
1588 assert_eq!(
1589 tax_rows[0].effective_to, None,
1590 "unversioned spec has no successor; effective_to is None"
1591 );
1592 }
1593
1594 #[test]
1595 fn test_evaluate_spec_all_rules() {
1596 let mut engine = Engine::new();
1597 engine
1598 .load([(
1599 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1600 r#"
1601 spec test
1602 data x: 10
1603 data y: 5
1604 rule sum: x + y
1605 rule product: x * y
1606 "#
1607 .to_string(),
1608 )])
1609 .unwrap();
1610
1611 let now = DateTimeValue::now();
1612 let response = engine
1613 .run(None, "test", Some(&now), HashMap::new(), None, false)
1614 .unwrap();
1615 assert_eq!(response.results.len(), 2);
1616
1617 let sum_result = response
1618 .results
1619 .values()
1620 .find(|r| r.rule.name == "sum")
1621 .unwrap();
1622 assert_eq!(sum_result.result().expect("result").to_string(), "15");
1623
1624 let product_result = response
1625 .results
1626 .values()
1627 .find(|r| r.rule.name == "product")
1628 .unwrap();
1629 assert_eq!(product_result.result().expect("result").to_string(), "50");
1630 }
1631
1632 #[test]
1633 fn test_evaluate_empty_data() {
1634 let mut engine = Engine::new();
1635 engine
1636 .load([(
1637 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1638 r#"
1639 spec test
1640 data price: 100
1641 rule total: price * 2
1642 "#
1643 .to_string(),
1644 )])
1645 .unwrap();
1646
1647 let now = DateTimeValue::now();
1648 let response = engine
1649 .run(None, "test", Some(&now), HashMap::new(), None, false)
1650 .unwrap();
1651 assert_eq!(response.results.len(), 1);
1652 assert_eq!(
1653 response
1654 .results
1655 .values()
1656 .next()
1657 .unwrap()
1658 .result()
1659 .expect("result"),
1660 "200"
1661 );
1662 }
1663
1664 #[test]
1665 fn test_evaluate_boolean_rule() {
1666 let mut engine = Engine::new();
1667 engine
1668 .load([(
1669 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1670 r#"
1671 spec test
1672 data age: 25
1673 rule is_adult: age >= 18
1674 "#
1675 .to_string(),
1676 )])
1677 .unwrap();
1678
1679 let now = DateTimeValue::now();
1680 let response = engine
1681 .run(None, "test", Some(&now), HashMap::new(), None, false)
1682 .unwrap();
1683 assert_eq!(
1684 response
1685 .results
1686 .values()
1687 .next()
1688 .unwrap()
1689 .result
1690 .as_ref()
1691 .unwrap()
1692 .boolean,
1693 Some(true)
1694 );
1695 }
1696
1697 #[test]
1698 fn test_evaluate_with_unless_clause() {
1699 let mut engine = Engine::new();
1700 engine
1701 .load([(
1702 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1703 r#"
1704 spec test
1705 data quantity: 15
1706 rule discount: 0
1707 unless quantity >= 10 then 10
1708 "#
1709 .to_string(),
1710 )])
1711 .unwrap();
1712
1713 let now = DateTimeValue::now();
1714 let response = engine
1715 .run(None, "test", Some(&now), HashMap::new(), None, false)
1716 .unwrap();
1717 assert_eq!(
1718 response
1719 .results
1720 .values()
1721 .next()
1722 .unwrap()
1723 .result()
1724 .expect("result"),
1725 "10"
1726 );
1727 }
1728
1729 #[test]
1730 fn test_spec_not_found() {
1731 let engine = Engine::new();
1732 let now = DateTimeValue::now();
1733 let result = engine.run(None, "nonexistent", Some(&now), HashMap::new(), None, false);
1734 assert!(result.is_err());
1735 let msg = result.unwrap_err().to_string();
1736 assert!(
1737 msg.contains("No execution plan") && msg.contains("nonexistent"),
1738 "missing spec must report no plan, got: {msg}"
1739 );
1740 }
1741
1742 #[test]
1743 fn test_multiple_specs() {
1744 let mut engine = Engine::new();
1745 engine
1746 .load([(
1747 SourceType::Path(Arc::new(std::path::PathBuf::from("spec 1.lemma"))),
1748 r#"
1749 spec spec1
1750 data x: 10
1751 rule result: x * 2
1752 "#
1753 .to_string(),
1754 )])
1755 .unwrap();
1756
1757 engine
1758 .load([(
1759 SourceType::Path(Arc::new(std::path::PathBuf::from("spec 2.lemma"))),
1760 r#"
1761 spec spec2
1762 data y: 5
1763 rule result: y * 3
1764 "#
1765 .to_string(),
1766 )])
1767 .unwrap();
1768
1769 let now = DateTimeValue::now();
1770 let response1 = engine
1771 .run(None, "spec1", Some(&now), HashMap::new(), None, false)
1772 .unwrap();
1773 assert_eq!(
1774 response1.results[0].result().expect("result").to_string(),
1775 "20"
1776 );
1777 let response2 = engine
1778 .run(None, "spec2", Some(&now), HashMap::new(), None, false)
1779 .unwrap();
1780 assert_eq!(
1781 response2.results[0].result().expect("result").to_string(),
1782 "15"
1783 );
1784 }
1785
1786 #[test]
1787 fn test_runtime_error_mapping() {
1788 let mut engine = Engine::new();
1789 engine
1790 .load([(
1791 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1792 r#"
1793 spec test
1794 data numerator: 10
1795 data denominator: 0
1796 rule division: numerator / denominator
1797 "#
1798 .to_string(),
1799 )])
1800 .unwrap();
1801
1802 let now = DateTimeValue::now();
1803 let result = engine.run(None, "test", Some(&now), HashMap::new(), None, false);
1804 assert!(result.is_ok(), "Evaluation should succeed");
1806 let response = result.unwrap();
1807 let division_result = response
1808 .results
1809 .values()
1810 .find(|r| r.rule.name == "division");
1811 assert!(
1812 division_result.is_some(),
1813 "Should have division rule result"
1814 );
1815 let division = division_result.unwrap();
1816 assert!(division.vetoed);
1817 assert!(
1818 division
1819 .veto_reason
1820 .as_deref()
1821 .unwrap()
1822 .contains("Division by zero"),
1823 "Veto message should mention division by zero: {:?}",
1824 division.veto_reason
1825 );
1826 }
1827
1828 #[test]
1829 fn test_rules_sorted_by_source_order() {
1830 let mut engine = Engine::new();
1831 engine
1832 .load([(
1833 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1834 r#"
1835 spec test
1836 data a: 1
1837 data b: 2
1838 rule z: a + b
1839 rule y: a * b
1840 rule x: a - b
1841 "#
1842 .to_string(),
1843 )])
1844 .unwrap();
1845
1846 let now = DateTimeValue::now();
1847 let response = engine
1848 .run(None, "test", Some(&now), HashMap::new(), None, false)
1849 .unwrap();
1850 assert_eq!(response.results.len(), 3);
1851
1852 let z_pos = response
1854 .results
1855 .values()
1856 .find(|r| r.rule.name == "z")
1857 .unwrap()
1858 .rule
1859 .source_location
1860 .span
1861 .start;
1862 let y_pos = response
1863 .results
1864 .values()
1865 .find(|r| r.rule.name == "y")
1866 .unwrap()
1867 .rule
1868 .source_location
1869 .span
1870 .start;
1871 let x_pos = response
1872 .results
1873 .values()
1874 .find(|r| r.rule.name == "x")
1875 .unwrap()
1876 .rule
1877 .source_location
1878 .span
1879 .start;
1880
1881 assert!(z_pos < y_pos);
1882 assert!(y_pos < x_pos);
1883 }
1884
1885 #[test]
1886 fn test_rule_filtering_evaluates_dependencies() {
1887 let mut engine = Engine::new();
1888 engine
1889 .load([(
1890 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1891 r#"
1892 spec test
1893 data base: 100
1894 rule subtotal: base * 2
1895 rule tax: subtotal * 10%
1896 rule total: subtotal + tax
1897 "#
1898 .to_string(),
1899 )])
1900 .unwrap();
1901
1902 let now = DateTimeValue::now();
1903 let response = engine
1904 .run(
1905 None,
1906 "test",
1907 Some(&now),
1908 HashMap::new(),
1909 Some(&["total".to_string()]),
1910 false,
1911 )
1912 .unwrap();
1913
1914 assert_eq!(response.results.len(), 1);
1915 assert_eq!(response.results.keys().next().unwrap(), "total");
1916
1917 let total = response.results.values().next().unwrap();
1919 assert_eq!(total.result().expect("result").to_string(), "220");
1920 }
1921
1922 use crate::parsing::ast::DateTimeValue;
1927
1928 #[test]
1929 fn pre_resolved_deps_in_file_map_evaluates_external_spec() {
1930 let mut engine = Engine::new();
1931
1932 engine
1933 .load([(
1934 SourceType::Dependency("@org/project".to_string()),
1935 "repo @org/project\nspec helper\ndata quantity: 42".to_string(),
1936 )])
1937 .expect("should load dependency files");
1938
1939 engine
1940 .load([(
1941 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1942 r#"spec main_spec
1943uses external: @org/project helper
1944rule value: external.quantity"#
1945 .to_string(),
1946 )])
1947 .expect("should succeed with pre-resolved deps");
1948
1949 let now = DateTimeValue::now();
1950 let response = engine
1951 .run(None, "main_spec", Some(&now), HashMap::new(), None, false)
1952 .expect("evaluate should succeed");
1953
1954 let value_result = response
1955 .results
1956 .get("value")
1957 .expect("rule 'value' should exist");
1958 assert_eq!(value_result.result().expect("result").to_string(), "42");
1959 }
1960
1961 #[test]
1962 fn show_with_repo_resolves_registry_spec() {
1963 let mut engine = Engine::new();
1964 engine
1965 .load([(
1966 SourceType::Dependency("@org/project".to_string()),
1967 "repo @org/project\nspec helper\ndata quantity: 42\nrule expose: quantity"
1968 .to_string(),
1969 )])
1970 .expect("registry bundle loads");
1971
1972 engine
1973 .load([(
1974 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1975 r#"spec main_spec
1976data x: 1"#
1977 .to_string(),
1978 )])
1979 .expect("main loads");
1980
1981 let now = DateTimeValue::now();
1982 let view = engine
1983 .show(Some("@org/project"), "helper", Some(&now))
1984 .expect("show for registry spec");
1985 assert!(view.data.contains_key("quantity"));
1986 }
1987
1988 #[test]
1989 fn load_no_external_refs_works() {
1990 let mut engine = Engine::new();
1991
1992 engine
1993 .load([(
1994 SourceType::Path(Arc::new(std::path::PathBuf::from("local.lemma"))),
1995 r#"spec local_only
1996data price: 100
1997rule doubled: price * 2"#
1998 .to_string(),
1999 )])
2000 .expect("should succeed when there are no @... references");
2001
2002 let now = DateTimeValue::now();
2003 let response = engine
2004 .run(None, "local_only", Some(&now), HashMap::new(), None, false)
2005 .expect("evaluate should succeed");
2006
2007 let doubled = response.results.get("doubled").expect("doubled rule");
2008 assert_eq!(doubled.result().expect("result").to_string(), "200");
2009 }
2010
2011 #[test]
2012 fn unresolved_external_ref_without_deps_fails() {
2013 let mut engine = Engine::new();
2014
2015 let result = engine.load([(
2016 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
2017 r#"spec main_spec
2018uses external: @org/project missing
2019rule value: external.quantity"#
2020 .to_string(),
2021 )]);
2022
2023 let errs = result.expect_err("Should fail when registry dep is not loaded");
2024 assert!(
2025 errs.iter()
2026 .any(|e| e.kind() == crate::ErrorKind::MissingRepository),
2027 "expected MissingRepository, got: {:?}",
2028 errs.iter().map(|e| e.kind()).collect::<Vec<_>>()
2029 );
2030 }
2031
2032 #[test]
2033 fn pre_resolved_deps_with_spec_and_type_refs() {
2034 let mut engine = Engine::new();
2035
2036 engine
2037 .load([(
2038 SourceType::Dependency("@org/example".to_string()),
2039 "repo @org/example\nspec helper\ndata value: 42".to_string(),
2040 )])
2041 .expect("should load helper file");
2042
2043 engine
2044 .load([(
2045 SourceType::Dependency("@iso/countries".to_string()),
2046 "repo @iso/countries\nspec alpha2\ndata code: text\n -> option \"NL\"\n -> option \"BE\"".to_string(),
2047 )])
2048 .expect("should load alpha2 file");
2049
2050 engine
2051 .load([(
2052 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
2053 r#"spec registry_demo
2054uses @iso/countries alpha2
2055data country: alpha2.code
2056data unit_count: 5
2057uses @org/example helper
2058rule helper_value: helper.value
2059rule line_total: unit_count * 2
2060rule formatted: helper_value + 0"#
2061 .to_string(),
2062 )])
2063 .expect("should succeed with pre-resolved spec and type deps");
2064
2065 let now = DateTimeValue::now();
2066 let response = engine
2067 .run(
2068 None,
2069 "registry_demo",
2070 Some(&now),
2071 HashMap::new(),
2072 None,
2073 false,
2074 )
2075 .expect("evaluate should succeed");
2076
2077 assert_eq!(
2078 response
2079 .results
2080 .get("helper_value")
2081 .expect("helper_value")
2082 .result()
2083 .expect("result"),
2084 "42"
2085 );
2086 let line = response
2087 .results
2088 .get("line_total")
2089 .expect("line_total")
2090 .result()
2091 .expect("result");
2092 assert_eq!(line, "10");
2093 assert_eq!(
2094 response
2095 .results
2096 .get("formatted")
2097 .expect("formatted")
2098 .result()
2099 .expect("result"),
2100 "42"
2101 );
2102 }
2103
2104 #[test]
2105 fn load_empty_labeled_source_is_error() {
2106 let mut engine = Engine::new();
2107 let err = engine
2108 .load([(
2109 SourceType::Path(Arc::new(std::path::PathBuf::from(" "))),
2110 "spec x\ndata a: 1".to_string(),
2111 )])
2112 .unwrap_err();
2113 assert!(err.errors.iter().any(|e| e.message().contains("non-empty")));
2114 }
2115
2116 #[test]
2117 fn add_dependency_files_accepts_registry_bundle_specs() {
2118 let mut engine = Engine::new();
2119 engine
2120 .load([(
2121 SourceType::Dependency("@org/my".to_string()),
2122 "repo @org/my\nspec helper\ndata x: 1".to_string(),
2123 )])
2124 .expect("dependency bundle specs should be accepted");
2125 }
2126
2127 #[test]
2128 fn user_load_rejects_reserved_embedded_stdlib_repository() {
2129 let mut engine = Engine::new();
2130 let batch = engine.load([(
2131 SourceType::Dependency(EMBEDDED_STDLIB_REPOSITORY.to_string()),
2132 "spec finance\ndata money: ratio -> decimals 2".to_string(),
2133 )]);
2134 assert!(
2135 batch.is_err(),
2136 "load must not write reserved lemma stdlib repo"
2137 );
2138 let msg = batch
2139 .unwrap_err()
2140 .errors
2141 .iter()
2142 .map(ToString::to_string)
2143 .collect::<Vec<_>>()
2144 .join("\n");
2145 assert!(
2146 msg.contains(EMBEDDED_STDLIB_REPOSITORY) && msg.contains("reserved"),
2147 "expected reserved-repo error, got: {msg}"
2148 );
2149
2150 let workspace = engine.load([(
2151 SourceType::Volatile,
2152 "repo lemma\nspec x\ndata a: 1".to_string(),
2153 )]);
2154 assert!(workspace.is_err(), "workspace repo lemma must be rejected");
2155 let msg = workspace
2156 .unwrap_err()
2157 .errors
2158 .iter()
2159 .map(ToString::to_string)
2160 .collect::<Vec<_>>()
2161 .join("\n");
2162 assert!(
2163 msg.contains(EMBEDDED_STDLIB_REPOSITORY) && msg.contains("reserved"),
2164 "expected reserved-repo error, got: {msg}"
2165 );
2166 }
2167
2168 #[test]
2169 fn load_returns_all_errors_not_just_first() {
2170 let mut engine = Engine::new();
2171
2172 let result = engine.load([(
2173 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
2174 r#"spec demo
2175uses type_src: nonexistent_type_source
2176 -> with amount: 10
2177uses helper: nonexistent_spec
2178data price: 10
2179rule total: helper.value + price"#
2180 .to_string(),
2181 )]);
2182
2183 assert!(result.is_err(), "Should fail with multiple errors");
2184 let load_err = result.unwrap_err();
2185 assert!(
2186 load_err.errors.len() >= 2,
2187 "expected at least 2 errors (type + spec ref), got {}",
2188 load_err.errors.len()
2189 );
2190 let error_message = load_err
2191 .errors
2192 .iter()
2193 .map(ToString::to_string)
2194 .collect::<Vec<_>>()
2195 .join("; ");
2196
2197 assert!(
2198 error_message.contains("nonexistent_type_source"),
2199 "Should mention data import source spec. Got:\n{}",
2200 error_message
2201 );
2202 assert!(
2203 error_message.contains("nonexistent_spec"),
2204 "Should mention spec reference error about 'nonexistent_spec'. Got:\n{}",
2205 error_message
2206 );
2207 }
2208
2209 #[test]
2215 fn planning_rejects_invalid_number_default() {
2216 let mut engine = Engine::new();
2217 let result = engine.load([(
2218 SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
2219 "spec t\ndata x: number -> suggest \"10 $$\"]\nrule r: x".to_string(),
2220 )]);
2221 assert!(
2222 result.is_err(),
2223 "must reject non-numeric suggestion on number type"
2224 );
2225 }
2226
2227 #[test]
2228 fn planning_rejects_text_literal_as_number_default() {
2229 let mut engine = Engine::new();
2234 let result = engine.load([(
2235 SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
2236 "spec t\ndata x: number -> suggest \"10\"]\nrule r: x".to_string(),
2237 )]);
2238 assert!(
2239 result.is_err(),
2240 "must reject text literal \"10\" as suggestion for number type"
2241 );
2242 }
2243
2244 #[test]
2245 fn planning_rejects_invalid_boolean_default() {
2246 let mut engine = Engine::new();
2247 let result = engine.load([(
2248 SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
2249 "spec t\ndata x: [boolean -> suggest \"maybe\"]\nrule r: x".to_string(),
2250 )]);
2251 assert!(
2252 result.is_err(),
2253 "must reject non-boolean suggestion on boolean type"
2254 );
2255 }
2256
2257 #[test]
2258 fn planning_rejects_invalid_named_type_default() {
2259 let mut engine = Engine::new();
2261 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())]);
2262 assert!(
2263 result.is_err(),
2264 "must reject non-numeric suggestion on named number type"
2265 );
2266 }
2267
2268 #[test]
2269 fn context_merges_cross_file_repo_identities() {
2270 let mut engine = Engine::new();
2271
2272 engine
2274 .load([(
2275 SourceType::Path(Arc::new(std::path::PathBuf::from("file1.lemma"))),
2276 "repo shared\nspec a\ndata x: 1".to_string(),
2277 )])
2278 .expect("first file should load");
2279
2280 engine
2281 .load([(
2282 SourceType::Path(Arc::new(std::path::PathBuf::from("file2.lemma"))),
2283 "repo shared\nspec b\ndata y: 2".to_string(),
2284 )])
2285 .expect("second file should load");
2286
2287 assert_eq!(
2290 engine.context.repositories().len(),
2291 3,
2292 "should have workspace, stdlib repository, and one named user repository"
2293 );
2294
2295 let shared_repo = engine
2296 .context
2297 .find_repository("shared")
2298 .expect("shared repo should exist");
2299 let shared_specs = engine.context.repositories().get(&shared_repo).unwrap();
2300 assert_eq!(
2301 shared_specs.len(),
2302 2,
2303 "shared repo should contain both specs"
2304 );
2305 assert!(shared_specs.contains_key("a"));
2306 assert!(shared_specs.contains_key("b"));
2307
2308 let _result = engine.load([(
2310 SourceType::Dependency("@some/dep".to_string()),
2311 "repo shared\nspec c\ndata z: 3".to_string(),
2312 )]);
2313
2314 let result = engine.load([(
2315 SourceType::Path(Arc::new(std::path::PathBuf::from("file2.lemma"))),
2316 "repo shared\nspec a\ndata y: 2".to_string(),
2317 )]);
2318
2319 assert!(
2320 result.is_err(),
2321 "should reject duplicate spec name in same repo"
2322 );
2323 let load_err = result.unwrap_err();
2324 assert_eq!(
2325 load_err.errors.len(),
2326 2,
2327 "duplicate spec must error on both declaring sources, got: {:?}",
2328 load_err.errors
2329 );
2330 let joined = load_err
2331 .errors
2332 .iter()
2333 .map(|e| e.to_string())
2334 .collect::<Vec<_>>()
2335 .join("\n");
2336 assert!(
2337 joined.contains("Duplicate spec 'a'"),
2338 "error should mention duplicate spec, got: {joined}"
2339 );
2340 let paths: Vec<String> = load_err
2341 .errors
2342 .iter()
2343 .map(|err| {
2344 err.location()
2345 .expect("duplicate errors must have source")
2346 .source_type
2347 .to_string()
2348 })
2349 .collect();
2350 assert!(
2351 paths.iter().any(|p| p == "file1.lemma"),
2352 "first declaring file must get conflict diagnostic, got paths: {paths:?}"
2353 );
2354 assert!(
2355 paths.iter().any(|p| p == "file2.lemma"),
2356 "incoming file must get conflict diagnostic, got paths: {paths:?}"
2357 );
2358 }
2359
2360 #[test]
2361 fn test_list_structure() {
2362 let mut engine = Engine::new();
2363 engine
2364 .load([(
2365 SourceType::Path(Arc::new(std::path::PathBuf::from("file1.lemma"))),
2366 "repo shared\nspec a\ndata x: 1\nrule r: x".to_string(),
2367 )])
2368 .expect("file should load");
2369
2370 let repos = engine.list();
2371 let shared_repo = repos
2372 .iter()
2373 .find(|r| r.repository.as_deref() == Some("shared"))
2374 .expect("shared repo in list");
2375 assert_eq!(shared_repo.specs.len(), 1);
2376 assert_eq!(shared_repo.specs[0].name, "a");
2377 }
2378
2379 fn path_st(name: &str) -> SourceType {
2380 SourceType::Path(Arc::new(std::path::PathBuf::from(name)))
2381 }
2382
2383 #[test]
2384 fn remove_none_removes_version_active_at_now() {
2385 let mut engine = Engine::new();
2386 engine
2387 .load([(
2388 path_st("t.lemma"),
2389 "spec t\ndata v: 1\nrule r: v\n\nspec t 2099-01-01\ndata v: 2\nrule r: v\n"
2390 .to_string(),
2391 )])
2392 .expect("load");
2393 engine
2394 .remove(None, "t", None)
2395 .expect("remove active-at now (origin while before 2099)");
2396 let workspace = engine
2397 .list()
2398 .into_iter()
2399 .find(|r| r.repository.is_none())
2400 .expect("workspace");
2401 assert_eq!(workspace.specs.iter().filter(|s| s.name == "t").count(), 1);
2402 assert!(
2403 workspace
2404 .specs
2405 .iter()
2406 .any(|s| s.name == "t" && s.effective_from.is_some()),
2407 "dated version must remain"
2408 );
2409 }
2410
2411 #[test]
2412 fn remove_none_errors_when_no_version_active_at_now() {
2413 let mut engine = Engine::new();
2414 engine
2415 .load([(
2416 path_st("t.lemma"),
2417 "spec t 2099-01-01\ndata v: 1\nrule r: v\n".to_string(),
2418 )])
2419 .expect("load");
2420 let err = engine
2421 .remove(None, "t", None)
2422 .expect_err("no version active at now");
2423 assert_eq!(err.kind(), crate::ErrorKind::Request);
2424 assert!(
2425 engine.show(None, "t", Some(&date(2099, 1, 1))).is_ok(),
2426 "future version must remain"
2427 );
2428 }
2429
2430 #[test]
2431 fn update_origin_code_keeps_later_version() {
2432 let mut engine = Engine::new();
2433 let st = path_st("t.lemma");
2434 engine
2435 .load([(
2436 st.clone(),
2437 "spec t\ndata v: 1\nrule r: v\n\nspec t 2099-01-01\ndata v: 2\nrule r: v\n"
2438 .to_string(),
2439 )])
2440 .expect("load");
2441 engine
2442 .update(
2443 None,
2444 "spec t\ndata v: 9\nrule r: v\n\nspec t 2099-01-01\ndata v: 2\nrule r: v\n"
2445 .to_string(),
2446 st,
2447 )
2448 .expect("update origin body while keeping later version in buffer");
2449 let workspace = engine
2450 .list()
2451 .into_iter()
2452 .find(|r| r.repository.is_none())
2453 .expect("workspace");
2454 assert_eq!(workspace.specs.iter().filter(|s| s.name == "t").count(), 2);
2455 let now = DateTimeValue::now();
2456 let response = engine
2457 .run(None, "t", Some(&now), HashMap::new(), None, false)
2458 .expect("run origin");
2459 assert_eq!(
2460 response.results.get("r").and_then(|r| r.result()),
2461 Some("9")
2462 );
2463 }
2464
2465 #[test]
2466 fn update_path_prunes_dropped_version() {
2467 let mut engine = Engine::new();
2468 let st = path_st("t.lemma");
2469 engine
2470 .load([(
2471 st.clone(),
2472 "spec t\ndata v: 1\nrule r: v\n\nspec t 2025-06-01\ndata v: 2\nrule r: v\n"
2473 .to_string(),
2474 )])
2475 .expect("load");
2476 engine
2477 .update(None, "spec t\ndata v: 1\nrule r: v\n".to_string(), st)
2478 .expect("prune second version");
2479 let workspace = engine
2480 .list()
2481 .into_iter()
2482 .find(|r| r.repository.is_none())
2483 .expect("workspace");
2484 assert_eq!(workspace.specs.iter().filter(|s| s.name == "t").count(), 1);
2485 assert!(workspace
2486 .specs
2487 .iter()
2488 .any(|s| s.name == "t" && s.effective_from.is_none()));
2489 }
2490
2491 #[test]
2492 fn update_volatile_does_not_prune_sibling() {
2493 let mut engine = Engine::new();
2494 engine
2495 .load([(
2496 SourceType::Volatile,
2497 "spec a\ndata v: 1\nrule r: v\n\nspec b\ndata v: 2\nrule r: v\n".to_string(),
2498 )])
2499 .expect("load");
2500 engine
2501 .update(
2502 None,
2503 "spec a\ndata v: 3\nrule r: v\n".to_string(),
2504 SourceType::Volatile,
2505 )
2506 .expect("update a");
2507 let workspace = engine
2508 .list()
2509 .into_iter()
2510 .find(|r| r.repository.is_none())
2511 .expect("workspace");
2512 assert!(workspace.specs.iter().any(|s| s.name == "a"));
2513 assert!(workspace.specs.iter().any(|s| s.name == "b"));
2514 }
2515
2516 #[test]
2517 fn update_upserts_new_identity() {
2518 let mut engine = Engine::new();
2519 let st = path_st("t.lemma");
2520 engine
2521 .load([(st.clone(), "spec a\ndata v: 1\nrule r: v\n".to_string())])
2522 .expect("load");
2523 engine
2524 .update(
2525 None,
2526 "spec a\ndata v: 1\nrule r: v\n\nspec b\ndata v: 2\nrule r: v\n".to_string(),
2527 st,
2528 )
2529 .expect("upsert b");
2530 let workspace = engine
2531 .list()
2532 .into_iter()
2533 .find(|r| r.repository.is_none())
2534 .expect("workspace");
2535 assert!(workspace.specs.iter().any(|s| s.name == "a"));
2536 assert!(workspace.specs.iter().any(|s| s.name == "b"));
2537 }
2538
2539 #[test]
2540 fn update_cross_path_identity_is_error() {
2541 let mut engine = Engine::new();
2542 engine
2543 .load([(
2544 path_st("a.lemma"),
2545 "spec conflict\ndata v: 1\nrule r: v\n".to_string(),
2546 )])
2547 .expect("load a");
2548 let err = engine
2549 .update(
2550 None,
2551 "spec conflict\ndata v: 2\nrule r: v\n".to_string(),
2552 path_st("b.lemma"),
2553 )
2554 .expect_err("cross-path identity");
2555 let joined: String = err
2556 .errors
2557 .iter()
2558 .map(|e| e.to_string())
2559 .collect::<Vec<_>>()
2560 .join("\n");
2561 assert!(
2562 joined.contains("Duplicate spec") && joined.contains("also declared"),
2563 "expected duplicate across paths, got: {joined}"
2564 );
2565 assert!(
2566 engine.show(None, "conflict", None).is_ok(),
2567 "failed cross-path update must leave the original identity loaded"
2568 );
2569 }
2570
2571 #[test]
2572 fn update_empty_dependency_prunes_all_specs_of_source() {
2573 let mut engine = Engine::new();
2574 let st = SourceType::Dependency("@org/dep".to_string());
2575 engine
2576 .load([(
2577 st.clone(),
2578 "repo @org/dep\n\nspec a\ndata v: 1\nrule r: v\n\nspec b\ndata v: 2\nrule r: v\n"
2579 .to_string(),
2580 )])
2581 .expect("load");
2582 engine
2583 .update(None, " \n".to_string(), st)
2584 .expect("empty dependency update prunes");
2585 let listed = engine.list();
2586 assert!(
2587 !listed.iter().any(|r| {
2588 r.repository.as_deref() == Some("@org/dep")
2589 && r.specs.iter().any(|s| s.name == "a" || s.name == "b")
2590 }),
2591 "empty dependency update must remove every live row of that source"
2592 );
2593 }
2594
2595 #[test]
2596 fn failed_apply_leaves_list_source_and_run_unchanged() {
2597 let mut engine = Engine::new();
2598 engine
2599 .load([(
2600 SourceType::Volatile,
2601 "spec price\ndata amount: number\nrule total: amount\n".to_string(),
2602 )])
2603 .expect("load");
2604 let before_list = engine.list();
2605 let before_source = engine.source(None, Some("price"), None).expect("source");
2606 let now = DateTimeValue::now();
2607 let before = engine
2608 .run(
2609 None,
2610 "price",
2611 Some(&now),
2612 [("amount".to_string(), "4".to_string())]
2613 .into_iter()
2614 .collect(),
2615 None,
2616 false,
2617 )
2618 .expect("run");
2619 let failed = engine.load([(
2620 SourceType::Volatile,
2621 "spec price\ndata amount: number\nrule total: amount +\n".to_string(),
2622 )]);
2623 assert!(failed.is_err(), "invalid spec must fail planning");
2624 assert_eq!(engine.list(), before_list);
2625 assert_eq!(
2626 engine.source(None, Some("price"), None).expect("source"),
2627 before_source
2628 );
2629 let after = engine
2630 .run(
2631 None,
2632 "price",
2633 Some(&now),
2634 [("amount".to_string(), "4".to_string())]
2635 .into_iter()
2636 .collect(),
2637 None,
2638 false,
2639 )
2640 .expect("run after failed load");
2641 assert_eq!(
2642 after.results.get("total").expect("total").result,
2643 before.results.get("total").expect("total").result
2644 );
2645 }
2646
2647 #[test]
2648 fn update_empty_path_prunes_all_specs_of_source() {
2649 let mut engine = Engine::new();
2650 let st = path_st("t.lemma");
2651 engine
2652 .load([(
2653 st.clone(),
2654 "spec a\ndata v: 1\nrule r: v\n\nspec b\ndata v: 2\nrule r: v\n".to_string(),
2655 )])
2656 .expect("load");
2657 engine
2658 .update(None, " \n".to_string(), st)
2659 .expect("empty path update prunes");
2660 let workspace = engine
2661 .list()
2662 .into_iter()
2663 .find(|r| r.repository.is_none())
2664 .expect("workspace");
2665 assert!(
2666 !workspace
2667 .specs
2668 .iter()
2669 .any(|s| s.name == "a" || s.name == "b"),
2670 "empty path update must remove every live row of that source"
2671 );
2672 }
2673
2674 #[test]
2675 fn update_empty_volatile_is_error() {
2676 let mut engine = Engine::new();
2677 engine
2678 .load([(
2679 SourceType::Volatile,
2680 "spec a\ndata v: 1\nrule r: v\n".to_string(),
2681 )])
2682 .expect("load");
2683 let err = engine
2684 .update(None, " \n".to_string(), SourceType::Volatile)
2685 .expect_err("empty volatile update");
2686 assert!(
2687 err.errors
2688 .iter()
2689 .any(|e| e.to_string().contains("at least one spec")),
2690 "got: {:?}",
2691 err.errors
2692 );
2693 assert!(
2694 engine.show(None, "a", None).is_ok(),
2695 "failed empty volatile update must leave the original identity loaded"
2696 );
2697 }
2698
2699 #[test]
2700 fn update_repository_param_mismatch_is_error() {
2701 let mut engine = Engine::new();
2702 let st = path_st("t.lemma");
2703 engine
2704 .load([(
2705 st.clone(),
2706 "repo other\nspec a\ndata v: 1\nrule r: v\n".to_string(),
2707 )])
2708 .expect("load");
2709 let err = engine
2710 .update(
2711 Some("expected"),
2712 "repo other\nspec a\ndata v: 2\nrule r: v\n".to_string(),
2713 st,
2714 )
2715 .expect_err("mismatch");
2716 assert!(
2717 err.errors
2718 .iter()
2719 .any(|e| e.to_string().contains("does not match")),
2720 "got: {:?}",
2721 err.errors
2722 );
2723 }
2724
2725 #[test]
2726 fn update_rollback_restores_pruned_and_replaced() {
2727 let mut engine = Engine::new();
2728 let dep = path_st("dep.lemma");
2729 let consumer = path_st("consumer.lemma");
2730 engine
2731 .load([
2732 (
2733 dep.clone(),
2734 "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: 2\nrule r: v\n"
2735 .to_string(),
2736 ),
2737 (
2738 consumer,
2739 "spec consumer\nuses d: dep\nrule out: d.r\n".to_string(),
2740 ),
2741 ])
2742 .expect("load");
2743 let before = engine.list();
2744 let err = engine
2745 .update(
2746 None,
2747 "spec dep\ndata other: 5\nrule unrelated: other\n".to_string(),
2748 dep,
2749 )
2750 .expect_err("consumer must break");
2751 assert!(!err.errors.is_empty());
2752 let after = engine.list();
2753 assert_eq!(after.len(), before.len(), "repository count must match");
2754 for (before_repo, after_repo) in before.iter().zip(after.iter()) {
2755 assert_eq!(before_repo.repository, after_repo.repository);
2756 let mut before_specs = before_repo.specs.clone();
2757 let mut after_specs = after_repo.specs.clone();
2758 before_specs
2759 .sort_by(|a, b| (&a.name, &a.effective_from).cmp(&(&b.name, &b.effective_from)));
2760 after_specs
2761 .sort_by(|a, b| (&a.name, &a.effective_from).cmp(&(&b.name, &b.effective_from)));
2762 assert_eq!(
2763 before_specs, after_specs,
2764 "failed update must restore listed specs for {:?}",
2765 before_repo.repository
2766 );
2767 }
2768 let now = DateTimeValue::now();
2769 engine
2770 .run(None, "consumer", Some(&now), HashMap::new(), None, false)
2771 .expect("consumer still runs after rollback");
2772 assert!(
2773 engine.show(None, "dep", Some(&date(2025, 6, 1))).is_ok(),
2774 "pruned later dep version must be restored"
2775 );
2776 }
2777
2778 #[test]
2779 fn update_identical_bytes_succeeds() {
2780 let mut engine = Engine::new();
2781 let st = path_st("t.lemma");
2782 let code = "spec t\ndata v: 1\nrule r: v\n".to_string();
2783 engine.load([(st.clone(), code.clone())]).expect("load");
2784 let before = engine.show(None, "t", None).expect("show before");
2785 engine.update(None, code, st).expect("identical update");
2786 let after = engine.show(None, "t", None).expect("show after");
2787 assert_eq!(before.meta, after.meta);
2788 assert_eq!(
2789 before.rules.keys().collect::<Vec<_>>(),
2790 after.rules.keys().collect::<Vec<_>>()
2791 );
2792 }
2793
2794 #[test]
2797 fn update_slice_mode_keeps_consumer_overlapping_sibling_version() {
2798 let mut engine = Engine::new();
2799 let dep = path_st("dep.lemma");
2800 let consumer = path_st("consumer.lemma");
2801 engine
2802 .load([
2803 (
2804 dep.clone(),
2805 "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: 2\nrule r: v\n"
2806 .to_string(),
2807 ),
2808 (
2809 consumer,
2810 "spec consumer\nuses d: dep\nrule out: d.r\n\nspec consumer 2025-06-01\nuses d: dep\nrule out: d.r\n"
2811 .to_string(),
2812 ),
2813 ])
2814 .expect("load");
2815 engine
2816 .update(
2817 None,
2818 "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: 9\nrule r: v\n"
2819 .to_string(),
2820 dep,
2821 )
2822 .expect("body-only edit of later dep version must keep consumers valid");
2823 let before_breakpoint = date(2025, 1, 15);
2824 let response = engine
2825 .run(
2826 None,
2827 "consumer",
2828 Some(&before_breakpoint),
2829 HashMap::new(),
2830 None,
2831 false,
2832 )
2833 .expect("origin consumer still runs against non-dirty dep version");
2834 assert_eq!(
2835 response.results.get("out").and_then(|r| r.result()),
2836 Some("1")
2837 );
2838 let after_breakpoint = date(2025, 7, 1);
2839 let response = engine
2840 .run(
2841 None,
2842 "consumer",
2843 Some(&after_breakpoint),
2844 HashMap::new(),
2845 None,
2846 false,
2847 )
2848 .expect("later consumer runs against dirty dep version");
2849 assert_eq!(
2850 response.results.get("out").and_then(|r| r.result()),
2851 Some("9")
2852 );
2853 }
2854
2855 #[test]
2858 fn update_slice_mode_rejects_interface_drift_vs_sibling_version() {
2859 let mut engine = Engine::new();
2860 let dep = path_st("dep.lemma");
2861 let consumer = path_st("consumer.lemma");
2862 engine
2863 .load([
2864 (
2865 dep.clone(),
2866 "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: 2\nrule r: v\n"
2867 .to_string(),
2868 ),
2869 (
2870 consumer.clone(),
2871 "spec consumer\nuses d: dep\nrule out: d.r\n".to_string(),
2872 ),
2873 ])
2874 .expect("load");
2875 let err = engine
2876 .update(
2877 None,
2878 "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: \"x\"\nrule r: v\n"
2879 .to_string(),
2880 dep.clone(),
2881 )
2882 .expect_err("drift between dep versions must fail incremental update");
2883 assert!(
2884 err.errors.iter().any(|e| {
2885 let msg = e.to_string();
2886 msg.contains("interface") || msg.contains("changed")
2887 }),
2888 "expected interface-drift error, got: {:?}",
2889 err.errors
2890 );
2891
2892 let mut cold = Engine::new();
2893 let cold_err = cold
2894 .load([
2895 (
2896 dep,
2897 "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: \"x\"\nrule r: v\n"
2898 .to_string(),
2899 ),
2900 (
2901 consumer,
2902 "spec consumer\nuses d: dep\nrule out: d.r\n".to_string(),
2903 ),
2904 ])
2905 .expect_err("cold load must also reject the drift");
2906 assert!(
2907 cold_err.errors.iter().any(|e| {
2908 let msg = e.to_string();
2909 msg.contains("interface") || msg.contains("changed")
2910 }),
2911 "expected interface-drift error on cold load, got: {:?}",
2912 cold_err.errors
2913 );
2914 }
2915
2916 #[test]
2919 fn update_slice_mode_planning_error_on_dirty_version_is_error_not_panic() {
2920 let mut engine = Engine::new();
2921 let dep = path_st("dep.lemma");
2922 let consumer = path_st("consumer.lemma");
2923 engine
2924 .load([
2925 (
2926 dep.clone(),
2927 "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: 2\nrule r: v\n"
2928 .to_string(),
2929 ),
2930 (
2931 consumer,
2932 "spec consumer\nuses d: dep\nrule out: d.r\n".to_string(),
2933 ),
2934 ])
2935 .expect("load");
2936 let err = engine
2937 .update(
2938 None,
2939 "spec dep\ndata v: 1\nrule r: v\n\nspec dep 2025-06-01\ndata v: 2\nrule r: v + nope\n"
2940 .to_string(),
2941 dep,
2942 )
2943 .expect_err("planning error on dirty version must be Err, not panic");
2944 assert!(!err.errors.is_empty());
2945 let now = DateTimeValue::now();
2946 engine
2947 .run(None, "consumer", Some(&now), HashMap::new(), None, false)
2948 .expect("failed update must leave previous engine state intact");
2949 }
2950}