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