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