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