1use crate::error::LemmaError;
14use crate::limits::ResourceLimits;
15use crate::parsing::ast::{FactValue, LemmaDoc, TypeDef};
16use crate::parsing::source::Source;
17use std::collections::{HashMap, HashSet};
18use std::fmt;
19
20#[derive(Debug, Clone)]
30pub struct RegistryBundle {
31 pub lemma_source: String,
35
36 pub attribute: String,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum RegistryErrorKind {
48 NotFound,
50 Unauthorized,
52 NetworkError,
54 ServerError,
56 Other,
58}
59
60impl fmt::Display for RegistryErrorKind {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 match self {
63 Self::NotFound => write!(f, "not found"),
64 Self::Unauthorized => write!(f, "unauthorized"),
65 Self::NetworkError => write!(f, "network error"),
66 Self::ServerError => write!(f, "server error"),
67 Self::Other => write!(f, "error"),
68 }
69 }
70}
71
72#[derive(Debug, Clone)]
74pub struct RegistryError {
75 pub message: String,
76 pub kind: RegistryErrorKind,
77}
78
79impl fmt::Display for RegistryError {
80 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
81 write!(formatter, "{}", self.message)
82 }
83}
84
85impl std::error::Error for RegistryError {}
86
87#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
95#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
96pub trait Registry: Send + Sync {
97 async fn resolve_doc(&self, identifier: &str) -> Result<RegistryBundle, RegistryError>;
105
106 async fn resolve_type(&self, identifier: &str) -> Result<RegistryBundle, RegistryError>;
115
116 fn url_for_id(&self, identifier: &str) -> Option<String>;
122}
123
124#[cfg(feature = "registry")]
135struct HttpFetchError {
136 status_code: Option<u16>,
138 message: String,
140}
141
142#[cfg(feature = "registry")]
146#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
147#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
148trait HttpFetcher: Send + Sync {
149 async fn get(&self, url: &str) -> Result<String, HttpFetchError>;
150}
151
152#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
154struct ReqwestHttpFetcher;
155
156#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
157#[async_trait::async_trait]
158impl HttpFetcher for ReqwestHttpFetcher {
159 async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
160 let response = reqwest::get(url).await.map_err(|e| HttpFetchError {
161 status_code: e.status().map(|s| s.as_u16()),
162 message: e.to_string(),
163 })?;
164 let status = response.status();
165 let body = response.text().await.map_err(|e| HttpFetchError {
166 status_code: None,
167 message: e.to_string(),
168 })?;
169 if !status.is_success() {
170 return Err(HttpFetchError {
171 status_code: Some(status.as_u16()),
172 message: format!("HTTP {}", status),
173 });
174 }
175 Ok(body)
176 }
177}
178
179#[cfg(all(feature = "registry", target_arch = "wasm32"))]
181struct WasmHttpFetcher;
182
183#[cfg(all(feature = "registry", target_arch = "wasm32"))]
184#[async_trait::async_trait(?Send)]
185impl HttpFetcher for WasmHttpFetcher {
186 async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
187 let response = gloo_net::http::Request::get(url)
188 .send()
189 .await
190 .map_err(|e| HttpFetchError {
191 status_code: None,
192 message: e.to_string(),
193 })?;
194 let status = response.status();
195 let ok = response.ok();
196 if !ok {
197 return Err(HttpFetchError {
198 status_code: Some(status),
199 message: format!("HTTP {}", status),
200 });
201 }
202 let text = response.text().await.map_err(|e| HttpFetchError {
203 status_code: None,
204 message: e.to_string(),
205 })?;
206 Ok(text)
207 }
208}
209
210#[cfg(feature = "registry")]
224pub struct LemmaBase {
225 fetcher: Box<dyn HttpFetcher>,
226}
227
228#[cfg(feature = "registry")]
229impl LemmaBase {
230 const BASE_URL: &'static str = "https://lemmabase.com";
232
233 pub fn new() -> Self {
235 Self {
236 #[cfg(not(target_arch = "wasm32"))]
237 fetcher: Box::new(ReqwestHttpFetcher),
238 #[cfg(target_arch = "wasm32")]
239 fetcher: Box::new(WasmHttpFetcher),
240 }
241 }
242
243 #[cfg(test)]
245 fn with_fetcher(fetcher: Box<dyn HttpFetcher>) -> Self {
246 Self { fetcher }
247 }
248
249 fn source_url_for_identifier(&self, identifier: &str) -> String {
254 format!("{}/@{}.lemma", Self::BASE_URL, identifier)
255 }
256
257 fn navigation_url_for_identifier(&self, identifier: &str) -> String {
262 format!("{}/@{}", Self::BASE_URL, identifier)
263 }
264
265 async fn fetch_source(&self, identifier: &str) -> Result<RegistryBundle, RegistryError> {
271 let url = self.source_url_for_identifier(identifier);
272
273 let lemma_source = self.fetcher.get(&url).await.map_err(|error| {
274 if let Some(code) = error.status_code {
275 let kind = match code {
276 404 => RegistryErrorKind::NotFound,
277 401 | 403 => RegistryErrorKind::Unauthorized,
278 500..=599 => RegistryErrorKind::ServerError,
279 _ => RegistryErrorKind::Other,
280 };
281 RegistryError {
282 message: format!("LemmaBase returned HTTP {} for '@{}'", code, identifier),
283 kind,
284 }
285 } else {
286 RegistryError {
287 message: format!(
288 "Failed to reach LemmaBase for '@{}': {}",
289 identifier, error.message
290 ),
291 kind: RegistryErrorKind::NetworkError,
292 }
293 }
294 })?;
295
296 Ok(RegistryBundle {
297 lemma_source,
298 attribute: format!("@{}", identifier),
299 })
300 }
301}
302
303#[cfg(feature = "registry")]
304impl Default for LemmaBase {
305 fn default() -> Self {
306 Self::new()
307 }
308}
309
310#[cfg(feature = "registry")]
311#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
312#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
313impl Registry for LemmaBase {
314 async fn resolve_doc(&self, identifier: &str) -> Result<RegistryBundle, RegistryError> {
315 self.fetch_source(identifier).await
316 }
317
318 async fn resolve_type(&self, identifier: &str) -> Result<RegistryBundle, RegistryError> {
319 self.fetch_source(identifier).await
320 }
321
322 fn url_for_id(&self, identifier: &str) -> Option<String> {
323 Some(self.navigation_url_for_identifier(identifier))
324 }
325}
326
327pub async fn resolve_registry_references(
346 local_docs: Vec<LemmaDoc>,
347 sources: &mut HashMap<String, String>,
348 registry: &dyn Registry,
349 limits: &ResourceLimits,
350) -> Result<Vec<LemmaDoc>, LemmaError> {
351 let mut all_docs = local_docs;
352 let mut already_requested: HashSet<String> = HashSet::new();
353
354 let mut known_document_names: HashSet<String> =
356 all_docs.iter().map(|doc| doc.name.clone()).collect();
357
358 loop {
359 let unresolved = collect_unresolved_registry_references(
360 &all_docs,
361 &known_document_names,
362 &already_requested,
363 );
364
365 if unresolved.is_empty() {
366 break;
367 }
368
369 let mut round_errors: Vec<LemmaError> = Vec::new();
370 for reference in &unresolved {
371 if already_requested.contains(&reference.identifier) {
372 continue;
373 }
374 already_requested.insert(reference.identifier.clone());
375
376 let bundle_result = match reference.kind {
377 RegistryReferenceKind::Document => {
378 registry.resolve_doc(&reference.identifier).await
379 }
380 RegistryReferenceKind::TypeImport => {
381 registry.resolve_type(&reference.identifier).await
382 }
383 };
384
385 let bundle = match bundle_result {
386 Ok(b) => b,
387 Err(registry_error) => {
388 let suggestion = match ®istry_error.kind {
389 RegistryErrorKind::NotFound => Some(
390 "Check that the identifier is spelled correctly and that the document exists on the registry.".to_string(),
391 ),
392 RegistryErrorKind::Unauthorized => Some(
393 "Check your authentication credentials or permissions for this registry.".to_string(),
394 ),
395 RegistryErrorKind::NetworkError => Some(
396 "Check your network connection. To compile without registry access, disable the 'registry' feature.".to_string(),
397 ),
398 RegistryErrorKind::ServerError => Some(
399 "The registry server returned an internal error. Try again later.".to_string(),
400 ),
401 RegistryErrorKind::Other => None,
402 };
403 round_errors.push(LemmaError::registry(
404 registry_error.message,
405 Some(reference.source.clone()),
406 reference.identifier.clone(),
407 registry_error.kind,
408 suggestion,
409 ));
410 continue;
411 }
412 };
413
414 sources.insert(bundle.attribute.clone(), bundle.lemma_source.clone());
415
416 let new_docs = crate::parsing::parse(&bundle.lemma_source, &bundle.attribute, limits)?;
417
418 for doc in new_docs {
419 known_document_names.insert(doc.name.clone());
420 all_docs.push(doc);
421 }
422 }
423
424 if !round_errors.is_empty() {
425 return Err(LemmaError::MultipleErrors(round_errors));
426 }
427 }
428
429 Ok(all_docs)
430}
431
432#[derive(Debug, Clone, PartialEq, Eq, Hash)]
434enum RegistryReferenceKind {
435 Document,
436 TypeImport,
437}
438
439#[derive(Debug, Clone)]
441struct RegistryReference {
442 identifier: String,
443 kind: RegistryReferenceKind,
444 source: Source,
445}
446
447fn collect_unresolved_registry_references(
456 docs: &[LemmaDoc],
457 known_document_names: &HashSet<String>,
458 already_requested: &HashSet<String>,
459) -> Vec<RegistryReference> {
460 let mut unresolved: Vec<RegistryReference> = Vec::new();
461 let mut seen_in_this_round: HashSet<(String, RegistryReferenceKind)> = HashSet::new();
462
463 for doc in docs {
464 if doc.attribute.is_none() {
465 let has_registry_refs =
466 doc.facts.iter().any(
467 |f| matches!(&f.value, FactValue::DocumentReference(ref r) if r.is_registry),
468 ) || doc
469 .types
470 .iter()
471 .any(|t| matches!(t, TypeDef::Import { from, .. } if from.is_registry));
472 if has_registry_refs {
473 panic!(
474 "BUG: document '{}' must have source attribute when it has registry references",
475 doc.name
476 );
477 }
478 continue;
479 }
480
481 for fact in &doc.facts {
484 if let FactValue::DocumentReference(doc_ref) = &fact.value {
485 if !doc_ref.is_registry {
486 continue;
487 }
488 let identifier = &doc_ref.name;
489 if !known_document_names.contains(identifier.as_str())
490 && !already_requested.contains(identifier.as_str())
491 && seen_in_this_round
492 .insert((identifier.clone(), RegistryReferenceKind::Document))
493 {
494 unresolved.push(RegistryReference {
495 identifier: identifier.clone(),
496 kind: RegistryReferenceKind::Document,
497 source: fact.source_location.clone(),
498 });
499 }
500 }
501 }
502
503 for type_def in &doc.types {
506 if let TypeDef::Import {
507 from,
508 source_location,
509 ..
510 } = type_def
511 {
512 if !from.is_registry {
513 continue;
514 }
515 let identifier = &from.name;
516 if !known_document_names.contains(identifier.as_str())
517 && !already_requested.contains(identifier.as_str())
518 && seen_in_this_round
519 .insert((identifier.clone(), RegistryReferenceKind::TypeImport))
520 {
521 unresolved.push(RegistryReference {
522 identifier: identifier.clone(),
523 kind: RegistryReferenceKind::TypeImport,
524 source: source_location.clone(),
525 });
526 }
527 }
528 }
529 }
530
531 unresolved
532}
533
534#[cfg(test)]
539mod tests {
540 use super::*;
541
542 struct TestRegistry {
544 bundles: HashMap<String, RegistryBundle>,
545 }
546
547 impl TestRegistry {
548 fn new() -> Self {
549 Self {
550 bundles: HashMap::new(),
551 }
552 }
553
554 fn add_doc_bundle(&mut self, identifier: &str, lemma_source: &str) {
555 self.bundles.insert(
556 identifier.to_string(),
557 RegistryBundle {
558 lemma_source: lemma_source.to_string(),
559 attribute: format!("@{}", identifier),
560 },
561 );
562 }
563 }
564
565 #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
566 #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
567 impl Registry for TestRegistry {
568 async fn resolve_doc(&self, identifier: &str) -> Result<RegistryBundle, RegistryError> {
569 self.bundles
570 .get(identifier)
571 .cloned()
572 .ok_or_else(|| RegistryError {
573 message: format!("Document '{}' not found in test registry", identifier),
574 kind: RegistryErrorKind::NotFound,
575 })
576 }
577
578 async fn resolve_type(&self, identifier: &str) -> Result<RegistryBundle, RegistryError> {
579 self.bundles
580 .get(identifier)
581 .cloned()
582 .ok_or_else(|| RegistryError {
583 message: format!("Type source '{}' not found in test registry", identifier),
584 kind: RegistryErrorKind::NotFound,
585 })
586 }
587
588 fn url_for_id(&self, identifier: &str) -> Option<String> {
589 Some(format!("https://test.registry/{}", identifier))
590 }
591 }
592
593 #[tokio::test]
594 async fn resolve_with_no_registry_references_returns_local_docs_unchanged() {
595 let source = r#"doc example
596fact price = 100"#;
597 let local_docs = crate::parse(source, "local.lemma", &ResourceLimits::default()).unwrap();
598 let mut sources = HashMap::new();
599 sources.insert("local.lemma".to_string(), source.to_string());
600
601 let registry = TestRegistry::new();
602 let result = resolve_registry_references(
603 local_docs.clone(),
604 &mut sources,
605 ®istry,
606 &ResourceLimits::default(),
607 )
608 .await
609 .unwrap();
610
611 assert_eq!(result.len(), 1);
612 assert_eq!(result[0].name, "example");
613 }
614
615 #[tokio::test]
616 async fn resolve_fetches_single_doc_from_registry() {
617 let local_source = r#"doc main_doc
618fact external = doc @org/project/helper
619rule value = external.quantity"#;
620 let local_docs =
621 crate::parse(local_source, "local.lemma", &ResourceLimits::default()).unwrap();
622 let mut sources = HashMap::new();
623 sources.insert("local.lemma".to_string(), local_source.to_string());
624
625 let mut registry = TestRegistry::new();
626 registry.add_doc_bundle(
627 "org/project/helper",
628 r#"doc org/project/helper
629fact quantity = 42"#,
630 );
631
632 let result = resolve_registry_references(
633 local_docs,
634 &mut sources,
635 ®istry,
636 &ResourceLimits::default(),
637 )
638 .await
639 .unwrap();
640
641 assert_eq!(result.len(), 2);
642 let names: Vec<&str> = result.iter().map(|d| d.name.as_str()).collect();
643 assert!(names.contains(&"main_doc"));
644 assert!(names.contains(&"org/project/helper"));
645 }
646
647 #[tokio::test]
648 async fn resolve_fetches_transitive_dependencies() {
649 let local_source = r#"doc main_doc
650fact a = doc @org/project/doc_a"#;
651 let local_docs =
652 crate::parse(local_source, "local.lemma", &ResourceLimits::default()).unwrap();
653 let mut sources = HashMap::new();
654 sources.insert("local.lemma".to_string(), local_source.to_string());
655
656 let mut registry = TestRegistry::new();
657 registry.add_doc_bundle(
659 "org/project/doc_a",
660 r#"doc org/project/doc_a
661fact b = doc @org/project/doc_b"#,
662 );
663 registry.add_doc_bundle(
664 "org/project/doc_b",
665 r#"doc org/project/doc_b
666fact value = 99"#,
667 );
668
669 let result = resolve_registry_references(
670 local_docs,
671 &mut sources,
672 ®istry,
673 &ResourceLimits::default(),
674 )
675 .await
676 .unwrap();
677
678 assert_eq!(result.len(), 3);
679 let names: Vec<&str> = result.iter().map(|d| d.name.as_str()).collect();
680 assert!(names.contains(&"main_doc"));
681 assert!(names.contains(&"org/project/doc_a"));
682 assert!(names.contains(&"org/project/doc_b"));
683 }
684
685 #[tokio::test]
686 async fn resolve_handles_bundle_with_multiple_docs() {
687 let local_source = r#"doc main_doc
688fact a = doc @org/project/doc_a"#;
689 let local_docs =
690 crate::parse(local_source, "local.lemma", &ResourceLimits::default()).unwrap();
691 let mut sources = HashMap::new();
692 sources.insert("local.lemma".to_string(), local_source.to_string());
693
694 let mut registry = TestRegistry::new();
695 registry.add_doc_bundle(
697 "org/project/doc_a",
698 r#"doc org/project/doc_a
699fact b = doc @org/project/doc_b
700
701doc org/project/doc_b
702fact value = 99"#,
703 );
704
705 let result = resolve_registry_references(
706 local_docs,
707 &mut sources,
708 ®istry,
709 &ResourceLimits::default(),
710 )
711 .await
712 .unwrap();
713
714 assert_eq!(result.len(), 3);
715 let names: Vec<&str> = result.iter().map(|d| d.name.as_str()).collect();
716 assert!(names.contains(&"main_doc"));
717 assert!(names.contains(&"org/project/doc_a"));
718 assert!(names.contains(&"org/project/doc_b"));
719 }
720
721 #[tokio::test]
722 async fn resolve_returns_registry_error_when_registry_fails() {
723 let local_source = r#"doc main_doc
724fact external = doc @org/project/missing"#;
725 let local_docs =
726 crate::parse(local_source, "local.lemma", &ResourceLimits::default()).unwrap();
727 let mut sources = HashMap::new();
728 sources.insert("local.lemma".to_string(), local_source.to_string());
729
730 let registry = TestRegistry::new(); let result = resolve_registry_references(
733 local_docs,
734 &mut sources,
735 ®istry,
736 &ResourceLimits::default(),
737 )
738 .await;
739
740 assert!(result.is_err(), "Should fail when Registry cannot resolve");
741 let error = result.unwrap_err();
742
743 let registry_err = match &error {
745 LemmaError::Registry { .. } => &error,
746 LemmaError::MultipleErrors(inner) => inner
747 .iter()
748 .find(|e| matches!(e, LemmaError::Registry { .. }))
749 .expect("MultipleErrors should contain at least one Registry error"),
750 other => panic!(
751 "Expected LemmaError::Registry or MultipleErrors, got: {}",
752 other
753 ),
754 };
755 match registry_err {
756 LemmaError::Registry {
757 identifier,
758 kind,
759 details,
760 } => {
761 assert_eq!(identifier, "org/project/missing");
762 assert_eq!(*kind, RegistryErrorKind::NotFound);
763 assert!(
764 details.suggestion.is_some(),
765 "NotFound errors should include a suggestion"
766 );
767 }
768 _ => unreachable!(),
769 }
770
771 let error_message = error.to_string();
772 assert!(
773 error_message.contains("org/project/missing"),
774 "Error should mention the identifier: {}",
775 error_message
776 );
777 }
778
779 #[tokio::test]
780 async fn resolve_returns_all_registry_errors_when_multiple_refs_fail() {
781 let local_source = r#"doc main_doc
782fact helper = doc @org/example/helper
783type money from @lemma/std/finance"#;
784 let local_docs =
785 crate::parse(local_source, "local.lemma", &ResourceLimits::default()).unwrap();
786 let mut sources = HashMap::new();
787 sources.insert("local.lemma".to_string(), local_source.to_string());
788
789 let registry = TestRegistry::new(); let result = resolve_registry_references(
792 local_docs,
793 &mut sources,
794 ®istry,
795 &ResourceLimits::default(),
796 )
797 .await;
798
799 assert!(result.is_err(), "Should fail when Registry cannot resolve");
800 let error = result.unwrap_err();
801 let errors = match &error {
802 LemmaError::MultipleErrors(inner) => inner,
803 other => panic!(
804 "Expected MultipleErrors (doc + type both fail), got: {}",
805 other
806 ),
807 };
808 assert_eq!(
809 errors.len(),
810 2,
811 "Both doc ref and type import ref should produce a Registry error"
812 );
813 let identifiers: Vec<&str> = errors
814 .iter()
815 .filter_map(|e| {
816 if let LemmaError::Registry { identifier, .. } = e {
817 Some(identifier.as_str())
818 } else {
819 None
820 }
821 })
822 .collect();
823 assert!(
824 identifiers.contains(&"org/example/helper"),
825 "Should include doc ref error: {:?}",
826 identifiers
827 );
828 assert!(
829 identifiers.contains(&"lemma/std/finance"),
830 "Should include type import error: {:?}",
831 identifiers
832 );
833 }
834
835 #[tokio::test]
836 async fn resolve_does_not_request_same_identifier_twice() {
837 let local_source = r#"doc doc_one
838fact a = doc @org/shared
839
840doc doc_two
841fact b = doc @org/shared"#;
842 let local_docs =
843 crate::parse(local_source, "local.lemma", &ResourceLimits::default()).unwrap();
844 let mut sources = HashMap::new();
845 sources.insert("local.lemma".to_string(), local_source.to_string());
846
847 let mut registry = TestRegistry::new();
848 registry.add_doc_bundle(
849 "org/shared",
850 r#"doc org/shared
851fact value = 1"#,
852 );
853
854 let result = resolve_registry_references(
855 local_docs,
856 &mut sources,
857 ®istry,
858 &ResourceLimits::default(),
859 )
860 .await
861 .unwrap();
862
863 assert_eq!(result.len(), 3);
865 let names: Vec<&str> = result.iter().map(|d| d.name.as_str()).collect();
866 assert!(names.contains(&"org/shared"));
867 }
868
869 #[tokio::test]
870 async fn resolve_handles_type_import_from_registry() {
871 let local_source = r#"doc main_doc
872type money from @lemma/std/finance
873fact price = [money]"#;
874 let local_docs =
875 crate::parse(local_source, "local.lemma", &ResourceLimits::default()).unwrap();
876 let mut sources = HashMap::new();
877 sources.insert("local.lemma".to_string(), local_source.to_string());
878
879 let mut registry = TestRegistry::new();
880 registry.add_doc_bundle(
881 "lemma/std/finance",
882 r#"doc lemma/std/finance
883type money = scale
884 -> unit eur 1.00
885 -> unit usd 1.10
886 -> decimals 2"#,
887 );
888
889 let result = resolve_registry_references(
890 local_docs,
891 &mut sources,
892 ®istry,
893 &ResourceLimits::default(),
894 )
895 .await
896 .unwrap();
897
898 assert_eq!(result.len(), 2);
899 let names: Vec<&str> = result.iter().map(|d| d.name.as_str()).collect();
900 assert!(names.contains(&"main_doc"));
901 assert!(names.contains(&"lemma/std/finance"));
902 }
903
904 #[cfg(feature = "registry")]
909 mod lemmabase_tests {
910 use super::super::*;
911 use std::sync::{Arc, Mutex};
912
913 type HttpFetchHandler = Box<dyn Fn(&str) -> Result<String, HttpFetchError> + Send + Sync>;
918
919 struct MockHttpFetcher {
920 handler: HttpFetchHandler,
921 }
922
923 impl MockHttpFetcher {
924 fn with_handler(
926 handler: impl Fn(&str) -> Result<String, HttpFetchError> + Send + Sync + 'static,
927 ) -> Self {
928 Self {
929 handler: Box::new(handler),
930 }
931 }
932
933 fn always_returning(body: &str) -> Self {
935 let body = body.to_string();
936 Self::with_handler(move |_| Ok(body.clone()))
937 }
938
939 fn always_failing_with_status(code: u16) -> Self {
941 Self::with_handler(move |_| {
942 Err(HttpFetchError {
943 status_code: Some(code),
944 message: format!("HTTP {}", code),
945 })
946 })
947 }
948
949 fn always_failing_with_network_error(msg: &str) -> Self {
951 let msg = msg.to_string();
952 Self::with_handler(move |_| {
953 Err(HttpFetchError {
954 status_code: None,
955 message: msg.clone(),
956 })
957 })
958 }
959 }
960
961 #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
962 #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
963 impl HttpFetcher for MockHttpFetcher {
964 async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
965 (self.handler)(url)
966 }
967 }
968
969 #[test]
974 fn source_url_for_simple_identifier() {
975 let registry = LemmaBase::new();
976 let url = registry.source_url_for_identifier("user/workspace/somedoc");
977 assert_eq!(url, "https://lemmabase.com/@user/workspace/somedoc.lemma");
978 }
979
980 #[test]
981 fn source_url_for_deeply_nested_identifier() {
982 let registry = LemmaBase::new();
983 let url = registry.source_url_for_identifier("org/team/project/subdir/doc");
984 assert_eq!(
985 url,
986 "https://lemmabase.com/@org/team/project/subdir/doc.lemma"
987 );
988 }
989
990 #[test]
991 fn navigation_url_for_simple_identifier() {
992 let registry = LemmaBase::new();
993 let url = registry.navigation_url_for_identifier("user/workspace/somedoc");
994 assert_eq!(url, "https://lemmabase.com/@user/workspace/somedoc");
995 }
996
997 #[test]
998 fn navigation_url_for_deeply_nested_identifier() {
999 let registry = LemmaBase::new();
1000 let url = registry.navigation_url_for_identifier("org/team/project/subdir/doc");
1001 assert_eq!(url, "https://lemmabase.com/@org/team/project/subdir/doc");
1002 }
1003
1004 #[test]
1005 fn url_for_id_returns_navigation_url() {
1006 let registry = LemmaBase::new();
1007 let url = registry.url_for_id("user/workspace/somedoc");
1008 assert_eq!(
1009 url,
1010 Some("https://lemmabase.com/@user/workspace/somedoc".to_string())
1011 );
1012 }
1013
1014 #[test]
1015 fn url_for_id_returns_navigation_url_for_nested_path() {
1016 let registry = LemmaBase::new();
1017 let url = registry.url_for_id("lemma/std/finance");
1018 assert_eq!(
1019 url,
1020 Some("https://lemmabase.com/@lemma/std/finance".to_string())
1021 );
1022 }
1023
1024 #[test]
1025 fn default_trait_creates_same_instance_as_new() {
1026 let from_new = LemmaBase::new();
1027 let from_default = LemmaBase::default();
1028 assert_eq!(
1030 from_new.url_for_id("test/doc"),
1031 from_default.url_for_id("test/doc")
1032 );
1033 }
1034
1035 #[tokio::test]
1040 async fn fetch_source_returns_bundle_on_success() {
1041 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1042 "doc org/my_doc\nfact x = 1",
1043 )));
1044
1045 let bundle = registry.fetch_source("org/my_doc").await.unwrap();
1046
1047 assert_eq!(bundle.lemma_source, "doc org/my_doc\nfact x = 1");
1048 assert_eq!(bundle.attribute, "@org/my_doc");
1049 }
1050
1051 #[tokio::test]
1052 async fn fetch_source_passes_correct_url_to_fetcher() {
1053 let captured_url = Arc::new(Mutex::new(String::new()));
1054 let captured = captured_url.clone();
1055 let mock = MockHttpFetcher::with_handler(move |url| {
1056 *captured.lock().unwrap() = url.to_string();
1057 Ok("doc test/doc\nfact x = 1".to_string())
1058 });
1059 let registry = LemmaBase::with_fetcher(Box::new(mock));
1060
1061 let _ = registry.fetch_source("user/workspace/somedoc").await;
1062
1063 assert_eq!(
1064 *captured_url.lock().unwrap(),
1065 "https://lemmabase.com/@user/workspace/somedoc.lemma"
1066 );
1067 }
1068
1069 #[tokio::test]
1070 async fn fetch_source_maps_http_404_to_not_found() {
1071 let registry =
1072 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(404)));
1073
1074 let err = registry.fetch_source("org/missing").await.unwrap_err();
1075
1076 assert_eq!(err.kind, RegistryErrorKind::NotFound);
1077 assert!(
1078 err.message.contains("HTTP 404"),
1079 "Expected 'HTTP 404' in: {}",
1080 err.message
1081 );
1082 assert!(
1083 err.message.contains("@org/missing"),
1084 "Expected '@org/missing' in: {}",
1085 err.message
1086 );
1087 }
1088
1089 #[tokio::test]
1090 async fn fetch_source_maps_http_500_to_server_error() {
1091 let registry =
1092 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(500)));
1093
1094 let err = registry.fetch_source("org/broken").await.unwrap_err();
1095
1096 assert_eq!(err.kind, RegistryErrorKind::ServerError);
1097 assert!(
1098 err.message.contains("HTTP 500"),
1099 "Expected 'HTTP 500' in: {}",
1100 err.message
1101 );
1102 }
1103
1104 #[tokio::test]
1105 async fn fetch_source_maps_http_502_to_server_error() {
1106 let registry =
1107 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(502)));
1108
1109 let err = registry.fetch_source("org/broken").await.unwrap_err();
1110
1111 assert_eq!(err.kind, RegistryErrorKind::ServerError);
1112 }
1113
1114 #[tokio::test]
1115 async fn fetch_source_maps_http_401_to_unauthorized() {
1116 let registry =
1117 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(401)));
1118
1119 let err = registry.fetch_source("org/secret").await.unwrap_err();
1120
1121 assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1122 assert!(err.message.contains("HTTP 401"));
1123 }
1124
1125 #[tokio::test]
1126 async fn fetch_source_maps_http_403_to_unauthorized() {
1127 let registry =
1128 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(403)));
1129
1130 let err = registry.fetch_source("org/private").await.unwrap_err();
1131
1132 assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1133 assert!(
1134 err.message.contains("HTTP 403"),
1135 "Expected 'HTTP 403' in: {}",
1136 err.message
1137 );
1138 }
1139
1140 #[tokio::test]
1141 async fn fetch_source_maps_unexpected_status_to_other() {
1142 let registry =
1143 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(418)));
1144
1145 let err = registry.fetch_source("org/teapot").await.unwrap_err();
1146
1147 assert_eq!(err.kind, RegistryErrorKind::Other);
1148 assert!(err.message.contains("HTTP 418"));
1149 }
1150
1151 #[tokio::test]
1152 async fn fetch_source_maps_network_error_to_network_error_kind() {
1153 let registry = LemmaBase::with_fetcher(Box::new(
1154 MockHttpFetcher::always_failing_with_network_error("connection refused"),
1155 ));
1156
1157 let err = registry.fetch_source("org/unreachable").await.unwrap_err();
1158
1159 assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1160 assert!(
1161 err.message.contains("connection refused"),
1162 "Expected 'connection refused' in: {}",
1163 err.message
1164 );
1165 assert!(
1166 err.message.contains("@org/unreachable"),
1167 "Expected '@org/unreachable' in: {}",
1168 err.message
1169 );
1170 }
1171
1172 #[tokio::test]
1173 async fn fetch_source_maps_dns_error_to_network_error_kind() {
1174 let registry = LemmaBase::with_fetcher(Box::new(
1175 MockHttpFetcher::always_failing_with_network_error(
1176 "dns error: failed to lookup address",
1177 ),
1178 ));
1179
1180 let err = registry.fetch_source("org/doc").await.unwrap_err();
1181
1182 assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1183 assert!(
1184 err.message.contains("dns error"),
1185 "Expected 'dns error' in: {}",
1186 err.message
1187 );
1188 assert!(
1189 err.message.contains("Failed to reach LemmaBase"),
1190 "Expected 'Failed to reach LemmaBase' in: {}",
1191 err.message
1192 );
1193 }
1194
1195 #[tokio::test]
1200 async fn resolve_doc_delegates_to_fetch_source() {
1201 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1202 "doc org/resolved\nfact a = 1",
1203 )));
1204
1205 let bundle = registry.resolve_doc("org/resolved").await.unwrap();
1206
1207 assert_eq!(bundle.lemma_source, "doc org/resolved\nfact a = 1");
1208 assert_eq!(bundle.attribute, "@org/resolved");
1209 }
1210
1211 #[tokio::test]
1212 async fn resolve_type_delegates_to_fetch_source() {
1213 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1214 "doc lemma/std/finance\ntype money = scale\n -> unit eur 1.00",
1215 )));
1216
1217 let bundle = registry.resolve_type("lemma/std/finance").await.unwrap();
1218
1219 assert_eq!(bundle.attribute, "@lemma/std/finance");
1220 assert!(
1221 bundle.lemma_source.contains("type money = scale"),
1222 "Expected source to contain 'type money = scale': {}",
1223 bundle.lemma_source
1224 );
1225 }
1226
1227 #[tokio::test]
1228 async fn resolve_doc_propagates_http_error() {
1229 let registry =
1230 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(404)));
1231
1232 let err = registry.resolve_doc("org/missing").await.unwrap_err();
1233
1234 assert!(err.message.contains("HTTP 404"));
1235 }
1236
1237 #[tokio::test]
1238 async fn resolve_type_propagates_network_error() {
1239 let registry = LemmaBase::with_fetcher(Box::new(
1240 MockHttpFetcher::always_failing_with_network_error("timeout"),
1241 ));
1242
1243 let err = registry.resolve_type("lemma/std/types").await.unwrap_err();
1244
1245 assert!(err.message.contains("timeout"));
1246 }
1247
1248 #[tokio::test]
1249 async fn fetch_source_returns_empty_body_as_valid_bundle() {
1250 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning("")));
1251
1252 let bundle = registry.fetch_source("org/empty").await.unwrap();
1253
1254 assert_eq!(bundle.lemma_source, "");
1255 assert_eq!(bundle.attribute, "@org/empty");
1256 }
1257 }
1258}