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 {
244 let base = format!("{}/@{}.lemma", Self::BASE_URL, name);
245 match effective {
246 None => base,
247 Some(d) => format!("{}?effective={}", base, d),
248 }
249 }
250
251 fn navigation_url(&self, name: &str, effective: Option<&DateTimeValue>) -> String {
253 let base = format!("{}/@{}", Self::BASE_URL, name);
254 match effective {
255 None => base,
256 Some(d) => format!("{}?effective={}", base, d),
257 }
258 }
259
260 fn display_id(name: &str, effective: Option<&DateTimeValue>) -> String {
262 match effective {
263 None => format!("@{}", name),
264 Some(d) => format!("@{} {}", name, d),
265 }
266 }
267
268 async fn fetch_source(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
270 let url = self.source_url(name, None);
271 let display = Self::display_id(name, None);
272 let source_url = self.source_url(name, None);
273
274 let lemma_source = self.fetcher.get(&url).await.map_err(|error| {
275 if let Some(code) = error.status_code {
276 let kind = match code {
277 404 => RegistryErrorKind::NotFound,
278 401 | 403 => RegistryErrorKind::Unauthorized,
279 500..=599 => RegistryErrorKind::ServerError,
280 _ => RegistryErrorKind::Other,
281 };
282 RegistryError {
283 message: format!(
284 "LemmaBase returned HTTP {} {} for '{}'",
285 code, source_url, display
286 ),
287 kind,
288 }
289 } else {
290 RegistryError {
291 message: format!(
292 "Failed to reach LemmaBase for '{}': {}",
293 display, error.message
294 ),
295 kind: RegistryErrorKind::NetworkError,
296 }
297 }
298 })?;
299
300 Ok(RegistryBundle {
301 lemma_source,
302 attribute: display,
303 })
304 }
305}
306
307#[cfg(feature = "registry")]
308impl Default for LemmaBase {
309 fn default() -> Self {
310 Self::new()
311 }
312}
313
314#[cfg(feature = "registry")]
315#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
316#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
317impl Registry for LemmaBase {
318 async fn fetch_specs(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
319 self.fetch_source(name).await
320 }
321
322 async fn fetch_types(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
323 self.fetch_source(name).await
324 }
325
326 fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
327 Some(self.navigation_url(name, effective))
328 }
329}
330
331pub async fn resolve_registry_references(
350 ctx: &mut Context,
351 sources: &mut HashMap<String, String>,
352 registry: &dyn Registry,
353 limits: &ResourceLimits,
354) -> Result<(), Vec<Error>> {
355 let mut already_requested: HashSet<(String, RegistryReferenceKind)> = HashSet::new();
356
357 loop {
358 let unresolved = collect_unresolved_registry_references(ctx, &already_requested);
359
360 if unresolved.is_empty() {
361 break;
362 }
363
364 let mut round_errors: Vec<Error> = Vec::new();
365 for reference in &unresolved {
366 let dedup = reference.dedup_key();
367 if already_requested.contains(&dedup) {
368 continue;
369 }
370 already_requested.insert(dedup);
371
372 let registry_name = reference.name.strip_prefix('@').unwrap_or(&reference.name);
373 let bundle_result = match reference.kind {
374 RegistryReferenceKind::Spec => registry.fetch_specs(registry_name).await,
375 RegistryReferenceKind::TypeImport => registry.fetch_types(registry_name).await,
376 };
377
378 let display_id = reference.name.clone();
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 Some(reference.source.clone()),
401 display_id,
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(specs) => 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: format!("@{}", identifier),
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()).unwrap();
610 let mut store = Context::new();
611 for spec in &local_specs {
612 store.insert_spec(Arc::new(spec.clone())).unwrap();
613 }
614 let mut sources = HashMap::new();
615 sources.insert("local.lemma".to_string(), source.to_string());
616
617 let registry = TestRegistry::new();
618 resolve_registry_references(
619 &mut store,
620 &mut sources,
621 ®istry,
622 &ResourceLimits::default(),
623 )
624 .await
625 .unwrap();
626
627 assert_eq!(store.len(), 1);
628 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
629 assert_eq!(names, ["example"]);
630 }
631
632 #[tokio::test]
633 async fn resolve_fetches_single_spec_from_registry() {
634 let local_source = r#"spec main_spec
635fact external: spec @org/project/helper
636rule value: external.quantity"#;
637 let local_specs =
638 crate::parse(local_source, "local.lemma", &ResourceLimits::default()).unwrap();
639 let mut store = Context::new();
640 for spec in local_specs {
641 store.insert_spec(Arc::new(spec)).unwrap();
642 }
643 let mut sources = HashMap::new();
644 sources.insert("local.lemma".to_string(), local_source.to_string());
645
646 let mut registry = TestRegistry::new();
647 registry.add_spec_bundle(
648 "org/project/helper",
649 r#"spec @org/project/helper
650fact quantity: 42"#,
651 );
652
653 resolve_registry_references(
654 &mut store,
655 &mut sources,
656 ®istry,
657 &ResourceLimits::default(),
658 )
659 .await
660 .unwrap();
661
662 assert_eq!(store.len(), 2);
663 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
664 assert!(names.iter().any(|n| n == "main_spec"));
665 assert!(names.iter().any(|n| n == "@org/project/helper"));
666 }
667
668 #[tokio::test]
669 async fn fetch_specs_returns_all_zones_and_url_for_id_supports_effective() {
670 let effective = DateTimeValue {
671 year: 2026,
672 month: 1,
673 day: 15,
674 hour: 0,
675 minute: 0,
676 second: 0,
677 microsecond: 0,
678 timezone: None,
679 };
680 let mut registry = TestRegistry::new();
681 registry.add_spec_bundle(
682 "org/spec",
683 "spec org/spec 2025-01-01\nfact x: 1\n\nspec org/spec 2026-01-15\nfact x: 2",
684 );
685
686 let bundle = registry.fetch_specs("org/spec").await.unwrap();
687 assert!(bundle.lemma_source.contains("fact x: 1"));
688 assert!(bundle.lemma_source.contains("fact x: 2"));
689
690 assert_eq!(
691 registry.url_for_id("org/spec", None),
692 Some("https://test.registry/org/spec".to_string())
693 );
694 assert_eq!(
695 registry.url_for_id("org/spec", Some(&effective)),
696 Some("https://test.registry/org/spec?effective=2026-01-15".to_string())
697 );
698 }
699
700 #[tokio::test]
701 async fn resolve_fetches_transitive_dependencies() {
702 let local_source = r#"spec main_spec
703fact a: spec @org/project/spec_a"#;
704 let local_specs =
705 crate::parse(local_source, "local.lemma", &ResourceLimits::default()).unwrap();
706 let mut store = Context::new();
707 for spec in local_specs {
708 store.insert_spec(Arc::new(spec)).unwrap();
709 }
710 let mut sources = HashMap::new();
711 sources.insert("local.lemma".to_string(), local_source.to_string());
712
713 let mut registry = TestRegistry::new();
714 registry.add_spec_bundle(
716 "org/project/spec_a",
717 r#"spec @org/project/spec_a
718fact b: spec @org/project/spec_b"#,
719 );
720 registry.add_spec_bundle(
721 "org/project/spec_b",
722 r#"spec @org/project/spec_b
723fact value: 99"#,
724 );
725
726 resolve_registry_references(
727 &mut store,
728 &mut sources,
729 ®istry,
730 &ResourceLimits::default(),
731 )
732 .await
733 .unwrap();
734
735 assert_eq!(store.len(), 3);
736 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
737 assert!(names.iter().any(|n| n == "main_spec"));
738 assert!(names.iter().any(|n| n == "@org/project/spec_a"));
739 assert!(names.iter().any(|n| n == "@org/project/spec_b"));
740 }
741
742 #[tokio::test]
743 async fn resolve_handles_bundle_with_multiple_specs() {
744 let local_source = r#"spec main_spec
745fact a: spec @org/project/spec_a"#;
746 let local_specs =
747 crate::parse(local_source, "local.lemma", &ResourceLimits::default()).unwrap();
748 let mut store = Context::new();
749 for spec in local_specs {
750 store.insert_spec(Arc::new(spec)).unwrap();
751 }
752 let mut sources = HashMap::new();
753 sources.insert("local.lemma".to_string(), local_source.to_string());
754
755 let mut registry = TestRegistry::new();
756 registry.add_spec_bundle(
758 "org/project/spec_a",
759 r#"spec @org/project/spec_a
760fact b: spec @org/project/spec_b
761
762spec @org/project/spec_b
763fact value: 99"#,
764 );
765
766 resolve_registry_references(
767 &mut store,
768 &mut sources,
769 ®istry,
770 &ResourceLimits::default(),
771 )
772 .await
773 .unwrap();
774
775 assert_eq!(store.len(), 3);
776 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
777 assert!(names.iter().any(|n| n == "main_spec"));
778 assert!(names.iter().any(|n| n == "@org/project/spec_a"));
779 assert!(names.iter().any(|n| n == "@org/project/spec_b"));
780 }
781
782 #[tokio::test]
783 async fn resolve_returns_registry_error_when_registry_fails() {
784 let local_source = r#"spec main_spec
785fact external: spec @org/project/missing"#;
786 let local_specs =
787 crate::parse(local_source, "local.lemma", &ResourceLimits::default()).unwrap();
788 let mut store = Context::new();
789 for spec in local_specs {
790 store.insert_spec(Arc::new(spec)).unwrap();
791 }
792 let mut sources = HashMap::new();
793 sources.insert("local.lemma".to_string(), local_source.to_string());
794
795 let registry = TestRegistry::new(); let result = resolve_registry_references(
798 &mut store,
799 &mut sources,
800 ®istry,
801 &ResourceLimits::default(),
802 )
803 .await;
804
805 assert!(result.is_err(), "Should fail when Registry cannot resolve");
806 let errs = result.unwrap_err();
807 let registry_err = errs
808 .iter()
809 .find(|e| matches!(e, Error::Registry { .. }))
810 .expect("expected at least one Registry error");
811 match registry_err {
812 Error::Registry {
813 identifier,
814 kind,
815 details,
816 } => {
817 assert_eq!(identifier, "@org/project/missing");
818 assert_eq!(*kind, RegistryErrorKind::NotFound);
819 assert!(
820 details.suggestion.is_some(),
821 "NotFound errors should include a suggestion"
822 );
823 }
824 _ => unreachable!(),
825 }
826
827 let error_message = errs
828 .iter()
829 .map(|e| e.to_string())
830 .collect::<Vec<_>>()
831 .join(" ");
832 assert!(
833 error_message.contains("org/project/missing"),
834 "Error should mention the identifier: {}",
835 error_message
836 );
837 }
838
839 #[tokio::test]
840 async fn resolve_returns_all_registry_errors_when_multiple_refs_fail() {
841 let local_source = r#"spec main_spec
842fact helper: spec @org/example/helper
843type money from @lemma/std/finance"#;
844 let local_specs =
845 crate::parse(local_source, "local.lemma", &ResourceLimits::default()).unwrap();
846 let mut store = Context::new();
847 for spec in local_specs {
848 store.insert_spec(Arc::new(spec)).unwrap();
849 }
850 let mut sources = HashMap::new();
851 sources.insert("local.lemma".to_string(), local_source.to_string());
852
853 let registry = TestRegistry::new(); let result = resolve_registry_references(
856 &mut store,
857 &mut sources,
858 ®istry,
859 &ResourceLimits::default(),
860 )
861 .await;
862
863 assert!(result.is_err(), "Should fail when Registry cannot resolve");
864 let errors = result.unwrap_err();
865 assert_eq!(
866 errors.len(),
867 2,
868 "Both spec ref and type import ref should produce a Registry error"
869 );
870 let identifiers: Vec<&str> = errors
871 .iter()
872 .filter_map(|e| {
873 if let Error::Registry { identifier, .. } = e {
874 Some(identifier.as_str())
875 } else {
876 None
877 }
878 })
879 .collect();
880 assert!(
881 identifiers.contains(&"@org/example/helper"),
882 "Should include spec ref error: {:?}",
883 identifiers
884 );
885 assert!(
886 identifiers.contains(&"@lemma/std/finance"),
887 "Should include type import error: {:?}",
888 identifiers
889 );
890 }
891
892 #[tokio::test]
893 async fn resolve_does_not_request_same_identifier_twice() {
894 let local_source = r#"spec spec_one
895fact a: spec @org/shared
896
897spec spec_two
898fact b: spec @org/shared"#;
899 let local_specs =
900 crate::parse(local_source, "local.lemma", &ResourceLimits::default()).unwrap();
901 let mut store = Context::new();
902 for spec in local_specs {
903 store.insert_spec(Arc::new(spec)).unwrap();
904 }
905 let mut sources = HashMap::new();
906 sources.insert("local.lemma".to_string(), local_source.to_string());
907
908 let mut registry = TestRegistry::new();
909 registry.add_spec_bundle(
910 "org/shared",
911 r#"spec @org/shared
912fact value: 1"#,
913 );
914
915 resolve_registry_references(
916 &mut store,
917 &mut sources,
918 ®istry,
919 &ResourceLimits::default(),
920 )
921 .await
922 .unwrap();
923
924 assert_eq!(store.len(), 3);
926 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
927 assert!(names.iter().any(|n| n == "@org/shared"));
928 }
929
930 #[tokio::test]
931 async fn resolve_handles_type_import_from_registry() {
932 let local_source = r#"spec main_spec
933type money from @lemma/std/finance
934fact price: [money]"#;
935 let local_specs =
936 crate::parse(local_source, "local.lemma", &ResourceLimits::default()).unwrap();
937 let mut store = Context::new();
938 for spec in local_specs {
939 store.insert_spec(Arc::new(spec)).unwrap();
940 }
941 let mut sources = HashMap::new();
942 sources.insert("local.lemma".to_string(), local_source.to_string());
943
944 let mut registry = TestRegistry::new();
945 registry.add_spec_bundle(
946 "lemma/std/finance",
947 r#"spec @lemma/std/finance
948type money: scale
949 -> unit eur 1.00
950 -> unit usd 1.10
951 -> decimals 2"#,
952 );
953
954 resolve_registry_references(
955 &mut store,
956 &mut sources,
957 ®istry,
958 &ResourceLimits::default(),
959 )
960 .await
961 .unwrap();
962
963 assert_eq!(store.len(), 2);
964 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
965 assert!(names.iter().any(|n| n == "main_spec"));
966 assert!(names.iter().any(|n| n == "@lemma/std/finance"));
967 }
968
969 #[cfg(feature = "registry")]
974 mod lemmabase_tests {
975 use super::super::*;
976 use std::sync::{Arc, Mutex};
977
978 type HttpFetchHandler = Box<dyn Fn(&str) -> Result<String, HttpFetchError> + Send + Sync>;
983
984 struct MockHttpFetcher {
985 handler: HttpFetchHandler,
986 }
987
988 impl MockHttpFetcher {
989 fn with_handler(
991 handler: impl Fn(&str) -> Result<String, HttpFetchError> + Send + Sync + 'static,
992 ) -> Self {
993 Self {
994 handler: Box::new(handler),
995 }
996 }
997
998 fn always_returning(body: &str) -> Self {
1000 let body = body.to_string();
1001 Self::with_handler(move |_| Ok(body.clone()))
1002 }
1003
1004 fn always_failing_with_status(code: u16) -> Self {
1006 Self::with_handler(move |_| {
1007 Err(HttpFetchError {
1008 status_code: Some(code),
1009 message: format!("HTTP {}", code),
1010 })
1011 })
1012 }
1013
1014 fn always_failing_with_network_error(msg: &str) -> Self {
1016 let msg = msg.to_string();
1017 Self::with_handler(move |_| {
1018 Err(HttpFetchError {
1019 status_code: None,
1020 message: msg.clone(),
1021 })
1022 })
1023 }
1024 }
1025
1026 #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
1027 #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
1028 impl HttpFetcher for MockHttpFetcher {
1029 async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
1030 (self.handler)(url)
1031 }
1032 }
1033
1034 #[test]
1039 fn source_url_without_effective() {
1040 let registry = LemmaBase::new();
1041 let url = registry.source_url("user/workspace/somespec", None);
1042 assert_eq!(
1043 url,
1044 format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1045 );
1046 }
1047
1048 #[test]
1049 fn source_url_with_effective() {
1050 let registry = LemmaBase::new();
1051 let effective = DateTimeValue {
1052 year: 2026,
1053 month: 1,
1054 day: 15,
1055 hour: 0,
1056 minute: 0,
1057 second: 0,
1058 microsecond: 0,
1059 timezone: None,
1060 };
1061 let url = registry.source_url("user/workspace/somespec", Some(&effective));
1062 assert_eq!(
1063 url,
1064 format!(
1065 "{}/@user/workspace/somespec.lemma?effective=2026-01-15",
1066 LemmaBase::BASE_URL
1067 )
1068 );
1069 }
1070
1071 #[test]
1072 fn source_url_for_deeply_nested_identifier() {
1073 let registry = LemmaBase::new();
1074 let url = registry.source_url("org/team/project/subdir/spec", None);
1075 assert_eq!(
1076 url,
1077 format!(
1078 "{}/@org/team/project/subdir/spec.lemma",
1079 LemmaBase::BASE_URL
1080 )
1081 );
1082 }
1083
1084 #[test]
1085 fn navigation_url_without_effective() {
1086 let registry = LemmaBase::new();
1087 let url = registry.navigation_url("user/workspace/somespec", None);
1088 assert_eq!(
1089 url,
1090 format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL)
1091 );
1092 }
1093
1094 #[test]
1095 fn navigation_url_with_effective() {
1096 let registry = LemmaBase::new();
1097 let effective = DateTimeValue {
1098 year: 2026,
1099 month: 1,
1100 day: 15,
1101 hour: 0,
1102 minute: 0,
1103 second: 0,
1104 microsecond: 0,
1105 timezone: None,
1106 };
1107 let url = registry.navigation_url("user/workspace/somespec", Some(&effective));
1108 assert_eq!(
1109 url,
1110 format!(
1111 "{}/@user/workspace/somespec?effective=2026-01-15",
1112 LemmaBase::BASE_URL
1113 )
1114 );
1115 }
1116
1117 #[test]
1118 fn navigation_url_for_deeply_nested_identifier() {
1119 let registry = LemmaBase::new();
1120 let url = registry.navigation_url("org/team/project/subdir/spec", None);
1121 assert_eq!(
1122 url,
1123 format!("{}/@org/team/project/subdir/spec", LemmaBase::BASE_URL)
1124 );
1125 }
1126
1127 #[test]
1128 fn url_for_id_returns_navigation_url() {
1129 let registry = LemmaBase::new();
1130 let url = registry.url_for_id("user/workspace/somespec", None);
1131 assert_eq!(
1132 url,
1133 Some(format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL))
1134 );
1135 }
1136
1137 #[test]
1138 fn url_for_id_with_effective() {
1139 let registry = LemmaBase::new();
1140 let effective = DateTimeValue {
1141 year: 2026,
1142 month: 1,
1143 day: 1,
1144 hour: 0,
1145 minute: 0,
1146 second: 0,
1147 microsecond: 0,
1148 timezone: None,
1149 };
1150 let url = registry.url_for_id("owner/repo/spec", Some(&effective));
1151 assert_eq!(
1152 url,
1153 Some(format!(
1154 "{}/@owner/repo/spec?effective=2026-01-01",
1155 LemmaBase::BASE_URL
1156 ))
1157 );
1158 }
1159
1160 #[test]
1161 fn url_for_id_returns_navigation_url_for_nested_path() {
1162 let registry = LemmaBase::new();
1163 let url = registry.url_for_id("lemma/std/finance", None);
1164 assert_eq!(
1165 url,
1166 Some(format!("{}/@lemma/std/finance", LemmaBase::BASE_URL))
1167 );
1168 }
1169
1170 #[test]
1171 fn default_trait_creates_same_instance_as_new() {
1172 let from_new = LemmaBase::new();
1173 let from_default = LemmaBase::default();
1174 assert_eq!(
1175 from_new.url_for_id("test/spec", None),
1176 from_default.url_for_id("test/spec", None)
1177 );
1178 }
1179
1180 #[tokio::test]
1185 async fn fetch_source_returns_bundle_on_success() {
1186 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1187 "spec org/my_spec\nfact x: 1",
1188 )));
1189
1190 let bundle = registry.fetch_source("org/my_spec").await.unwrap();
1191
1192 assert_eq!(bundle.lemma_source, "spec org/my_spec\nfact x: 1");
1193 assert_eq!(bundle.attribute, "@org/my_spec");
1194 }
1195
1196 #[tokio::test]
1197 async fn fetch_source_passes_correct_url_to_fetcher() {
1198 let captured_url = Arc::new(Mutex::new(String::new()));
1199 let captured = captured_url.clone();
1200 let mock = MockHttpFetcher::with_handler(move |url| {
1201 *captured.lock().unwrap() = url.to_string();
1202 Ok("spec test/spec\nfact x: 1".to_string())
1203 });
1204 let registry = LemmaBase::with_fetcher(Box::new(mock));
1205
1206 let _ = registry.fetch_source("user/workspace/somespec").await;
1207
1208 assert_eq!(
1209 *captured_url.lock().unwrap(),
1210 format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1211 );
1212 }
1213
1214 #[tokio::test]
1215 async fn fetch_source_maps_http_404_to_not_found() {
1216 let registry =
1217 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(404)));
1218
1219 let err = registry.fetch_source("org/missing").await.unwrap_err();
1220
1221 assert_eq!(err.kind, RegistryErrorKind::NotFound);
1222 assert!(
1223 err.message.contains("HTTP 404"),
1224 "Expected 'HTTP 404' in: {}",
1225 err.message
1226 );
1227 assert!(
1228 err.message.contains("@org/missing"),
1229 "Expected '@org/missing' in: {}",
1230 err.message
1231 );
1232 }
1233
1234 #[tokio::test]
1235 async fn fetch_source_maps_http_500_to_server_error() {
1236 let registry =
1237 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(500)));
1238
1239 let err = registry.fetch_source("org/broken").await.unwrap_err();
1240
1241 assert_eq!(err.kind, RegistryErrorKind::ServerError);
1242 assert!(
1243 err.message.contains("HTTP 500"),
1244 "Expected 'HTTP 500' in: {}",
1245 err.message
1246 );
1247 }
1248
1249 #[tokio::test]
1250 async fn fetch_source_maps_http_502_to_server_error() {
1251 let registry =
1252 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(502)));
1253
1254 let err = registry.fetch_source("org/broken").await.unwrap_err();
1255
1256 assert_eq!(err.kind, RegistryErrorKind::ServerError);
1257 }
1258
1259 #[tokio::test]
1260 async fn fetch_source_maps_http_401_to_unauthorized() {
1261 let registry =
1262 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(401)));
1263
1264 let err = registry.fetch_source("org/secret").await.unwrap_err();
1265
1266 assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1267 assert!(err.message.contains("HTTP 401"));
1268 }
1269
1270 #[tokio::test]
1271 async fn fetch_source_maps_http_403_to_unauthorized() {
1272 let registry =
1273 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(403)));
1274
1275 let err = registry.fetch_source("org/private").await.unwrap_err();
1276
1277 assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1278 assert!(
1279 err.message.contains("HTTP 403"),
1280 "Expected 'HTTP 403' in: {}",
1281 err.message
1282 );
1283 }
1284
1285 #[tokio::test]
1286 async fn fetch_source_maps_unexpected_status_to_other() {
1287 let registry =
1288 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(418)));
1289
1290 let err = registry.fetch_source("org/teapot").await.unwrap_err();
1291
1292 assert_eq!(err.kind, RegistryErrorKind::Other);
1293 assert!(err.message.contains("HTTP 418"));
1294 }
1295
1296 #[tokio::test]
1297 async fn fetch_source_maps_network_error_to_network_error_kind() {
1298 let registry = LemmaBase::with_fetcher(Box::new(
1299 MockHttpFetcher::always_failing_with_network_error("connection refused"),
1300 ));
1301
1302 let err = registry.fetch_source("org/unreachable").await.unwrap_err();
1303
1304 assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1305 assert!(
1306 err.message.contains("connection refused"),
1307 "Expected 'connection refused' in: {}",
1308 err.message
1309 );
1310 assert!(
1311 err.message.contains("@org/unreachable"),
1312 "Expected '@org/unreachable' in: {}",
1313 err.message
1314 );
1315 }
1316
1317 #[tokio::test]
1318 async fn fetch_source_maps_dns_error_to_network_error_kind() {
1319 let registry = LemmaBase::with_fetcher(Box::new(
1320 MockHttpFetcher::always_failing_with_network_error(
1321 "dns error: failed to lookup address",
1322 ),
1323 ));
1324
1325 let err = registry.fetch_source("org/spec").await.unwrap_err();
1326
1327 assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1328 assert!(
1329 err.message.contains("dns error"),
1330 "Expected 'dns error' in: {}",
1331 err.message
1332 );
1333 assert!(
1334 err.message.contains("Failed to reach LemmaBase"),
1335 "Expected 'Failed to reach LemmaBase' in: {}",
1336 err.message
1337 );
1338 }
1339
1340 #[tokio::test]
1345 async fn fetch_specs_delegates_to_fetch_source() {
1346 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1347 "spec org/resolved\nfact a: 1",
1348 )));
1349
1350 let bundle = registry.fetch_specs("org/resolved").await.unwrap();
1351
1352 assert_eq!(bundle.lemma_source, "spec org/resolved\nfact a: 1");
1353 assert_eq!(bundle.attribute, "@org/resolved");
1354 }
1355
1356 #[tokio::test]
1357 async fn fetch_types_delegates_to_fetch_source() {
1358 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1359 "spec lemma/std/finance\ntype money: scale\n -> unit eur 1.00",
1360 )));
1361
1362 let bundle = registry.fetch_types("lemma/std/finance").await.unwrap();
1363
1364 assert_eq!(bundle.attribute, "@lemma/std/finance");
1365 assert!(
1366 bundle.lemma_source.contains("type money: scale"),
1367 "Expected source to contain 'type money: scale': {}",
1368 bundle.lemma_source
1369 );
1370 }
1371
1372 #[tokio::test]
1373 async fn fetch_specs_propagates_http_error() {
1374 let registry =
1375 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(404)));
1376
1377 let err = registry.fetch_specs("org/missing").await.unwrap_err();
1378
1379 assert!(err.message.contains("HTTP 404"));
1380 }
1381
1382 #[tokio::test]
1383 async fn fetch_types_propagates_network_error() {
1384 let registry = LemmaBase::with_fetcher(Box::new(
1385 MockHttpFetcher::always_failing_with_network_error("timeout"),
1386 ));
1387
1388 let err = registry.fetch_types("lemma/std/types").await.unwrap_err();
1389
1390 assert!(err.message.contains("timeout"));
1391 }
1392
1393 #[tokio::test]
1394 async fn fetch_source_returns_empty_body_as_valid_bundle() {
1395 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning("")));
1396
1397 let bundle = registry.fetch_source("org/empty").await.unwrap();
1398
1399 assert_eq!(bundle.lemma_source, "");
1400 assert_eq!(bundle.attribute, "@org/empty");
1401 }
1402 }
1403}