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 std::collections::HashMap;
12use std::sync::Arc;
13
14#[derive(Debug, Clone)]
16pub struct Errors {
17 pub errors: Vec<Error>,
18 pub sources: HashMap<SourceType, String>,
19}
20
21impl Errors {
22 pub fn iter(&self) -> std::slice::Iter<'_, Error> {
24 self.errors.iter()
25 }
26}
27
28pub fn resolve_effective(raw: Option<&str>) -> Result<DateTimeValue, Error> {
33 match raw {
34 Some(s) if !s.trim().is_empty() => s.trim().parse::<DateTimeValue>().map_err(|_| {
35 Error::request(
36 format!(
37 "Invalid effective value '{}'. Expected: YYYY, YYYY-MM, YYYY-MM-DD, or ISO 8601 datetime",
38 s.trim()
39 ),
40 None::<String>,
41 )
42 }),
43 _ => Ok(DateTimeValue::now()),
44 }
45}
46
47pub const EMBEDDED_STDLIB_REPOSITORY: &str = "lemma";
50
51#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
53pub struct ListedSpec {
54 pub name: String,
55 #[serde(skip_serializing_if = "Option::is_none", default)]
56 pub effective_from: Option<DateTimeValue>,
57 #[serde(skip_serializing_if = "Option::is_none", default)]
58 pub effective_to: Option<DateTimeValue>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
63pub struct ResolvedRepository {
64 #[serde(skip_serializing_if = "Option::is_none", default)]
65 pub repository: Option<String>,
66 pub specs: Vec<ListedSpec>,
67}
68
69#[derive(Debug)]
81pub struct Context {
82 repositories: IndexMap<Arc<LemmaRepository>, IndexMap<String, LemmaSpecSet>>,
83 workspace: Arc<LemmaRepository>,
84}
85
86impl Default for Context {
87 fn default() -> Self {
88 Self::new()
89 }
90}
91
92impl Context {
93 pub fn new() -> Self {
95 let workspace = Arc::new(LemmaRepository::new(None));
96 let mut repositories = IndexMap::new();
97 repositories.insert(Arc::clone(&workspace), IndexMap::new());
98 Self {
99 repositories,
100 workspace,
101 }
102 }
103
104 #[must_use]
108 pub fn workspace(&self) -> Arc<LemmaRepository> {
109 Arc::clone(&self.workspace)
110 }
111
112 #[must_use]
114 pub fn find_repository(&self, name: &str) -> Option<Arc<LemmaRepository>> {
115 let probe = Arc::new(LemmaRepository::new(Some(name.to_string())));
116 self.repositories
117 .get_key_value(&probe)
118 .map(|(k, _)| Arc::clone(k))
119 }
120
121 #[must_use]
124 pub fn repositories(&self) -> &IndexMap<Arc<LemmaRepository>, IndexMap<String, LemmaSpecSet>> {
125 &self.repositories
126 }
127
128 #[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
134 pub fn iter(&self) -> impl Iterator<Item = &LemmaSpec> + '_ {
135 self.repositories
136 .values()
137 .flat_map(|m| m.values())
138 .flat_map(|ss| ss.iter_specs())
139 }
140
141 #[must_use]
144 pub fn spec_set(&self, repository: &Arc<LemmaRepository>, name: &str) -> Option<&LemmaSpecSet> {
145 let canonical_name = crate::parsing::ast::ascii_lowercase_logical_name(name.to_string());
146 self.repositories
147 .get(repository)
148 .and_then(|m| m.get(&canonical_name))
149 }
150
151 pub(crate) fn spec_sets_for(
154 &self,
155 repository: &Arc<LemmaRepository>,
156 ) -> impl Iterator<Item = &LemmaSpecSet> + '_ {
157 self.repositories
158 .get(repository)
159 .expect("BUG: repository not in context")
160 .values()
161 }
162
163 pub fn insert_spec(
169 &mut self,
170 repository: Arc<LemmaRepository>,
171 spec: LemmaSpec,
172 ) -> Result<(), Error> {
173 if let Some((existing_repo, _)) = self.repositories.get_key_value(&repository) {
174 if existing_repo.dependency != repository.dependency {
175 let repo_display = repository.name.as_deref().unwrap_or("(main)");
176 let existing_owner = match &existing_repo.dependency {
177 None => "the workspace".to_string(),
178 Some(id) => format!("dependency '{id}'"),
179 };
180 let new_owner = match &repository.dependency {
181 None => "the workspace".to_string(),
182 Some(id) => format!("dependency '{id}'"),
183 };
184 return Err(Error::validation_with_context(
185 format!(
186 "Repository '{repo_display}' was introduced by {existing_owner} but {new_owner} also declares it"
187 ),
188 None,
189 Some("Each dependency's repositories must be unique across all loaded sources"),
190 Some(&spec),
191 None,
192 ));
193 }
194 }
195
196 let entry = self
197 .repositories
198 .entry(Arc::clone(&repository))
199 .or_default();
200 if entry
201 .get(&spec.name)
202 .is_some_and(|ss| ss.get_exact(spec.effective_from()).is_some())
203 {
204 return Err(Error::validation_with_context(
205 format!(
206 "Duplicate spec '{}' (same repository, name and effective_from already in context)",
207 spec.name
208 ),
209 None,
210 None::<String>,
211 Some(&spec),
212 None,
213 ));
214 }
215
216 let name = spec.name.clone();
217 if !entry
218 .entry(name.clone())
219 .or_insert_with(|| LemmaSpecSet::new(repository, name))
220 .insert(spec)
221 {
222 unreachable!("BUG: duplicate effective_from rejected above");
223 }
224 Ok(())
225 }
226
227 pub fn remove_spec(&mut self, repository: &Arc<LemmaRepository>, spec: &LemmaSpec) -> bool {
228 self.remove_spec_by_identity(repository, &spec.name, spec.effective_from())
229 }
230
231 pub fn remove_spec_by_identity(
233 &mut self,
234 repository: &Arc<LemmaRepository>,
235 name: &str,
236 effective_from: Option<&DateTimeValue>,
237 ) -> bool {
238 let Some(inner) = self.repositories.get_mut(repository) else {
239 return false;
240 };
241 let Some(ss) = inner.get_mut(name) else {
242 return false;
243 };
244 if !ss.remove(effective_from) {
245 return false;
246 }
247 if ss.is_empty() {
248 inner.shift_remove(name);
249 }
250 true
251 }
252}
253
254enum Mutation {
261 Remove {
262 repository: Option<String>,
263 spec: String,
264 effective: Option<DateTimeValue>,
265 },
266 Load {
267 source_type: SourceType,
268 code: String,
269 },
270}
271
272pub struct Engine {
282 pub(crate) context: Context,
283 pub(crate) plans: PlanStore,
284 limits: ResourceLimits,
285}
286
287impl Default for Engine {
288 fn default() -> Self {
289 Self::new()
290 }
291}
292
293impl Engine {
294 pub fn new() -> Self {
295 Self::with_limits(ResourceLimits::default())
296 }
297
298 pub fn with_limits(limits: ResourceLimits) -> Self {
299 let mut engine = Self {
300 context: Context::new(),
301 plans: PlanStore::new(),
302 limits,
303 };
304 engine
305 .apply(
306 vec![Mutation::Load {
307 source_type: SourceType::Dependency(EMBEDDED_STDLIB_REPOSITORY.to_string()),
308 code: crate::stdlib::UNITS_LEMMA.to_string(),
309 }],
310 true,
311 )
312 .expect("BUG: embedded stdlib must load");
313 engine
314 }
315
316 pub fn limits(&self) -> &ResourceLimits {
318 &self.limits
319 }
320
321 pub fn load(
327 &mut self,
328 sources: impl IntoIterator<Item = (SourceType, impl Into<String>)>,
329 ) -> Result<(), Errors> {
330 let mutations = sources
331 .into_iter()
332 .map(|(source_type, code)| Mutation::Load {
333 source_type,
334 code: code.into(),
335 })
336 .collect();
337 self.apply(mutations, false)
338 }
339
340 pub fn update(
345 &mut self,
346 repository: Option<&str>,
347 spec: &str,
348 effective: Option<&DateTimeValue>,
349 source_type: SourceType,
350 code: String,
351 ) -> Result<(), Errors> {
352 self.apply(
353 vec![
354 Mutation::Remove {
355 repository: repository.map(str::to_string),
356 spec: spec.to_string(),
357 effective: effective.cloned(),
358 },
359 Mutation::Load { source_type, code },
360 ],
361 false,
362 )
363 }
364
365 pub fn remove(
367 &mut self,
368 repository: Option<&str>,
369 spec: &str,
370 effective: Option<&DateTimeValue>,
371 ) -> Result<(), Error> {
372 self.apply(
373 vec![Mutation::Remove {
374 repository: repository.map(str::to_string),
375 spec: spec.to_string(),
376 effective: effective.cloned(),
377 }],
378 false,
379 )
380 .map_err(|errs| {
381 errs.errors
382 .into_iter()
383 .next()
384 .expect("BUG: apply Errors must contain at least one error")
385 })
386 }
387
388 #[must_use]
392 pub fn list(&self) -> Vec<ResolvedRepository> {
393 self.context
394 .repositories()
395 .iter()
396 .map(|(repo, inner)| {
397 let specs = inner
398 .values()
399 .flat_map(|spec_set| {
400 spec_set
401 .iter_with_ranges()
402 .map(|(spec, from, to)| ListedSpec {
403 name: spec.name.clone(),
404 effective_from: from,
405 effective_to: to,
406 })
407 })
408 .collect();
409 ResolvedRepository {
410 repository: repo.name.clone(),
411 specs,
412 }
413 })
414 .collect()
415 }
416
417 pub fn show(
422 &self,
423 repository: Option<&str>,
424 spec: &str,
425 effective: Option<&DateTimeValue>,
426 ) -> Result<Show, Error> {
427 let effective_dt = self.effective_or_now(effective);
428 let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
429
430 let plan = match self.plans.get_plan(repository, spec, &instant) {
431 Some(plan) => plan,
432 None => {
433 let repository_arc = match repository {
436 Some(q) => self.context.find_repository(q).ok_or_else(|| {
437 Error::request_not_found(
438 format!("Repository '{q}' not loaded"),
439 Some(
440 "List repositories with `lemma list` after loading your workspace",
441 ),
442 )
443 })?,
444 None => self.context.workspace(),
445 };
446 let canonical_name =
447 crate::parsing::ast::ascii_lowercase_logical_name(spec.to_string());
448 let spec_set = self.context.spec_set(&repository_arc, &canonical_name);
449 return match spec_set.and_then(|ss| ss.spec_at(&instant)) {
450 None => Err(self.spec_not_found_in_repository_error(
451 &repository_arc,
452 spec,
453 &effective_dt,
454 )),
455 Some(_) => Err(Error::request_not_found(
456 format!(
457 "No execution plan slice for spec '{spec}' at effective {effective_dt}"
458 ),
459 Some("Ensure sources loaded and planning succeeded".to_string()),
460 )),
461 };
462 }
463 };
464
465 let needed_by_rules = &plan.needed_by_rules;
466 let mut data_entries: Vec<(usize, usize, String, ShowData)> = plan
467 .data
468 .iter()
469 .filter(|(_, data)| {
470 data.schema_type().is_some() && !matches!(data, DataDefinition::Reference { .. })
471 })
472 .filter_map(|(path, data)| {
473 let input_key = path.input_key();
474 let used_by = needed_by_rules.get(&input_key).cloned().unwrap_or_default();
475 if used_by.is_empty() {
476 return None;
477 }
478 let lemma_type = data
479 .schema_type()
480 .expect("BUG: filter above ensured lemma_type is Some")
481 .clone();
482 let display = plan.data_display.get(path);
483 Some((
484 path.segments.len(),
485 data.source().span.start,
486 input_key,
487 ShowData {
488 lemma_type,
489 prefilled: display.and_then(|d| d.prefilled.clone()),
490 suggestion: display.and_then(|d| d.suggestion.clone()),
491 needed_by_rules: used_by,
492 },
493 ))
494 })
495 .collect();
496 data_entries.sort_by_key(|(depth, pos, _, _)| (*depth, *pos));
497
498 let rule_entries: Vec<(String, crate::planning::semantics::LemmaType)> = plan
499 .rules
500 .values()
501 .filter(|rule| rule.path.segments.is_empty())
502 .map(|rule| (rule.name().to_string(), (*rule.rule_type).clone()))
503 .collect();
504
505 Ok(Show {
506 spec: plan.spec_name.clone(),
507 commentary: plan.commentary.clone(),
508 effective_from: plan.effective_from.clone(),
509 effective_to: plan.effective_to.clone(),
510 versions: plan.versions.clone(),
511 start_line: plan.start_line,
512 source_type: plan.source_type.clone(),
513 data: data_entries
514 .into_iter()
515 .map(|(_, _, name, entry)| (name, entry))
516 .collect(),
517 rules: rule_entries.into_iter().collect(),
518 meta: plan.meta.clone(),
519 })
520 }
521
522 pub fn source(
527 &self,
528 repository: Option<&str>,
529 spec: Option<&str>,
530 effective: Option<&DateTimeValue>,
531 ) -> Result<String, Error> {
532 match spec {
533 None => self.format_repository_source(repository),
534 Some(spec_name) => {
535 let effective_dt = self.effective_or_now(effective);
536 let resolved_spec = self.get_spec(spec_name, repository, Some(&effective_dt))?;
537 Ok(crate::formatting::format_spec_refs(&[resolved_spec]))
538 }
539 }
540 }
541
542 pub fn run(
544 &self,
545 repository: Option<&str>,
546 spec: &str,
547 effective: Option<&DateTimeValue>,
548 data: HashMap<String, String>,
549 rules: Option<&[String]>,
550 explain: bool,
551 ) -> Result<Response, Error> {
552 let effective = self.effective_or_now(effective);
553 let instant = EffectiveDate::DateTimeValue(effective.clone());
554
555 let plan = self
556 .plans
557 .get_plan(repository, spec, &instant)
558 .ok_or_else(|| {
559 Error::request_not_found(
560 format!("No execution plan for spec '{spec}' at effective {effective}"),
561 Some("Ensure sources loaded and planning succeeded".to_string()),
562 )
563 })?;
564
565 let response_rules = plan.validated_response_rule_names(rules)?;
566 let data_values: HashMap<String, RunDataValue> = data
567 .into_iter()
568 .map(|(key, value)| (key, RunDataValue::string(value)))
569 .collect();
570 let run_data = RunData::resolve(plan, data_values, &self.limits)?;
571 let now_semantic = crate::planning::semantics::date_time_to_semantic(&effective);
572 let now_literal = crate::planning::semantics::LiteralValue {
573 value: crate::planning::semantics::ValueKind::Date(now_semantic),
574 lemma_type: crate::planning::semantics::primitive_date_arc().clone(),
575 };
576 let evaluator = Evaluator;
577 let mut response =
578 evaluator.evaluate(plan, &run_data, now_literal, &response_rules, explain);
579
580 response.spec_effective_from = plan.effective_from.clone();
581 response.spec_effective_to = plan.effective_to.clone();
582
583 Ok(response)
584 }
585
586 fn format_repository_source(&self, repository: Option<&str>) -> Result<String, Error> {
587 let repo_arc = self.resolve_repository(repository)?;
588 let mut all_specs: Vec<&LemmaSpec> = self
589 .context
590 .spec_sets_for(&repo_arc)
591 .flat_map(|ss| ss.iter_specs())
592 .collect();
593 all_specs.sort_by(|a, b| {
594 a.name
595 .cmp(&b.name)
596 .then_with(|| a.effective_from.cmp(&b.effective_from))
597 });
598 let body = crate::formatting::format_spec_refs(&all_specs);
599 let mut source_text = String::new();
600 if let Some(name) = repo_arc.name.as_deref() {
601 source_text.push_str("repo ");
602 source_text.push_str(name);
603 source_text.push_str("\n\n");
604 }
605 source_text.push_str(&body);
606 Ok(source_text)
607 }
608
609 fn resolve_repository(&self, repository: Option<&str>) -> Result<Arc<LemmaRepository>, Error> {
610 match repository {
611 None => Ok(self.context.workspace()),
612 Some(qualifier) => {
613 let q = qualifier.trim();
614 if q.is_empty() {
615 return Err(Error::request(
616 "Repository qualifier cannot be empty",
617 None::<String>,
618 ));
619 }
620 self.context.find_repository(q).ok_or_else(|| {
621 Error::request_not_found(
622 format!("Repository '{qualifier}' not loaded"),
623 Some(format!(
624 "List repositories with `{}` after loading your workspace",
625 "lemma list"
626 )),
627 )
628 })
629 }
630 }
631 }
632
633 fn spec_not_found_in_repository_error(
634 &self,
635 repository: &LemmaRepository,
636 spec_name: &str,
637 effective: &DateTimeValue,
638 ) -> Error {
639 let repo_label = match &repository.name {
640 Some(n) => n.clone(),
641 None => "(workspace)".to_string(),
642 };
643 Error::request_not_found(
644 format!(
645 "Spec '{spec_name}' not found in repository {repo_label} at effective {effective}",
646 ),
647 Some("Try `lemma list`"),
648 )
649 }
650
651 #[must_use]
653 fn effective_or_now(&self, effective: Option<&DateTimeValue>) -> DateTimeValue {
654 effective.cloned().unwrap_or_else(DateTimeValue::now)
655 }
656
657 fn reserved_stdlib_error(source: Option<crate::parsing::source::Source>) -> Error {
658 Error::validation(
659 format!(
660 "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"
661 ),
662 source,
663 Some(
664 "Load registry dependencies with @owner/repo qualifiers, not the reserved 'lemma' stdlib repository"
665 .to_string(),
666 ),
667 )
668 }
669
670 fn resource_limit_errors(
671 name: &str,
672 limit: impl ToString,
673 actual: impl ToString,
674 hint: &str,
675 sources: IndexMap<SourceType, String>,
676 ) -> Errors {
677 Errors {
678 errors: vec![Error::resource_limit_exceeded(
679 name,
680 limit.to_string(),
681 actual.to_string(),
682 hint,
683 None::<crate::parsing::source::Source>,
684 None,
685 None,
686 )],
687 sources: sources.into_iter().collect(),
688 }
689 }
690
691 fn apply(&mut self, mutations: Vec<Mutation>, embedded_stdlib: bool) -> Result<(), Errors> {
696 let mut sources: IndexMap<SourceType, String> = IndexMap::new();
697 let mut errors: Vec<Error> = Vec::new();
698 let mut to_restore: Vec<(Arc<LemmaRepository>, LemmaSpec)> = Vec::new();
699
700 for mutation in mutations {
701 match mutation {
702 Mutation::Remove {
703 repository,
704 spec,
705 effective,
706 } => {
707 let repo_ref = repository.as_deref();
708 let effective_dt = self.effective_or_now(effective.as_ref());
709 match self.get_spec(&spec, repo_ref, Some(&effective_dt)) {
710 Ok(spec_to_remove) => {
711 let repository_arc = self
712 .resolve_repository(repo_ref)
713 .expect("BUG: get_spec succeeded so repository exists");
714 to_restore.push((repository_arc, spec_to_remove.clone()));
715 }
716 Err(e) => errors.push(e),
717 }
718 }
719 Mutation::Load { source_type, code } => {
720 if sources.insert(source_type.clone(), code).is_some() {
721 return Err(Errors {
722 errors: vec![Error::request(
723 format!("Duplicate source key: {source_type}"),
724 None::<String>,
725 )],
726 sources: sources.into_iter().collect(),
727 });
728 }
729 }
730 }
731 }
732
733 if !errors.is_empty() {
734 return Err(Errors {
735 errors,
736 sources: sources.into_iter().collect(),
737 });
738 }
739
740 for st in sources.keys() {
741 match st {
742 SourceType::Path(p) if p.as_os_str().to_string_lossy().trim().is_empty() => {
743 return Err(Errors {
744 errors: vec![Error::request(
745 "Source path must be non-empty",
746 None::<String>,
747 )],
748 sources: HashMap::new(),
749 });
750 }
751 SourceType::Dependency(id) if id.is_empty() => {
752 return Err(Errors {
753 errors: vec![Error::request(
754 "Dependency source identifier must be non-empty",
755 None::<String>,
756 )],
757 sources: HashMap::new(),
758 });
759 }
760 SourceType::Dependency(id)
761 if !embedded_stdlib && id == EMBEDDED_STDLIB_REPOSITORY =>
762 {
763 return Err(Errors {
764 errors: vec![Self::reserved_stdlib_error(None)],
765 sources: HashMap::new(),
766 });
767 }
768 _ => {}
769 }
770 }
771 if !embedded_stdlib && !sources.is_empty() {
772 let limits = &self.limits;
773 if sources.len() > limits.max_sources {
774 return Err(Self::resource_limit_errors(
775 "max_sources",
776 limits.max_sources,
777 sources.len(),
778 "Reduce the number of paths or sources in one load",
779 sources,
780 ));
781 }
782 let total_loaded_bytes: usize = sources.values().map(|s| s.len()).sum();
783 if total_loaded_bytes > limits.max_loaded_bytes {
784 return Err(Self::resource_limit_errors(
785 "max_loaded_bytes",
786 limits.max_loaded_bytes,
787 total_loaded_bytes,
788 "Load fewer or smaller sources",
789 sources,
790 ));
791 }
792 if let Some(code) = sources
793 .values()
794 .find(|code| code.len() > limits.max_source_size_bytes)
795 {
796 return Err(Self::resource_limit_errors(
797 "max_source_size_bytes",
798 limits.max_source_size_bytes,
799 code.len(),
800 "Use a smaller source text or increase limit",
801 sources,
802 ));
803 }
804 }
805
806 let parse_limits = if embedded_stdlib {
807 &ResourceLimits::default()
808 } else {
809 &self.limits
810 };
811 let mut staged: Vec<(SourceType, Arc<LemmaRepository>, LemmaSpec)> = Vec::new();
812
813 for (source_id, code) in &sources {
814 let dependency = match source_id {
815 SourceType::Dependency(id) => Some(id.as_str()),
816 _ => None,
817 };
818 match parse(code, source_id.clone(), parse_limits) {
819 Ok(result) => {
820 if result.repositories.is_empty() {
821 continue;
822 }
823
824 for (parsed_repo, specs) in result.repositories {
825 let repository_arc = if let Some(dep_id) = dependency {
826 let repo_name = parsed_repo
827 .name
828 .clone()
829 .or_else(|| Some(dep_id.to_string()));
831 Arc::new(
832 LemmaRepository::new(repo_name)
833 .with_dependency(dep_id)
834 .with_start_line(parsed_repo.start_line),
835 )
836 } else {
837 parsed_repo
838 };
839 if !embedded_stdlib
840 && repository_arc.name.as_deref() == Some(EMBEDDED_STDLIB_REPOSITORY)
841 {
842 let source = crate::parsing::source::Source::new(
843 source_id.clone(),
844 crate::parsing::ast::Span {
845 start: 0,
846 end: 0,
847 line: repository_arc.start_line,
848 col: 0,
849 },
850 );
851 errors.push(Self::reserved_stdlib_error(Some(source)));
852 continue;
853 }
854 for spec in specs {
855 staged.push((source_id.clone(), Arc::clone(&repository_arc), spec));
856 }
857 }
858 }
859 Err(e) => errors.push(e),
860 }
861 }
862
863 if !errors.is_empty() {
864 return Err(Errors {
865 errors,
866 sources: sources.into_iter().collect(),
867 });
868 }
869
870 for (repo, spec) in &to_restore {
871 self.context.remove_spec(repo, spec);
872 }
873
874 let mut inserted: Vec<(Arc<LemmaRepository>, String, EffectiveDate)> = Vec::new();
875 for (source_id, repository_arc, spec) in staged {
876 let start_line = spec.start_line;
877 let name = spec.name.clone();
878 let effective_from = spec.effective_from.clone();
879 match self.context.insert_spec(Arc::clone(&repository_arc), spec) {
880 Ok(()) => inserted.push((repository_arc, name, effective_from)),
881 Err(e) => {
882 let source = crate::parsing::source::Source::new(
883 source_id.clone(),
884 crate::parsing::ast::Span {
885 start: 0,
886 end: 0,
887 line: start_line,
888 col: 0,
889 },
890 );
891 errors.push(Error::validation(
892 e.to_string(),
893 Some(source),
894 None::<String>,
895 ));
896 self.rollback_apply(&inserted, &to_restore);
897 return Err(Errors {
898 errors,
899 sources: sources.into_iter().collect(),
900 });
901 }
902 }
903 }
904
905 let result = crate::planning::plan(&self.context, &self.limits);
906 if !result.errors.is_empty() {
907 self.rollback_apply(&inserted, &to_restore);
908 return Err(Errors {
909 errors: result.errors,
910 sources: sources.into_iter().collect(),
911 });
912 }
913
914 self.plans.replace(result.plans);
915 Ok(())
916 }
917
918 fn rollback_apply(
919 &mut self,
920 inserted: &[(Arc<LemmaRepository>, String, EffectiveDate)],
921 removed: &[(Arc<LemmaRepository>, LemmaSpec)],
922 ) {
923 for (repo, inserted_name, inserted_effective) in inserted.iter().rev() {
924 self.context
925 .remove_spec_by_identity(repo, inserted_name, inserted_effective.as_ref());
926 }
927 for (repo, spec) in removed.iter().rev() {
928 self.context
929 .insert_spec(Arc::clone(repo), spec.clone())
930 .expect("BUG: restore removed spec for rollback");
931 }
932 }
933
934 pub(crate) fn get_spec(
938 &self,
939 name: &str,
940 repository: Option<&str>,
941 effective: Option<&DateTimeValue>,
942 ) -> Result<&LemmaSpec, Error> {
943 let effective_dt = self.effective_or_now(effective);
944 let instant = EffectiveDate::DateTimeValue(effective_dt.clone());
945 let repository_arc = match repository {
946 Some(q) => self.context.find_repository(q).ok_or_else(|| {
947 Error::request_not_found(
948 format!("Repository '{q}' not loaded"),
949 Some("List repositories with `lemma list` after loading your workspace"),
950 )
951 })?,
952 None => self.context.workspace(),
953 };
954 let spec_set = self
955 .context
956 .spec_set(&repository_arc, name)
957 .ok_or_else(|| {
958 self.spec_not_found_in_repository_error(&repository_arc, name, &effective_dt)
959 })?;
960 spec_set.spec_at(&instant).ok_or_else(|| {
961 self.spec_not_found_in_repository_error(&repository_arc, name, &effective_dt)
962 })
963 }
964}
965#[cfg(test)]
966mod tests {
967 use super::*;
968
969 fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
970 DateTimeValue {
971 year,
972 month,
973 day,
974 hour: 0,
975 minute: 0,
976 second: 0,
977 microsecond: 0,
978 timezone: None,
979 granularity: crate::literals::DateGranularity::Full,
980 }
981 }
982
983 fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
984 let mut spec = LemmaSpec::new(name.to_string());
985 spec.effective_from = crate::parsing::ast::EffectiveDate::from_option(effective_from);
986 spec
987 }
988
989 #[test]
992 fn list_order_is_name_then_effective_from_ascending() {
993 let mut ctx = Context::new();
994 let repository = ctx.workspace();
995 let s_2026 = make_spec_with_range("mortgage", Some(date(2026, 1, 1)));
996 let s_2025 = make_spec_with_range("mortgage", Some(date(2025, 1, 1)));
997 ctx.insert_spec(Arc::clone(&repository), s_2026).unwrap();
998 ctx.insert_spec(Arc::clone(&repository), s_2025).unwrap();
999 let listed: Vec<_> = ctx
1000 .spec_set(&repository, "mortgage")
1001 .expect("mortgage set")
1002 .iter_specs()
1003 .collect();
1004 assert_eq!(listed.len(), 2);
1005 assert_eq!(listed[0].effective_from(), Some(&date(2025, 1, 1)));
1006 assert_eq!(listed[1].effective_from(), Some(&date(2026, 1, 1)));
1007 }
1008
1009 #[test]
1010 fn get_spec_resolves_temporal_version_by_effective() {
1011 let mut engine = Engine::new();
1012 engine
1013 .load([(
1014 SourceType::Path(Arc::new(std::path::PathBuf::from("a.lemma"))),
1015 r#"
1016 spec pricing 2025-01-01
1017 data x: 1
1018 rule r: x
1019 "#
1020 .to_string(),
1021 )])
1022 .unwrap();
1023 engine
1024 .load([(
1025 SourceType::Path(Arc::new(std::path::PathBuf::from("b.lemma"))),
1026 r#"
1027 spec pricing 2025-06-01
1028 data x: 2
1029 rule r: x
1030 "#
1031 .to_string(),
1032 )])
1033 .unwrap();
1034
1035 let jan = DateTimeValue {
1036 year: 2025,
1037 month: 1,
1038 day: 15,
1039 hour: 0,
1040 minute: 0,
1041 second: 0,
1042 microsecond: 0,
1043 timezone: None,
1044 granularity: crate::literals::DateGranularity::Full,
1045 };
1046 let jul = DateTimeValue {
1047 year: 2025,
1048 month: 7,
1049 day: 1,
1050 hour: 0,
1051 minute: 0,
1052 second: 0,
1053 microsecond: 0,
1054 timezone: None,
1055 granularity: crate::literals::DateGranularity::Full,
1056 };
1057
1058 let v1 = DateTimeValue {
1059 year: 2025,
1060 month: 1,
1061 day: 1,
1062 hour: 0,
1063 minute: 0,
1064 second: 0,
1065 microsecond: 0,
1066 timezone: None,
1067 granularity: crate::literals::DateGranularity::Full,
1068 };
1069 let v2 = DateTimeValue {
1070 year: 2025,
1071 month: 6,
1072 day: 1,
1073 hour: 0,
1074 minute: 0,
1075 second: 0,
1076 microsecond: 0,
1077 timezone: None,
1078 granularity: crate::literals::DateGranularity::Full,
1079 };
1080
1081 let s_jan = engine
1082 .get_spec("pricing", None, Some(&jan))
1083 .expect("jan spec");
1084 let s_jul = engine
1085 .get_spec("pricing", None, Some(&jul))
1086 .expect("jul spec");
1087 assert_eq!(s_jan.effective_from(), Some(&v1));
1088 assert_eq!(s_jul.effective_from(), Some(&v2));
1089 }
1090
1091 #[test]
1096 fn list_returns_half_open_ranges_per_temporal_version() {
1097 let mut engine = Engine::new();
1098 engine
1099 .load([(
1100 SourceType::Path(Arc::new(std::path::PathBuf::from("a.lemma"))),
1101 r#"
1102 spec pricing 2025-01-01
1103 data x: 1
1104 rule r: x
1105 "#
1106 .to_string(),
1107 )])
1108 .unwrap();
1109 engine
1110 .load([(
1111 SourceType::Path(Arc::new(std::path::PathBuf::from("b.lemma"))),
1112 r#"
1113 spec pricing 2025-06-01
1114 data x: 2
1115 rule r: x
1116 "#
1117 .to_string(),
1118 )])
1119 .unwrap();
1120
1121 let january = date(2025, 1, 1);
1122 let june = date(2025, 6, 1);
1123
1124 let workspace = engine
1125 .list()
1126 .into_iter()
1127 .find(|r| r.repository.is_none())
1128 .expect("workspace");
1129 let mut pricing_rows: Vec<_> = workspace
1130 .specs
1131 .iter()
1132 .filter(|ls| ls.name == "pricing")
1133 .map(|ls| (ls.effective_from.clone(), ls.effective_to.clone()))
1134 .collect();
1135 pricing_rows.sort_by(|a, b| match (&a.0, &b.0) {
1136 (Some(x), Some(y)) => x.cmp(y),
1137 (None, Some(_)) => std::cmp::Ordering::Less,
1138 (Some(_), None) => std::cmp::Ordering::Greater,
1139 (None, None) => std::cmp::Ordering::Equal,
1140 });
1141 assert_eq!(pricing_rows.len(), 2);
1142 assert_eq!(
1143 pricing_rows[0],
1144 (Some(january.clone()), Some(june.clone())),
1145 "earlier row ends at the next row's effective_from"
1146 );
1147 assert_eq!(
1148 pricing_rows[1],
1149 (Some(june.clone()), None),
1150 "latest row has no successor; effective_to is None"
1151 );
1152
1153 assert!(
1154 !engine
1155 .list()
1156 .into_iter()
1157 .find(|r| r.repository.is_none())
1158 .expect("workspace")
1159 .specs
1160 .iter()
1161 .any(|ls| ls.name == "unknown"),
1162 "no rows for unknown spec"
1163 );
1164 }
1165
1166 #[test]
1169 fn get_workspace_specs_with_half_open_ranges() {
1170 let mut engine = Engine::new();
1171 engine
1172 .load([(
1173 SourceType::Path(Arc::new(std::path::PathBuf::from("pricing_v1.lemma"))),
1174 r#"
1175 spec pricing 2025-01-01
1176 data x: 1
1177 rule r: x
1178 "#
1179 .to_string(),
1180 )])
1181 .unwrap();
1182 engine
1183 .load([(
1184 SourceType::Path(Arc::new(std::path::PathBuf::from("pricing_v2.lemma"))),
1185 r#"
1186 spec pricing 2026-01-01
1187 data x: 2
1188 rule r: x
1189 "#
1190 .to_string(),
1191 )])
1192 .unwrap();
1193 engine
1194 .load([(
1195 SourceType::Path(Arc::new(std::path::PathBuf::from("taxes.lemma"))),
1196 r#"
1197 spec taxes
1198 data rate: 0.21
1199 rule amount: rate
1200 "#
1201 .to_string(),
1202 )])
1203 .unwrap();
1204
1205 let workspace = engine
1206 .list()
1207 .into_iter()
1208 .find(|r| r.repository.is_none())
1209 .expect("workspace");
1210 let unique_names: std::collections::BTreeSet<&str> =
1211 workspace.specs.iter().map(|ls| ls.name.as_str()).collect();
1212 assert_eq!(
1213 unique_names.len(),
1214 2,
1215 "two unique spec names: pricing and taxes"
1216 );
1217
1218 let pricing_rows: Vec<_> = workspace
1219 .specs
1220 .iter()
1221 .filter(|ls| ls.name == "pricing")
1222 .collect();
1223 assert_eq!(pricing_rows.len(), 2);
1224 assert_eq!(pricing_rows[0].effective_from, Some(date(2025, 1, 1)));
1225 assert_eq!(
1226 pricing_rows[0].effective_to,
1227 Some(date(2026, 1, 1)),
1228 "earlier pricing row ends at the next pricing row's effective_from"
1229 );
1230 assert_eq!(pricing_rows[1].effective_from, Some(date(2026, 1, 1)));
1231 assert_eq!(
1232 pricing_rows[1].effective_to, None,
1233 "latest pricing row has no successor; effective_to is None"
1234 );
1235
1236 let tax_rows: Vec<_> = workspace
1237 .specs
1238 .iter()
1239 .filter(|ls| ls.name == "taxes")
1240 .collect();
1241 assert_eq!(tax_rows.len(), 1);
1242 assert_eq!(
1243 tax_rows[0].effective_from, None,
1244 "unversioned spec has no declared effective_from"
1245 );
1246 assert_eq!(
1247 tax_rows[0].effective_to, None,
1248 "unversioned spec has no successor; effective_to is None"
1249 );
1250 }
1251
1252 #[test]
1253 fn test_evaluate_spec_all_rules() {
1254 let mut engine = Engine::new();
1255 engine
1256 .load([(
1257 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1258 r#"
1259 spec test
1260 data x: 10
1261 data y: 5
1262 rule sum: x + y
1263 rule product: x * y
1264 "#
1265 .to_string(),
1266 )])
1267 .unwrap();
1268
1269 let now = DateTimeValue::now();
1270 let response = engine
1271 .run(None, "test", Some(&now), HashMap::new(), None, false)
1272 .unwrap();
1273 assert_eq!(response.results.len(), 2);
1274
1275 let sum_result = response
1276 .results
1277 .values()
1278 .find(|r| r.rule.name == "sum")
1279 .unwrap();
1280 assert_eq!(sum_result.display().expect("display").to_string(), "15");
1281
1282 let product_result = response
1283 .results
1284 .values()
1285 .find(|r| r.rule.name == "product")
1286 .unwrap();
1287 assert_eq!(product_result.display().expect("display").to_string(), "50");
1288 }
1289
1290 #[test]
1291 fn test_evaluate_empty_data() {
1292 let mut engine = Engine::new();
1293 engine
1294 .load([(
1295 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1296 r#"
1297 spec test
1298 data price: 100
1299 rule total: price * 2
1300 "#
1301 .to_string(),
1302 )])
1303 .unwrap();
1304
1305 let now = DateTimeValue::now();
1306 let response = engine
1307 .run(None, "test", Some(&now), HashMap::new(), None, false)
1308 .unwrap();
1309 assert_eq!(response.results.len(), 1);
1310 assert_eq!(
1311 response
1312 .results
1313 .values()
1314 .next()
1315 .unwrap()
1316 .display()
1317 .expect("display"),
1318 "200"
1319 );
1320 }
1321
1322 #[test]
1323 fn test_evaluate_boolean_rule() {
1324 let mut engine = Engine::new();
1325 engine
1326 .load([(
1327 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1328 r#"
1329 spec test
1330 data age: 25
1331 rule is_adult: age >= 18
1332 "#
1333 .to_string(),
1334 )])
1335 .unwrap();
1336
1337 let now = DateTimeValue::now();
1338 let response = engine
1339 .run(None, "test", Some(&now), HashMap::new(), None, false)
1340 .unwrap();
1341 assert_eq!(
1342 response
1343 .results
1344 .values()
1345 .next()
1346 .unwrap()
1347 .value
1348 .as_ref()
1349 .unwrap()
1350 .boolean,
1351 Some(true)
1352 );
1353 }
1354
1355 #[test]
1356 fn test_evaluate_with_unless_clause() {
1357 let mut engine = Engine::new();
1358 engine
1359 .load([(
1360 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1361 r#"
1362 spec test
1363 data quantity: 15
1364 rule discount: 0
1365 unless quantity >= 10 then 10
1366 "#
1367 .to_string(),
1368 )])
1369 .unwrap();
1370
1371 let now = DateTimeValue::now();
1372 let response = engine
1373 .run(None, "test", Some(&now), HashMap::new(), None, false)
1374 .unwrap();
1375 assert_eq!(
1376 response
1377 .results
1378 .values()
1379 .next()
1380 .unwrap()
1381 .display()
1382 .expect("display"),
1383 "10"
1384 );
1385 }
1386
1387 #[test]
1388 fn test_spec_not_found() {
1389 let engine = Engine::new();
1390 let now = DateTimeValue::now();
1391 let result = engine.run(None, "nonexistent", Some(&now), HashMap::new(), None, false);
1392 assert!(result.is_err());
1393 let msg = result.unwrap_err().to_string();
1394 assert!(
1395 msg.contains("No execution plan") && msg.contains("nonexistent"),
1396 "missing spec must report no plan, got: {msg}"
1397 );
1398 }
1399
1400 #[test]
1401 fn test_multiple_specs() {
1402 let mut engine = Engine::new();
1403 engine
1404 .load([(
1405 SourceType::Path(Arc::new(std::path::PathBuf::from("spec 1.lemma"))),
1406 r#"
1407 spec spec1
1408 data x: 10
1409 rule result: x * 2
1410 "#
1411 .to_string(),
1412 )])
1413 .unwrap();
1414
1415 engine
1416 .load([(
1417 SourceType::Path(Arc::new(std::path::PathBuf::from("spec 2.lemma"))),
1418 r#"
1419 spec spec2
1420 data y: 5
1421 rule result: y * 3
1422 "#
1423 .to_string(),
1424 )])
1425 .unwrap();
1426
1427 let now = DateTimeValue::now();
1428 let response1 = engine
1429 .run(None, "spec1", Some(&now), HashMap::new(), None, false)
1430 .unwrap();
1431 assert_eq!(
1432 response1.results[0].display().expect("display").to_string(),
1433 "20"
1434 );
1435 let response2 = engine
1436 .run(None, "spec2", Some(&now), HashMap::new(), None, false)
1437 .unwrap();
1438 assert_eq!(
1439 response2.results[0].display().expect("display").to_string(),
1440 "15"
1441 );
1442 }
1443
1444 #[test]
1445 fn test_runtime_error_mapping() {
1446 let mut engine = Engine::new();
1447 engine
1448 .load([(
1449 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1450 r#"
1451 spec test
1452 data numerator: 10
1453 data denominator: 0
1454 rule division: numerator / denominator
1455 "#
1456 .to_string(),
1457 )])
1458 .unwrap();
1459
1460 let now = DateTimeValue::now();
1461 let result = engine.run(None, "test", Some(&now), HashMap::new(), None, false);
1462 assert!(result.is_ok(), "Evaluation should succeed");
1464 let response = result.unwrap();
1465 let division_result = response
1466 .results
1467 .values()
1468 .find(|r| r.rule.name == "division");
1469 assert!(
1470 division_result.is_some(),
1471 "Should have division rule result"
1472 );
1473 let division = division_result.unwrap();
1474 assert!(division.vetoed);
1475 assert!(
1476 division
1477 .veto_reason
1478 .as_deref()
1479 .unwrap()
1480 .contains("Division by zero"),
1481 "Veto message should mention division by zero: {:?}",
1482 division.veto_reason
1483 );
1484 }
1485
1486 #[test]
1487 fn test_rules_sorted_by_source_order() {
1488 let mut engine = Engine::new();
1489 engine
1490 .load([(
1491 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1492 r#"
1493 spec test
1494 data a: 1
1495 data b: 2
1496 rule z: a + b
1497 rule y: a * b
1498 rule x: a - b
1499 "#
1500 .to_string(),
1501 )])
1502 .unwrap();
1503
1504 let now = DateTimeValue::now();
1505 let response = engine
1506 .run(None, "test", Some(&now), HashMap::new(), None, false)
1507 .unwrap();
1508 assert_eq!(response.results.len(), 3);
1509
1510 let z_pos = response
1512 .results
1513 .values()
1514 .find(|r| r.rule.name == "z")
1515 .unwrap()
1516 .rule
1517 .source_location
1518 .span
1519 .start;
1520 let y_pos = response
1521 .results
1522 .values()
1523 .find(|r| r.rule.name == "y")
1524 .unwrap()
1525 .rule
1526 .source_location
1527 .span
1528 .start;
1529 let x_pos = response
1530 .results
1531 .values()
1532 .find(|r| r.rule.name == "x")
1533 .unwrap()
1534 .rule
1535 .source_location
1536 .span
1537 .start;
1538
1539 assert!(z_pos < y_pos);
1540 assert!(y_pos < x_pos);
1541 }
1542
1543 #[test]
1544 fn test_rule_filtering_evaluates_dependencies() {
1545 let mut engine = Engine::new();
1546 engine
1547 .load([(
1548 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1549 r#"
1550 spec test
1551 data base: 100
1552 rule subtotal: base * 2
1553 rule tax: subtotal * 10%
1554 rule total: subtotal + tax
1555 "#
1556 .to_string(),
1557 )])
1558 .unwrap();
1559
1560 let now = DateTimeValue::now();
1561 let response = engine
1562 .run(
1563 None,
1564 "test",
1565 Some(&now),
1566 HashMap::new(),
1567 Some(&["total".to_string()]),
1568 false,
1569 )
1570 .unwrap();
1571
1572 assert_eq!(response.results.len(), 1);
1573 assert_eq!(response.results.keys().next().unwrap(), "total");
1574
1575 let total = response.results.values().next().unwrap();
1577 assert_eq!(total.display().expect("display").to_string(), "220");
1578 }
1579
1580 use crate::parsing::ast::DateTimeValue;
1585
1586 #[test]
1587 fn pre_resolved_deps_in_file_map_evaluates_external_spec() {
1588 let mut engine = Engine::new();
1589
1590 engine
1591 .load([(
1592 SourceType::Dependency("@org/project".to_string()),
1593 "repo @org/project\nspec helper\ndata quantity: 42".to_string(),
1594 )])
1595 .expect("should load dependency files");
1596
1597 engine
1598 .load([(
1599 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1600 r#"spec main_spec
1601uses external: @org/project helper
1602rule value: external.quantity"#
1603 .to_string(),
1604 )])
1605 .expect("should succeed with pre-resolved deps");
1606
1607 let now = DateTimeValue::now();
1608 let response = engine
1609 .run(None, "main_spec", Some(&now), HashMap::new(), None, false)
1610 .expect("evaluate should succeed");
1611
1612 let value_result = response
1613 .results
1614 .get("value")
1615 .expect("rule 'value' should exist");
1616 assert_eq!(value_result.display().expect("display").to_string(), "42");
1617 }
1618
1619 #[test]
1620 fn show_with_repo_resolves_registry_spec() {
1621 let mut engine = Engine::new();
1622 engine
1623 .load([(
1624 SourceType::Dependency("@org/project".to_string()),
1625 "repo @org/project\nspec helper\ndata quantity: 42\nrule expose: quantity"
1626 .to_string(),
1627 )])
1628 .expect("registry bundle loads");
1629
1630 engine
1631 .load([(
1632 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1633 r#"spec main_spec
1634data x: 1"#
1635 .to_string(),
1636 )])
1637 .expect("main loads");
1638
1639 let now = DateTimeValue::now();
1640 let view = engine
1641 .show(Some("@org/project"), "helper", Some(&now))
1642 .expect("show for registry spec");
1643 assert!(view.data.contains_key("quantity"));
1644 }
1645
1646 #[test]
1647 fn load_no_external_refs_works() {
1648 let mut engine = Engine::new();
1649
1650 engine
1651 .load([(
1652 SourceType::Path(Arc::new(std::path::PathBuf::from("local.lemma"))),
1653 r#"spec local_only
1654data price: 100
1655rule doubled: price * 2"#
1656 .to_string(),
1657 )])
1658 .expect("should succeed when there are no @... references");
1659
1660 let now = DateTimeValue::now();
1661 let response = engine
1662 .run(None, "local_only", Some(&now), HashMap::new(), None, false)
1663 .expect("evaluate should succeed");
1664
1665 let doubled = response.results.get("doubled").expect("doubled rule");
1666 assert_eq!(doubled.display().expect("display").to_string(), "200");
1667 }
1668
1669 #[test]
1670 fn unresolved_external_ref_without_deps_fails() {
1671 let mut engine = Engine::new();
1672
1673 let result = engine.load([(
1674 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1675 r#"spec main_spec
1676uses external: @org/project missing
1677rule value: external.quantity"#
1678 .to_string(),
1679 )]);
1680
1681 let errs = result.expect_err("Should fail when registry dep is not loaded");
1682 assert!(
1683 errs.iter()
1684 .any(|e| e.kind() == crate::ErrorKind::MissingRepository),
1685 "expected MissingRepository, got: {:?}",
1686 errs.iter().map(|e| e.kind()).collect::<Vec<_>>()
1687 );
1688 }
1689
1690 #[test]
1691 fn pre_resolved_deps_with_spec_and_type_refs() {
1692 let mut engine = Engine::new();
1693
1694 engine
1695 .load([(
1696 SourceType::Dependency("@org/example".to_string()),
1697 "repo @org/example\nspec helper\ndata value: 42".to_string(),
1698 )])
1699 .expect("should load helper file");
1700
1701 engine
1702 .load([(
1703 SourceType::Dependency("@iso/countries".to_string()),
1704 "repo @iso/countries\nspec alpha2\ndata code: text\n -> option \"NL\"\n -> option \"BE\"".to_string(),
1705 )])
1706 .expect("should load alpha2 file");
1707
1708 engine
1709 .load([(
1710 SourceType::Path(Arc::new(std::path::PathBuf::from("main.lemma"))),
1711 r#"spec registry_demo
1712uses @iso/countries alpha2
1713data country: alpha2.code
1714data unit_count: 5
1715uses @org/example helper
1716rule helper_value: helper.value
1717rule line_total: unit_count * 2
1718rule formatted: helper_value + 0"#
1719 .to_string(),
1720 )])
1721 .expect("should succeed with pre-resolved spec and type deps");
1722
1723 let now = DateTimeValue::now();
1724 let response = engine
1725 .run(
1726 None,
1727 "registry_demo",
1728 Some(&now),
1729 HashMap::new(),
1730 None,
1731 false,
1732 )
1733 .expect("evaluate should succeed");
1734
1735 assert_eq!(
1736 response
1737 .results
1738 .get("helper_value")
1739 .expect("helper_value")
1740 .display()
1741 .expect("display"),
1742 "42"
1743 );
1744 let line = response
1745 .results
1746 .get("line_total")
1747 .expect("line_total")
1748 .display()
1749 .expect("display");
1750 assert_eq!(line, "10");
1751 assert_eq!(
1752 response
1753 .results
1754 .get("formatted")
1755 .expect("formatted")
1756 .display()
1757 .expect("display"),
1758 "42"
1759 );
1760 }
1761
1762 #[test]
1763 fn load_empty_labeled_source_is_error() {
1764 let mut engine = Engine::new();
1765 let err = engine
1766 .load([(
1767 SourceType::Path(Arc::new(std::path::PathBuf::from(" "))),
1768 "spec x\ndata a: 1".to_string(),
1769 )])
1770 .unwrap_err();
1771 assert!(err.errors.iter().any(|e| e.message().contains("non-empty")));
1772 }
1773
1774 #[test]
1775 fn add_dependency_files_accepts_registry_bundle_specs() {
1776 let mut engine = Engine::new();
1777 engine
1778 .load([(
1779 SourceType::Dependency("@org/my".to_string()),
1780 "repo @org/my\nspec helper\ndata x: 1".to_string(),
1781 )])
1782 .expect("dependency bundle specs should be accepted");
1783 }
1784
1785 #[test]
1786 fn user_load_rejects_reserved_embedded_stdlib_repository() {
1787 let mut engine = Engine::new();
1788 let batch = engine.load([(
1789 SourceType::Dependency(EMBEDDED_STDLIB_REPOSITORY.to_string()),
1790 "spec finance\ndata money: ratio -> decimals 2".to_string(),
1791 )]);
1792 assert!(
1793 batch.is_err(),
1794 "load must not write reserved lemma stdlib repo"
1795 );
1796 let msg = batch
1797 .unwrap_err()
1798 .errors
1799 .iter()
1800 .map(ToString::to_string)
1801 .collect::<Vec<_>>()
1802 .join("\n");
1803 assert!(
1804 msg.contains(EMBEDDED_STDLIB_REPOSITORY) && msg.contains("reserved"),
1805 "expected reserved-repo error, got: {msg}"
1806 );
1807
1808 let workspace = engine.load([(
1809 SourceType::Volatile,
1810 "repo lemma\nspec x\ndata a: 1".to_string(),
1811 )]);
1812 assert!(workspace.is_err(), "workspace repo lemma must be rejected");
1813 let msg = workspace
1814 .unwrap_err()
1815 .errors
1816 .iter()
1817 .map(ToString::to_string)
1818 .collect::<Vec<_>>()
1819 .join("\n");
1820 assert!(
1821 msg.contains(EMBEDDED_STDLIB_REPOSITORY) && msg.contains("reserved"),
1822 "expected reserved-repo error, got: {msg}"
1823 );
1824 }
1825
1826 #[test]
1827 fn load_returns_all_errors_not_just_first() {
1828 let mut engine = Engine::new();
1829
1830 let result = engine.load([(
1831 SourceType::Path(Arc::new(std::path::PathBuf::from("test.lemma"))),
1832 r#"spec demo
1833uses type_src: nonexistent_type_source
1834with type_src.amount: 10
1835uses helper: nonexistent_spec
1836data price: 10
1837rule total: helper.value + price"#
1838 .to_string(),
1839 )]);
1840
1841 assert!(result.is_err(), "Should fail with multiple errors");
1842 let load_err = result.unwrap_err();
1843 assert!(
1844 load_err.errors.len() >= 2,
1845 "expected at least 2 errors (type + spec ref), got {}",
1846 load_err.errors.len()
1847 );
1848 let error_message = load_err
1849 .errors
1850 .iter()
1851 .map(ToString::to_string)
1852 .collect::<Vec<_>>()
1853 .join("; ");
1854
1855 assert!(
1856 error_message.contains("nonexistent_type_source"),
1857 "Should mention data import source spec. Got:\n{}",
1858 error_message
1859 );
1860 assert!(
1861 error_message.contains("nonexistent_spec"),
1862 "Should mention spec reference error about 'nonexistent_spec'. Got:\n{}",
1863 error_message
1864 );
1865 }
1866
1867 #[test]
1873 fn planning_rejects_invalid_number_default() {
1874 let mut engine = Engine::new();
1875 let result = engine.load([(
1876 SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
1877 "spec t\ndata x: number -> suggest \"10 $$\"]\nrule r: x".to_string(),
1878 )]);
1879 assert!(
1880 result.is_err(),
1881 "must reject non-numeric suggestion on number type"
1882 );
1883 }
1884
1885 #[test]
1886 fn planning_rejects_text_literal_as_number_default() {
1887 let mut engine = Engine::new();
1892 let result = engine.load([(
1893 SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
1894 "spec t\ndata x: number -> suggest \"10\"]\nrule r: x".to_string(),
1895 )]);
1896 assert!(
1897 result.is_err(),
1898 "must reject text literal \"10\" as suggestion for number type"
1899 );
1900 }
1901
1902 #[test]
1903 fn planning_rejects_invalid_boolean_default() {
1904 let mut engine = Engine::new();
1905 let result = engine.load([(
1906 SourceType::Path(Arc::new(std::path::PathBuf::from("t.lemma"))),
1907 "spec t\ndata x: [boolean -> suggest \"maybe\"]\nrule r: x".to_string(),
1908 )]);
1909 assert!(
1910 result.is_err(),
1911 "must reject non-boolean suggestion on boolean type"
1912 );
1913 }
1914
1915 #[test]
1916 fn planning_rejects_invalid_named_type_default() {
1917 let mut engine = Engine::new();
1919 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())]);
1920 assert!(
1921 result.is_err(),
1922 "must reject non-numeric suggestion on named number type"
1923 );
1924 }
1925
1926 #[test]
1927 fn context_merges_cross_file_repo_identities() {
1928 let mut engine = Engine::new();
1929
1930 engine
1932 .load([(
1933 SourceType::Path(Arc::new(std::path::PathBuf::from("file1.lemma"))),
1934 "repo shared\nspec a\ndata x: 1".to_string(),
1935 )])
1936 .expect("first file should load");
1937
1938 engine
1939 .load([(
1940 SourceType::Path(Arc::new(std::path::PathBuf::from("file2.lemma"))),
1941 "repo shared\nspec b\ndata y: 2".to_string(),
1942 )])
1943 .expect("second file should load");
1944
1945 assert_eq!(
1948 engine.context.repositories().len(),
1949 3,
1950 "should have workspace, stdlib repository, and one named user repository"
1951 );
1952
1953 let shared_repo = engine
1954 .context
1955 .find_repository("shared")
1956 .expect("shared repo should exist");
1957 let shared_specs = engine.context.repositories().get(&shared_repo).unwrap();
1958 assert_eq!(
1959 shared_specs.len(),
1960 2,
1961 "shared repo should contain both specs"
1962 );
1963 assert!(shared_specs.contains_key("a"));
1964 assert!(shared_specs.contains_key("b"));
1965
1966 let _result = engine.load([(
1968 SourceType::Dependency("@some/dep".to_string()),
1969 "repo shared\nspec c\ndata z: 3".to_string(),
1970 )]);
1971
1972 let result = engine.load([(
1973 SourceType::Path(Arc::new(std::path::PathBuf::from("file2.lemma"))),
1974 "repo shared\nspec a\ndata y: 2".to_string(),
1975 )]);
1976
1977 assert!(
1978 result.is_err(),
1979 "should reject duplicate spec name in same repo"
1980 );
1981 let err_msg = result.unwrap_err().errors[0].to_string();
1982 assert!(
1983 err_msg.contains("Duplicate spec 'a'"),
1984 "error should mention duplicate spec"
1985 );
1986 }
1987
1988 #[test]
1989 fn test_list_structure() {
1990 let mut engine = Engine::new();
1991 engine
1992 .load([(
1993 SourceType::Path(Arc::new(std::path::PathBuf::from("file1.lemma"))),
1994 "repo shared\nspec a\ndata x: 1\nrule r: x".to_string(),
1995 )])
1996 .expect("file should load");
1997
1998 let repos = engine.list();
1999 let shared_repo = repos
2000 .iter()
2001 .find(|r| r.repository.as_deref() == Some("shared"))
2002 .expect("shared repo in list");
2003 assert_eq!(shared_repo.specs.len(), 1);
2004 assert_eq!(shared_repo.specs[0].name, "a");
2005 }
2006}