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