1use crate::engine::Context;
14use crate::error::Error;
15use crate::limits::ResourceLimits;
16use crate::parsing::ast::{DateTimeValue, FactValue, TypeDef};
17use crate::parsing::source::Source;
18use std::collections::{HashMap, HashSet};
19use std::fmt;
20use std::sync::Arc;
21
22#[derive(Debug, Clone)]
30pub struct RegistryBundle {
31 pub lemma_source: String,
33
34 pub attribute: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum RegistryErrorKind {
46 NotFound,
48 Unauthorized,
50 NetworkError,
52 ServerError,
54 Other,
56}
57
58impl fmt::Display for RegistryErrorKind {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60 match self {
61 Self::NotFound => write!(f, "not found"),
62 Self::Unauthorized => write!(f, "unauthorized"),
63 Self::NetworkError => write!(f, "network error"),
64 Self::ServerError => write!(f, "server error"),
65 Self::Other => write!(f, "error"),
66 }
67 }
68}
69
70#[derive(Debug, Clone)]
72pub struct RegistryError {
73 pub message: String,
74 pub kind: RegistryErrorKind,
75}
76
77impl fmt::Display for RegistryError {
78 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79 write!(formatter, "{}", self.message)
80 }
81}
82
83impl std::error::Error for RegistryError {}
84
85#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
97#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
98pub trait Registry: Send + Sync {
99 async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError>;
104
105 fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String>;
110}
111
112#[cfg(feature = "registry")]
123struct HttpFetchError {
124 status_code: Option<u16>,
126 message: String,
128}
129
130#[cfg(feature = "registry")]
134#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
135#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
136trait HttpFetcher: Send + Sync {
137 async fn get(&self, url: &str) -> Result<String, HttpFetchError>;
138}
139
140#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
142struct ReqwestHttpFetcher;
143
144#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
145#[async_trait::async_trait]
146impl HttpFetcher for ReqwestHttpFetcher {
147 async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
148 let response = reqwest::get(url).await.map_err(|e| HttpFetchError {
149 status_code: e.status().map(|s| s.as_u16()),
150 message: e.to_string(),
151 })?;
152 let status = response.status();
153 let body = response.text().await.map_err(|e| HttpFetchError {
154 status_code: None,
155 message: e.to_string(),
156 })?;
157 if !status.is_success() {
158 return Err(HttpFetchError {
159 status_code: Some(status.as_u16()),
160 message: format!("HTTP {}", status),
161 });
162 }
163 Ok(body)
164 }
165}
166
167#[cfg(all(feature = "registry", target_arch = "wasm32"))]
169struct WasmHttpFetcher;
170
171#[cfg(all(feature = "registry", target_arch = "wasm32"))]
172#[async_trait::async_trait(?Send)]
173impl HttpFetcher for WasmHttpFetcher {
174 async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
175 let response = gloo_net::http::Request::get(url)
176 .send()
177 .await
178 .map_err(|e| HttpFetchError {
179 status_code: None,
180 message: e.to_string(),
181 })?;
182 let status = response.status();
183 let ok = response.ok();
184 if !ok {
185 return Err(HttpFetchError {
186 status_code: Some(status),
187 message: format!("HTTP {}", status),
188 });
189 }
190 let text = response.text().await.map_err(|e| HttpFetchError {
191 status_code: None,
192 message: e.to_string(),
193 })?;
194 Ok(text)
195 }
196}
197
198#[cfg(feature = "registry")]
212pub struct LemmaBase {
213 fetcher: Box<dyn HttpFetcher>,
214}
215
216#[cfg(feature = "registry")]
217impl LemmaBase {
218 pub const BASE_URL: &'static str = "https://lemmabase.com";
220
221 pub fn new() -> Self {
223 Self {
224 #[cfg(not(target_arch = "wasm32"))]
225 fetcher: Box::new(ReqwestHttpFetcher),
226 #[cfg(target_arch = "wasm32")]
227 fetcher: Box::new(WasmHttpFetcher),
228 }
229 }
230
231 #[cfg(test)]
233 fn with_fetcher(fetcher: Box<dyn HttpFetcher>) -> Self {
234 Self { fetcher }
235 }
236
237 fn source_url(&self, name: &str, effective: Option<&DateTimeValue>) -> String {
240 let base = format!("{}/{}.lemma", Self::BASE_URL, name);
241 match effective {
242 None => base,
243 Some(d) => format!("{}?effective={}", base, d),
244 }
245 }
246
247 fn navigation_url(&self, name: &str, effective: Option<&DateTimeValue>) -> String {
250 let base = format!("{}/{}", Self::BASE_URL, name);
251 match effective {
252 None => base,
253 Some(d) => format!("{}?effective={}", base, d),
254 }
255 }
256
257 fn display_id(name: &str, effective: Option<&DateTimeValue>) -> String {
260 match effective {
261 None => name.to_string(),
262 Some(d) => format!("{} {}", name, d),
263 }
264 }
265
266 async fn fetch_source(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
268 let url = self.source_url(name, None);
269 let display = Self::display_id(name, None);
270 let source_url = self.source_url(name, None);
271
272 let lemma_source = self.fetcher.get(&url).await.map_err(|error| {
273 if let Some(code) = error.status_code {
274 let kind = match code {
275 404 => RegistryErrorKind::NotFound,
276 401 | 403 => RegistryErrorKind::Unauthorized,
277 500..=599 => RegistryErrorKind::ServerError,
278 _ => RegistryErrorKind::Other,
279 };
280 RegistryError {
281 message: format!(
282 "LemmaBase returned HTTP {} {} for '{}'",
283 code, source_url, display
284 ),
285 kind,
286 }
287 } else {
288 RegistryError {
289 message: format!(
290 "Failed to reach LemmaBase for '{}': {}",
291 display, error.message
292 ),
293 kind: RegistryErrorKind::NetworkError,
294 }
295 }
296 })?;
297
298 Ok(RegistryBundle {
299 lemma_source,
300 attribute: display,
301 })
302 }
303}
304
305#[cfg(feature = "registry")]
306impl Default for LemmaBase {
307 fn default() -> Self {
308 Self::new()
309 }
310}
311
312#[cfg(feature = "registry")]
313#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
314#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
315impl Registry for LemmaBase {
316 async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
317 self.fetch_source(name).await
318 }
319
320 fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
321 Some(self.navigation_url(name, effective))
322 }
323}
324
325pub async fn resolve_registry_references(
344 ctx: &mut Context,
345 sources: &mut HashMap<String, String>,
346 registry: &dyn Registry,
347 limits: &ResourceLimits,
348) -> Result<(), Vec<Error>> {
349 let mut already_requested: HashSet<String> = HashSet::new();
350
351 loop {
352 let unresolved = collect_unresolved_registry_references(ctx, &already_requested);
353
354 if unresolved.is_empty() {
355 break;
356 }
357
358 let mut round_errors: Vec<Error> = Vec::new();
359 for reference in &unresolved {
360 if already_requested.contains(&reference.name) {
361 continue;
362 }
363 already_requested.insert(reference.name.clone());
364
365 let bundle_result = registry.get(&reference.name).await;
366
367 let bundle = match bundle_result {
368 Ok(b) => b,
369 Err(registry_error) => {
370 let suggestion = match ®istry_error.kind {
371 RegistryErrorKind::NotFound => Some(
372 "Check that the identifier is spelled correctly and that the spec exists on the registry.".to_string(),
373 ),
374 RegistryErrorKind::Unauthorized => Some(
375 "Check your authentication credentials or permissions for this registry.".to_string(),
376 ),
377 RegistryErrorKind::NetworkError => Some(
378 "Check your network connection. To compile without registry access, disable the 'registry' feature.".to_string(),
379 ),
380 RegistryErrorKind::ServerError => Some(
381 "The registry server returned an internal error. Try again later.".to_string(),
382 ),
383 RegistryErrorKind::Other => None,
384 };
385 let spec_context = ctx.iter().find(|s| {
386 s.attribute.as_deref() == Some(reference.source.attribute.as_str())
387 });
388 round_errors.push(Error::registry(
389 registry_error.message,
390 reference.source.clone(),
391 &reference.name,
392 registry_error.kind,
393 suggestion,
394 spec_context,
395 None,
396 ));
397 continue;
398 }
399 };
400
401 sources.insert(bundle.attribute.clone(), bundle.lemma_source.clone());
402
403 let new_specs =
404 match crate::parsing::parse(&bundle.lemma_source, &bundle.attribute, limits) {
405 Ok(result) => result.specs,
406 Err(e) => {
407 round_errors.push(e);
408 return Err(round_errors);
409 }
410 };
411
412 for spec in new_specs {
413 let bare_refs = crate::planning::validation::collect_bare_registry_refs(&spec);
414 if !bare_refs.is_empty() {
415 round_errors.push(Error::validation_with_context(
416 format!(
417 "Registry spec '{}' contains references without '@' prefix: {}. \
418 The registry must rewrite all references to use '@'-prefixed names",
419 spec.name,
420 bare_refs.join(", ")
421 ),
422 None,
423 Some(
424 "The registry must prefix all spec references with '@' \
425 before serving the bundle.",
426 ),
427 Some(std::sync::Arc::new(spec.clone())),
428 None,
429 ));
430 continue;
431 }
432 if let Err(e) = ctx.insert_spec(Arc::new(spec), true) {
433 round_errors.push(e);
434 }
435 }
436 }
437
438 if !round_errors.is_empty() {
439 return Err(round_errors);
440 }
441 }
442
443 Ok(())
444}
445
446#[derive(Debug, Clone)]
448struct RegistryReference {
449 name: String,
450 source: Source,
451}
452
453fn collect_unresolved_registry_references(
456 ctx: &Context,
457 already_requested: &HashSet<String>,
458) -> Vec<RegistryReference> {
459 let mut unresolved: Vec<RegistryReference> = Vec::new();
460 let mut seen_in_this_round: HashSet<String> = HashSet::new();
461
462 for spec in ctx.iter() {
463 let spec = spec.as_ref();
464 if spec.attribute.is_none() {
465 let has_registry_refs = spec.facts.iter().any(|f| match &f.value {
466 FactValue::SpecReference(ref r) => r.from_registry,
467 FactValue::TypeDeclaration {
468 from: Some(ref r), ..
469 } => r.from_registry,
470 _ => false,
471 }) || spec.types.iter().any(|t| match t {
472 TypeDef::Import { from, .. } => from.from_registry,
473 TypeDef::Inline {
474 from: Some(ref r), ..
475 } => r.from_registry,
476 _ => false,
477 });
478 if has_registry_refs {
479 panic!(
480 "BUG: spec '{}' must have source attribute when it has registry references",
481 spec.name
482 );
483 }
484 continue;
485 }
486
487 let mut try_collect = |name: &str, source: &Source| {
488 let already_satisfied = ctx.get_spec_effective_from(name, None).is_some();
489 if !already_satisfied
490 && !already_requested.contains(name)
491 && seen_in_this_round.insert(name.to_string())
492 {
493 unresolved.push(RegistryReference {
494 name: name.to_string(),
495 source: source.clone(),
496 });
497 }
498 };
499
500 for fact in &spec.facts {
501 match &fact.value {
502 FactValue::SpecReference(spec_ref) if spec_ref.from_registry => {
503 try_collect(&spec_ref.name, &fact.source_location);
504 }
505 FactValue::TypeDeclaration {
506 from: Some(from_ref),
507 ..
508 } if from_ref.from_registry => {
509 try_collect(&from_ref.name, &fact.source_location);
510 }
511 _ => {}
512 }
513 }
514
515 for type_def in &spec.types {
516 match type_def {
517 TypeDef::Import {
518 from,
519 source_location,
520 ..
521 } if from.from_registry => {
522 try_collect(&from.name, source_location);
523 }
524 TypeDef::Inline {
525 from: Some(from_ref),
526 source_location,
527 ..
528 } if from_ref.from_registry => {
529 try_collect(&from_ref.name, source_location);
530 }
531 _ => {}
532 }
533 }
534 }
535
536 unresolved
537}
538
539#[cfg(test)]
544mod tests {
545 use super::*;
546
547 struct TestRegistry {
549 bundles: HashMap<String, RegistryBundle>,
550 }
551
552 impl TestRegistry {
553 fn new() -> Self {
554 Self {
555 bundles: HashMap::new(),
556 }
557 }
558
559 fn add_spec_bundle(&mut self, identifier: &str, lemma_source: &str) {
561 self.bundles.insert(
562 identifier.to_string(),
563 RegistryBundle {
564 lemma_source: lemma_source.to_string(),
565 attribute: identifier.to_string(),
566 },
567 );
568 }
569 }
570
571 #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
572 #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
573 impl Registry for TestRegistry {
574 async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
575 self.bundles
576 .get(name)
577 .cloned()
578 .ok_or_else(|| RegistryError {
579 message: format!("'{}' not found in test registry", name),
580 kind: RegistryErrorKind::NotFound,
581 })
582 }
583
584 fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
585 if self.bundles.contains_key(name) {
586 Some(match effective {
587 None => format!("https://test.registry/{}", name),
588 Some(d) => format!("https://test.registry/{}?effective={}", name, d),
589 })
590 } else {
591 None
592 }
593 }
594 }
595
596 #[tokio::test]
597 async fn resolve_with_no_registry_references_returns_local_specs_unchanged() {
598 let source = r#"spec example
599fact price: 100"#;
600 let local_specs = crate::parse(source, "local.lemma", &ResourceLimits::default())
601 .unwrap()
602 .specs;
603 let mut store = Context::new();
604 for spec in &local_specs {
605 store.insert_spec(Arc::new(spec.clone()), false).unwrap();
606 }
607 let mut sources = HashMap::new();
608 sources.insert("local.lemma".to_string(), source.to_string());
609
610 let registry = TestRegistry::new();
611 resolve_registry_references(
612 &mut store,
613 &mut sources,
614 ®istry,
615 &ResourceLimits::default(),
616 )
617 .await
618 .unwrap();
619
620 assert_eq!(store.len(), 1);
621 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
622 assert_eq!(names, ["example"]);
623 }
624
625 #[tokio::test]
626 async fn resolve_fetches_single_spec_from_registry() {
627 let local_source = r#"spec main_spec
628fact external: spec @org/project/helper
629rule value: external.quantity"#;
630 let local_specs = crate::parse(local_source, "local.lemma", &ResourceLimits::default())
631 .unwrap()
632 .specs;
633 let mut store = Context::new();
634 for spec in local_specs {
635 store.insert_spec(Arc::new(spec), false).unwrap();
636 }
637 let mut sources = HashMap::new();
638 sources.insert("local.lemma".to_string(), local_source.to_string());
639
640 let mut registry = TestRegistry::new();
641 registry.add_spec_bundle(
642 "@org/project/helper",
643 r#"spec @org/project/helper
644fact quantity: 42"#,
645 );
646
647 resolve_registry_references(
648 &mut store,
649 &mut sources,
650 ®istry,
651 &ResourceLimits::default(),
652 )
653 .await
654 .unwrap();
655
656 assert_eq!(store.len(), 2);
657 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
658 assert!(names.iter().any(|n| n == "main_spec"));
659 assert!(names.iter().any(|n| n == "@org/project/helper"));
660 }
661
662 #[tokio::test]
663 async fn get_returns_all_zones_and_url_for_id_supports_effective() {
664 let effective = DateTimeValue {
665 year: 2026,
666 month: 1,
667 day: 15,
668 hour: 0,
669 minute: 0,
670 second: 0,
671 microsecond: 0,
672 timezone: None,
673 };
674 let mut registry = TestRegistry::new();
675 registry.add_spec_bundle(
676 "org/spec",
677 "spec org/spec 2025-01-01\nfact x: 1\n\nspec org/spec 2026-01-15\nfact x: 2",
678 );
679
680 let bundle = registry.get("org/spec").await.unwrap();
681 assert!(bundle.lemma_source.contains("fact x: 1"));
682 assert!(bundle.lemma_source.contains("fact x: 2"));
683
684 assert_eq!(
685 registry.url_for_id("org/spec", None),
686 Some("https://test.registry/org/spec".to_string())
687 );
688 assert_eq!(
689 registry.url_for_id("org/spec", Some(&effective)),
690 Some("https://test.registry/org/spec?effective=2026-01-15".to_string())
691 );
692 }
693
694 #[tokio::test]
695 async fn resolve_fetches_transitive_dependencies() {
696 let local_source = r#"spec main_spec
697fact a: spec @org/project/spec_a"#;
698 let local_specs = crate::parse(local_source, "local.lemma", &ResourceLimits::default())
699 .unwrap()
700 .specs;
701 let mut store = Context::new();
702 for spec in local_specs {
703 store.insert_spec(Arc::new(spec), false).unwrap();
704 }
705 let mut sources = HashMap::new();
706 sources.insert("local.lemma".to_string(), local_source.to_string());
707
708 let mut registry = TestRegistry::new();
709 registry.add_spec_bundle(
710 "@org/project/spec_a",
711 r#"spec @org/project/spec_a
712fact b: spec @org/project/spec_b"#,
713 );
714 registry.add_spec_bundle(
715 "@org/project/spec_b",
716 r#"spec @org/project/spec_b
717fact value: 99"#,
718 );
719
720 resolve_registry_references(
721 &mut store,
722 &mut sources,
723 ®istry,
724 &ResourceLimits::default(),
725 )
726 .await
727 .unwrap();
728
729 assert_eq!(store.len(), 3);
730 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
731 assert!(names.iter().any(|n| n == "main_spec"));
732 assert!(names.iter().any(|n| n == "@org/project/spec_a"));
733 assert!(names.iter().any(|n| n == "@org/project/spec_b"));
734 }
735
736 #[tokio::test]
737 async fn resolve_handles_bundle_with_multiple_specs() {
738 let local_source = r#"spec main_spec
739fact a: spec @org/project/spec_a"#;
740 let local_specs = crate::parse(local_source, "local.lemma", &ResourceLimits::default())
741 .unwrap()
742 .specs;
743 let mut store = Context::new();
744 for spec in local_specs {
745 store.insert_spec(Arc::new(spec), false).unwrap();
746 }
747 let mut sources = HashMap::new();
748 sources.insert("local.lemma".to_string(), local_source.to_string());
749
750 let mut registry = TestRegistry::new();
751 registry.add_spec_bundle(
752 "@org/project/spec_a",
753 r#"spec @org/project/spec_a
754fact b: spec @org/project/spec_b
755
756spec @org/project/spec_b
757fact value: 99"#,
758 );
759
760 resolve_registry_references(
761 &mut store,
762 &mut sources,
763 ®istry,
764 &ResourceLimits::default(),
765 )
766 .await
767 .unwrap();
768
769 assert_eq!(store.len(), 3);
770 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
771 assert!(names.iter().any(|n| n == "main_spec"));
772 assert!(names.iter().any(|n| n == "@org/project/spec_a"));
773 assert!(names.iter().any(|n| n == "@org/project/spec_b"));
774 }
775
776 #[tokio::test]
777 async fn resolve_returns_registry_error_when_registry_fails() {
778 let local_source = r#"spec main_spec
779fact external: spec @org/project/missing"#;
780 let local_specs = crate::parse(local_source, "local.lemma", &ResourceLimits::default())
781 .unwrap()
782 .specs;
783 let mut store = Context::new();
784 for spec in local_specs {
785 store.insert_spec(Arc::new(spec), false).unwrap();
786 }
787 let mut sources = HashMap::new();
788 sources.insert("local.lemma".to_string(), local_source.to_string());
789
790 let registry = TestRegistry::new(); let result = resolve_registry_references(
793 &mut store,
794 &mut sources,
795 ®istry,
796 &ResourceLimits::default(),
797 )
798 .await;
799
800 assert!(result.is_err(), "Should fail when Registry cannot resolve");
801 let errs = result.unwrap_err();
802 let registry_err = errs
803 .iter()
804 .find(|e| matches!(e, Error::Registry { .. }))
805 .expect("expected at least one Registry error");
806 match registry_err {
807 Error::Registry {
808 identifier,
809 kind,
810 details,
811 } => {
812 assert_eq!(identifier, "@org/project/missing");
813 assert_eq!(*kind, RegistryErrorKind::NotFound);
814 assert!(
815 details.suggestion.is_some(),
816 "NotFound errors should include a suggestion"
817 );
818 }
819 _ => unreachable!(),
820 }
821
822 let error_message = errs
823 .iter()
824 .map(|e| e.to_string())
825 .collect::<Vec<_>>()
826 .join(" ");
827 assert!(
828 error_message.contains("org/project/missing"),
829 "Error should mention the identifier: {}",
830 error_message
831 );
832 }
833
834 #[tokio::test]
835 async fn resolve_returns_all_registry_errors_when_multiple_refs_fail() {
836 let local_source = r#"spec main_spec
837fact helper: spec @org/example/helper
838type money from @lemma/std/finance"#;
839 let local_specs = crate::parse(local_source, "local.lemma", &ResourceLimits::default())
840 .unwrap()
841 .specs;
842 let mut store = Context::new();
843 for spec in local_specs {
844 store.insert_spec(Arc::new(spec), false).unwrap();
845 }
846 let mut sources = HashMap::new();
847 sources.insert("local.lemma".to_string(), local_source.to_string());
848
849 let registry = TestRegistry::new(); let result = resolve_registry_references(
852 &mut store,
853 &mut sources,
854 ®istry,
855 &ResourceLimits::default(),
856 )
857 .await;
858
859 assert!(result.is_err(), "Should fail when Registry cannot resolve");
860 let errors = result.unwrap_err();
861 assert_eq!(
862 errors.len(),
863 2,
864 "Both spec ref and type import ref should produce a Registry error"
865 );
866 let identifiers: Vec<&str> = errors
867 .iter()
868 .filter_map(|e| {
869 if let Error::Registry { identifier, .. } = e {
870 Some(identifier.as_str())
871 } else {
872 None
873 }
874 })
875 .collect();
876 assert!(
877 identifiers.contains(&"@org/example/helper"),
878 "Should include spec ref error: {:?}",
879 identifiers
880 );
881 assert!(
882 identifiers.contains(&"@lemma/std/finance"),
883 "Should include type import error: {:?}",
884 identifiers
885 );
886 }
887
888 #[tokio::test]
889 async fn resolve_does_not_request_same_identifier_twice() {
890 let local_source = r#"spec spec_one
891fact a: spec @org/shared
892
893spec spec_two
894fact b: spec @org/shared"#;
895 let local_specs = crate::parse(local_source, "local.lemma", &ResourceLimits::default())
896 .unwrap()
897 .specs;
898 let mut store = Context::new();
899 for spec in local_specs {
900 store.insert_spec(Arc::new(spec), false).unwrap();
901 }
902 let mut sources = HashMap::new();
903 sources.insert("local.lemma".to_string(), local_source.to_string());
904
905 let mut registry = TestRegistry::new();
906 registry.add_spec_bundle(
907 "@org/shared",
908 r#"spec @org/shared
909fact value: 1"#,
910 );
911
912 resolve_registry_references(
913 &mut store,
914 &mut sources,
915 ®istry,
916 &ResourceLimits::default(),
917 )
918 .await
919 .unwrap();
920
921 assert_eq!(store.len(), 3);
923 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
924 assert!(names.iter().any(|n| n == "@org/shared"));
925 }
926
927 #[tokio::test]
928 async fn resolve_handles_type_import_from_registry() {
929 let local_source = r#"spec main_spec
930type money from @lemma/std/finance
931fact price: [money]"#;
932 let local_specs = crate::parse(local_source, "local.lemma", &ResourceLimits::default())
933 .unwrap()
934 .specs;
935 let mut store = Context::new();
936 for spec in local_specs {
937 store.insert_spec(Arc::new(spec), false).unwrap();
938 }
939 let mut sources = HashMap::new();
940 sources.insert("local.lemma".to_string(), local_source.to_string());
941
942 let mut registry = TestRegistry::new();
943 registry.add_spec_bundle(
944 "@lemma/std/finance",
945 r#"spec @lemma/std/finance
946type money: scale
947 -> unit eur 1.00
948 -> unit usd 1.10
949 -> decimals 2"#,
950 );
951
952 resolve_registry_references(
953 &mut store,
954 &mut sources,
955 ®istry,
956 &ResourceLimits::default(),
957 )
958 .await
959 .unwrap();
960
961 assert_eq!(store.len(), 2);
962 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
963 assert!(names.iter().any(|n| n == "main_spec"));
964 assert!(names.iter().any(|n| n == "@lemma/std/finance"));
965 }
966
967 #[cfg(feature = "registry")]
972 mod lemmabase_tests {
973 use super::super::*;
974 use std::sync::{Arc, Mutex};
975
976 type HttpFetchHandler = Box<dyn Fn(&str) -> Result<String, HttpFetchError> + Send + Sync>;
981
982 struct MockHttpFetcher {
983 handler: HttpFetchHandler,
984 }
985
986 impl MockHttpFetcher {
987 fn with_handler(
989 handler: impl Fn(&str) -> Result<String, HttpFetchError> + Send + Sync + 'static,
990 ) -> Self {
991 Self {
992 handler: Box::new(handler),
993 }
994 }
995
996 fn always_returning(body: &str) -> Self {
998 let body = body.to_string();
999 Self::with_handler(move |_| Ok(body.clone()))
1000 }
1001
1002 fn always_failing_with_status(code: u16) -> Self {
1004 Self::with_handler(move |_| {
1005 Err(HttpFetchError {
1006 status_code: Some(code),
1007 message: format!("HTTP {}", code),
1008 })
1009 })
1010 }
1011
1012 fn always_failing_with_network_error(msg: &str) -> Self {
1014 let msg = msg.to_string();
1015 Self::with_handler(move |_| {
1016 Err(HttpFetchError {
1017 status_code: None,
1018 message: msg.clone(),
1019 })
1020 })
1021 }
1022 }
1023
1024 #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
1025 #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
1026 impl HttpFetcher for MockHttpFetcher {
1027 async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
1028 (self.handler)(url)
1029 }
1030 }
1031
1032 #[test]
1037 fn source_url_without_effective() {
1038 let registry = LemmaBase::new();
1039 let url = registry.source_url("@user/workspace/somespec", None);
1040 assert_eq!(
1041 url,
1042 format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1043 );
1044 }
1045
1046 #[test]
1047 fn source_url_with_effective() {
1048 let registry = LemmaBase::new();
1049 let effective = DateTimeValue {
1050 year: 2026,
1051 month: 1,
1052 day: 15,
1053 hour: 0,
1054 minute: 0,
1055 second: 0,
1056 microsecond: 0,
1057 timezone: None,
1058 };
1059 let url = registry.source_url("@user/workspace/somespec", Some(&effective));
1060 assert_eq!(
1061 url,
1062 format!(
1063 "{}/@user/workspace/somespec.lemma?effective=2026-01-15",
1064 LemmaBase::BASE_URL
1065 )
1066 );
1067 }
1068
1069 #[test]
1070 fn source_url_for_deeply_nested_identifier() {
1071 let registry = LemmaBase::new();
1072 let url = registry.source_url("@org/team/project/subdir/spec", None);
1073 assert_eq!(
1074 url,
1075 format!(
1076 "{}/@org/team/project/subdir/spec.lemma",
1077 LemmaBase::BASE_URL
1078 )
1079 );
1080 }
1081
1082 #[test]
1083 fn navigation_url_without_effective() {
1084 let registry = LemmaBase::new();
1085 let url = registry.navigation_url("@user/workspace/somespec", None);
1086 assert_eq!(
1087 url,
1088 format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL)
1089 );
1090 }
1091
1092 #[test]
1093 fn navigation_url_with_effective() {
1094 let registry = LemmaBase::new();
1095 let effective = DateTimeValue {
1096 year: 2026,
1097 month: 1,
1098 day: 15,
1099 hour: 0,
1100 minute: 0,
1101 second: 0,
1102 microsecond: 0,
1103 timezone: None,
1104 };
1105 let url = registry.navigation_url("@user/workspace/somespec", Some(&effective));
1106 assert_eq!(
1107 url,
1108 format!(
1109 "{}/@user/workspace/somespec?effective=2026-01-15",
1110 LemmaBase::BASE_URL
1111 )
1112 );
1113 }
1114
1115 #[test]
1116 fn navigation_url_for_deeply_nested_identifier() {
1117 let registry = LemmaBase::new();
1118 let url = registry.navigation_url("@org/team/project/subdir/spec", None);
1119 assert_eq!(
1120 url,
1121 format!("{}/@org/team/project/subdir/spec", LemmaBase::BASE_URL)
1122 );
1123 }
1124
1125 #[test]
1126 fn url_for_id_returns_navigation_url() {
1127 let registry = LemmaBase::new();
1128 let url = registry.url_for_id("@user/workspace/somespec", None);
1129 assert_eq!(
1130 url,
1131 Some(format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL))
1132 );
1133 }
1134
1135 #[test]
1136 fn url_for_id_with_effective() {
1137 let registry = LemmaBase::new();
1138 let effective = DateTimeValue {
1139 year: 2026,
1140 month: 1,
1141 day: 1,
1142 hour: 0,
1143 minute: 0,
1144 second: 0,
1145 microsecond: 0,
1146 timezone: None,
1147 };
1148 let url = registry.url_for_id("@owner/repo/spec", Some(&effective));
1149 assert_eq!(
1150 url,
1151 Some(format!(
1152 "{}/@owner/repo/spec?effective=2026-01-01",
1153 LemmaBase::BASE_URL
1154 ))
1155 );
1156 }
1157
1158 #[test]
1159 fn url_for_id_returns_navigation_url_for_nested_path() {
1160 let registry = LemmaBase::new();
1161 let url = registry.url_for_id("@lemma/std/finance", None);
1162 assert_eq!(
1163 url,
1164 Some(format!("{}/@lemma/std/finance", LemmaBase::BASE_URL))
1165 );
1166 }
1167
1168 #[test]
1169 fn default_trait_creates_same_instance_as_new() {
1170 let from_new = LemmaBase::new();
1171 let from_default = LemmaBase::default();
1172 assert_eq!(
1173 from_new.url_for_id("test/spec", None),
1174 from_default.url_for_id("test/spec", None)
1175 );
1176 }
1177
1178 #[tokio::test]
1183 async fn fetch_source_returns_bundle_on_success() {
1184 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1185 "spec org/my_spec\nfact x: 1",
1186 )));
1187
1188 let bundle = registry.fetch_source("@org/my_spec").await.unwrap();
1189
1190 assert_eq!(bundle.lemma_source, "spec org/my_spec\nfact x: 1");
1191 assert_eq!(bundle.attribute, "@org/my_spec");
1192 }
1193
1194 #[tokio::test]
1195 async fn fetch_source_passes_correct_url_to_fetcher() {
1196 let captured_url = Arc::new(Mutex::new(String::new()));
1197 let captured = captured_url.clone();
1198 let mock = MockHttpFetcher::with_handler(move |url| {
1199 *captured.lock().unwrap() = url.to_string();
1200 Ok("spec test/spec\nfact x: 1".to_string())
1201 });
1202 let registry = LemmaBase::with_fetcher(Box::new(mock));
1203
1204 let _ = registry.fetch_source("@user/workspace/somespec").await;
1205
1206 assert_eq!(
1207 *captured_url.lock().unwrap(),
1208 format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1209 );
1210 }
1211
1212 #[tokio::test]
1213 async fn fetch_source_maps_http_404_to_not_found() {
1214 let registry =
1215 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(404)));
1216
1217 let err = registry.fetch_source("@org/missing").await.unwrap_err();
1218
1219 assert_eq!(err.kind, RegistryErrorKind::NotFound);
1220 assert!(
1221 err.message.contains("HTTP 404"),
1222 "Expected 'HTTP 404' in: {}",
1223 err.message
1224 );
1225 assert!(
1226 err.message.contains("@org/missing"),
1227 "Expected '@org/missing' in: {}",
1228 err.message
1229 );
1230 }
1231
1232 #[tokio::test]
1233 async fn fetch_source_maps_http_500_to_server_error() {
1234 let registry =
1235 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(500)));
1236
1237 let err = registry.fetch_source("@org/broken").await.unwrap_err();
1238
1239 assert_eq!(err.kind, RegistryErrorKind::ServerError);
1240 assert!(
1241 err.message.contains("HTTP 500"),
1242 "Expected 'HTTP 500' in: {}",
1243 err.message
1244 );
1245 }
1246
1247 #[tokio::test]
1248 async fn fetch_source_maps_http_502_to_server_error() {
1249 let registry =
1250 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(502)));
1251
1252 let err = registry.fetch_source("@org/broken").await.unwrap_err();
1253
1254 assert_eq!(err.kind, RegistryErrorKind::ServerError);
1255 }
1256
1257 #[tokio::test]
1258 async fn fetch_source_maps_http_401_to_unauthorized() {
1259 let registry =
1260 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(401)));
1261
1262 let err = registry.fetch_source("@org/secret").await.unwrap_err();
1263
1264 assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1265 assert!(err.message.contains("HTTP 401"));
1266 }
1267
1268 #[tokio::test]
1269 async fn fetch_source_maps_http_403_to_unauthorized() {
1270 let registry =
1271 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(403)));
1272
1273 let err = registry.fetch_source("@org/private").await.unwrap_err();
1274
1275 assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1276 assert!(
1277 err.message.contains("HTTP 403"),
1278 "Expected 'HTTP 403' in: {}",
1279 err.message
1280 );
1281 }
1282
1283 #[tokio::test]
1284 async fn fetch_source_maps_unexpected_status_to_other() {
1285 let registry =
1286 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(418)));
1287
1288 let err = registry.fetch_source("@org/teapot").await.unwrap_err();
1289
1290 assert_eq!(err.kind, RegistryErrorKind::Other);
1291 assert!(err.message.contains("HTTP 418"));
1292 }
1293
1294 #[tokio::test]
1295 async fn fetch_source_maps_network_error_to_network_error_kind() {
1296 let registry = LemmaBase::with_fetcher(Box::new(
1297 MockHttpFetcher::always_failing_with_network_error("connection refused"),
1298 ));
1299
1300 let err = registry.fetch_source("@org/unreachable").await.unwrap_err();
1301
1302 assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1303 assert!(
1304 err.message.contains("connection refused"),
1305 "Expected 'connection refused' in: {}",
1306 err.message
1307 );
1308 assert!(
1309 err.message.contains("@org/unreachable"),
1310 "Expected '@org/unreachable' in: {}",
1311 err.message
1312 );
1313 }
1314
1315 #[tokio::test]
1316 async fn fetch_source_maps_dns_error_to_network_error_kind() {
1317 let registry = LemmaBase::with_fetcher(Box::new(
1318 MockHttpFetcher::always_failing_with_network_error(
1319 "dns error: failed to lookup address",
1320 ),
1321 ));
1322
1323 let err = registry.fetch_source("@org/spec").await.unwrap_err();
1324
1325 assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1326 assert!(
1327 err.message.contains("dns error"),
1328 "Expected 'dns error' in: {}",
1329 err.message
1330 );
1331 assert!(
1332 err.message.contains("Failed to reach LemmaBase"),
1333 "Expected 'Failed to reach LemmaBase' in: {}",
1334 err.message
1335 );
1336 }
1337
1338 #[tokio::test]
1343 async fn get_delegates_to_fetch_source() {
1344 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1345 "spec org/resolved\nfact a: 1",
1346 )));
1347
1348 let bundle = registry.get("@org/resolved").await.unwrap();
1349
1350 assert_eq!(bundle.lemma_source, "spec org/resolved\nfact a: 1");
1351 assert_eq!(bundle.attribute, "@org/resolved");
1352 }
1353
1354 #[tokio::test]
1355 async fn get_propagates_http_error() {
1356 let registry =
1357 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(404)));
1358
1359 let err = registry.get("@org/missing").await.unwrap_err();
1360
1361 assert!(err.message.contains("HTTP 404"));
1362 }
1363
1364 #[tokio::test]
1365 async fn get_propagates_network_error() {
1366 let registry = LemmaBase::with_fetcher(Box::new(
1367 MockHttpFetcher::always_failing_with_network_error("timeout"),
1368 ));
1369
1370 let err = registry.get("@lemma/std/types").await.unwrap_err();
1371
1372 assert!(err.message.contains("timeout"));
1373 }
1374
1375 #[tokio::test]
1376 async fn fetch_source_returns_empty_body_as_valid_bundle() {
1377 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning("")));
1378
1379 let bundle = registry.fetch_source("@org/empty").await.unwrap();
1380
1381 assert_eq!(bundle.lemma_source, "");
1382 assert_eq!(bundle.attribute, "@org/empty");
1383 }
1384 }
1385}