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