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 get_specs(&self, name: &str) -> Result<RegistryBundle, RegistryError>;
103
104 async fn get_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 get_specs(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
322 self.fetch_source(name).await
323 }
324
325 async fn get_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.get_specs(&reference.name).await,
377 RegistryReferenceKind::TypeImport => registry.get_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 let spec_context = ctx.iter().find(|s| {
399 s.attribute.as_deref() == Some(reference.source.attribute.as_str())
400 });
401 round_errors.push(Error::registry(
402 registry_error.message,
403 reference.source.clone(),
404 &reference.name,
405 registry_error.kind,
406 suggestion,
407 spec_context,
408 None,
409 ));
410 continue;
411 }
412 };
413
414 sources.insert(bundle.attribute.clone(), bundle.lemma_source.clone());
415
416 let new_specs =
417 match crate::parsing::parse(&bundle.lemma_source, &bundle.attribute, limits) {
418 Ok(result) => result.specs,
419 Err(e) => {
420 round_errors.push(e);
421 return Err(round_errors);
422 }
423 };
424
425 for spec in new_specs {
426 let bare_refs = crate::planning::validation::collect_bare_registry_refs(&spec);
427 if !bare_refs.is_empty() {
428 round_errors.push(Error::validation_with_context(
429 format!(
430 "Registry spec '{}' contains references without '@' prefix: {}. \
431 The registry must rewrite all references to use '@'-prefixed names",
432 spec.name,
433 bare_refs.join(", ")
434 ),
435 None,
436 Some(
437 "The registry must prefix all spec references with '@' \
438 before serving the bundle.",
439 ),
440 Some(std::sync::Arc::new(spec.clone())),
441 None,
442 ));
443 continue;
444 }
445 if let Err(e) = ctx.insert_spec(Arc::new(spec), true) {
446 round_errors.push(e);
447 }
448 }
449 }
450
451 if !round_errors.is_empty() {
452 return Err(round_errors);
453 }
454 }
455
456 Ok(())
457}
458
459#[derive(Debug, Clone, PartialEq, Eq, Hash)]
461enum RegistryReferenceKind {
462 Spec,
463 TypeImport,
464}
465
466#[derive(Debug, Clone)]
468struct RegistryReference {
469 name: String,
470 kind: RegistryReferenceKind,
471 source: Source,
472}
473
474impl RegistryReference {
475 fn dedup_key(&self) -> (String, RegistryReferenceKind) {
476 (self.name.clone(), self.kind.clone())
477 }
478}
479
480fn collect_unresolved_registry_references(
483 ctx: &Context,
484 already_requested: &HashSet<(String, RegistryReferenceKind)>,
485) -> Vec<RegistryReference> {
486 let mut unresolved: Vec<RegistryReference> = Vec::new();
487 let mut seen_in_this_round: HashSet<(String, RegistryReferenceKind)> = HashSet::new();
488
489 for spec in ctx.iter() {
490 let spec = spec.as_ref();
491 if spec.attribute.is_none() {
492 let has_registry_refs =
493 spec.facts.iter().any(
494 |f| matches!(&f.value, FactValue::SpecReference(ref r) if r.from_registry),
495 ) || spec
496 .types
497 .iter()
498 .any(|t| matches!(t, TypeDef::Import { from, .. } if from.from_registry));
499 if has_registry_refs {
500 panic!(
501 "BUG: spec '{}' must have source attribute when it has registry references",
502 spec.name
503 );
504 }
505 continue;
506 }
507
508 for fact in &spec.facts {
509 if let FactValue::SpecReference(spec_ref) = &fact.value {
510 if !spec_ref.from_registry {
511 continue;
512 }
513 let already_satisfied = ctx
514 .get_spec_effective_from(spec_ref.name.as_str(), None)
515 .is_some();
516 let dedup = (spec_ref.name.clone(), RegistryReferenceKind::Spec);
517 if !already_satisfied
518 && !already_requested.contains(&dedup)
519 && seen_in_this_round.insert(dedup)
520 {
521 unresolved.push(RegistryReference {
522 name: spec_ref.name.clone(),
523 kind: RegistryReferenceKind::Spec,
524 source: fact.source_location.clone(),
525 });
526 }
527 }
528 }
529
530 for type_def in &spec.types {
531 if let TypeDef::Import {
532 from,
533 source_location,
534 ..
535 } = type_def
536 {
537 if !from.from_registry {
538 continue;
539 }
540 let already_satisfied = ctx
541 .get_spec_effective_from(from.name.as_str(), None)
542 .is_some();
543 let dedup = (from.name.clone(), RegistryReferenceKind::TypeImport);
544 if !already_satisfied
545 && !already_requested.contains(&dedup)
546 && seen_in_this_round.insert(dedup)
547 {
548 unresolved.push(RegistryReference {
549 name: from.name.clone(),
550 kind: RegistryReferenceKind::TypeImport,
551 source: source_location.clone(),
552 });
553 }
554 }
555 }
556 }
557
558 unresolved
559}
560
561#[cfg(test)]
566mod tests {
567 use super::*;
568
569 struct TestRegistry {
571 bundles: HashMap<String, RegistryBundle>,
572 }
573
574 impl TestRegistry {
575 fn new() -> Self {
576 Self {
577 bundles: HashMap::new(),
578 }
579 }
580
581 fn add_spec_bundle(&mut self, identifier: &str, lemma_source: &str) {
583 self.bundles.insert(
584 identifier.to_string(),
585 RegistryBundle {
586 lemma_source: lemma_source.to_string(),
587 attribute: identifier.to_string(),
588 },
589 );
590 }
591 }
592
593 #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
594 #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
595 impl Registry for TestRegistry {
596 async fn get_specs(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
597 self.bundles
598 .get(name)
599 .cloned()
600 .ok_or_else(|| RegistryError {
601 message: format!("Spec '{}' not found in test registry", name),
602 kind: RegistryErrorKind::NotFound,
603 })
604 }
605
606 async fn get_types(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
607 self.bundles
608 .get(name)
609 .cloned()
610 .ok_or_else(|| RegistryError {
611 message: format!("Type source '{}' not found in test registry", name),
612 kind: RegistryErrorKind::NotFound,
613 })
614 }
615
616 fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
617 if self.bundles.contains_key(name) {
618 Some(match effective {
619 None => format!("https://test.registry/{}", name),
620 Some(d) => format!("https://test.registry/{}?effective={}", name, d),
621 })
622 } else {
623 None
624 }
625 }
626 }
627
628 #[tokio::test]
629 async fn resolve_with_no_registry_references_returns_local_specs_unchanged() {
630 let source = r#"spec example
631fact price: 100"#;
632 let local_specs = crate::parse(source, "local.lemma", &ResourceLimits::default())
633 .unwrap()
634 .specs;
635 let mut store = Context::new();
636 for spec in &local_specs {
637 store.insert_spec(Arc::new(spec.clone()), false).unwrap();
638 }
639 let mut sources = HashMap::new();
640 sources.insert("local.lemma".to_string(), source.to_string());
641
642 let registry = TestRegistry::new();
643 resolve_registry_references(
644 &mut store,
645 &mut sources,
646 ®istry,
647 &ResourceLimits::default(),
648 )
649 .await
650 .unwrap();
651
652 assert_eq!(store.len(), 1);
653 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
654 assert_eq!(names, ["example"]);
655 }
656
657 #[tokio::test]
658 async fn resolve_fetches_single_spec_from_registry() {
659 let local_source = r#"spec main_spec
660fact external: spec @org/project/helper
661rule value: external.quantity"#;
662 let local_specs = crate::parse(local_source, "local.lemma", &ResourceLimits::default())
663 .unwrap()
664 .specs;
665 let mut store = Context::new();
666 for spec in local_specs {
667 store.insert_spec(Arc::new(spec), false).unwrap();
668 }
669 let mut sources = HashMap::new();
670 sources.insert("local.lemma".to_string(), local_source.to_string());
671
672 let mut registry = TestRegistry::new();
673 registry.add_spec_bundle(
674 "@org/project/helper",
675 r#"spec @org/project/helper
676fact quantity: 42"#,
677 );
678
679 resolve_registry_references(
680 &mut store,
681 &mut sources,
682 ®istry,
683 &ResourceLimits::default(),
684 )
685 .await
686 .unwrap();
687
688 assert_eq!(store.len(), 2);
689 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
690 assert!(names.iter().any(|n| n == "main_spec"));
691 assert!(names.iter().any(|n| n == "@org/project/helper"));
692 }
693
694 #[tokio::test]
695 async fn get_specs_returns_all_zones_and_url_for_id_supports_effective() {
696 let effective = DateTimeValue {
697 year: 2026,
698 month: 1,
699 day: 15,
700 hour: 0,
701 minute: 0,
702 second: 0,
703 microsecond: 0,
704 timezone: None,
705 };
706 let mut registry = TestRegistry::new();
707 registry.add_spec_bundle(
708 "org/spec",
709 "spec org/spec 2025-01-01\nfact x: 1\n\nspec org/spec 2026-01-15\nfact x: 2",
710 );
711
712 let bundle = registry.get_specs("org/spec").await.unwrap();
713 assert!(bundle.lemma_source.contains("fact x: 1"));
714 assert!(bundle.lemma_source.contains("fact x: 2"));
715
716 assert_eq!(
717 registry.url_for_id("org/spec", None),
718 Some("https://test.registry/org/spec".to_string())
719 );
720 assert_eq!(
721 registry.url_for_id("org/spec", Some(&effective)),
722 Some("https://test.registry/org/spec?effective=2026-01-15".to_string())
723 );
724 }
725
726 #[tokio::test]
727 async fn resolve_fetches_transitive_dependencies() {
728 let local_source = r#"spec main_spec
729fact a: spec @org/project/spec_a"#;
730 let local_specs = crate::parse(local_source, "local.lemma", &ResourceLimits::default())
731 .unwrap()
732 .specs;
733 let mut store = Context::new();
734 for spec in local_specs {
735 store.insert_spec(Arc::new(spec), false).unwrap();
736 }
737 let mut sources = HashMap::new();
738 sources.insert("local.lemma".to_string(), local_source.to_string());
739
740 let mut registry = TestRegistry::new();
741 registry.add_spec_bundle(
742 "@org/project/spec_a",
743 r#"spec @org/project/spec_a
744fact b: spec @org/project/spec_b"#,
745 );
746 registry.add_spec_bundle(
747 "@org/project/spec_b",
748 r#"spec @org/project/spec_b
749fact value: 99"#,
750 );
751
752 resolve_registry_references(
753 &mut store,
754 &mut sources,
755 ®istry,
756 &ResourceLimits::default(),
757 )
758 .await
759 .unwrap();
760
761 assert_eq!(store.len(), 3);
762 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
763 assert!(names.iter().any(|n| n == "main_spec"));
764 assert!(names.iter().any(|n| n == "@org/project/spec_a"));
765 assert!(names.iter().any(|n| n == "@org/project/spec_b"));
766 }
767
768 #[tokio::test]
769 async fn resolve_handles_bundle_with_multiple_specs() {
770 let local_source = r#"spec main_spec
771fact a: spec @org/project/spec_a"#;
772 let local_specs = crate::parse(local_source, "local.lemma", &ResourceLimits::default())
773 .unwrap()
774 .specs;
775 let mut store = Context::new();
776 for spec in local_specs {
777 store.insert_spec(Arc::new(spec), false).unwrap();
778 }
779 let mut sources = HashMap::new();
780 sources.insert("local.lemma".to_string(), local_source.to_string());
781
782 let mut registry = TestRegistry::new();
783 registry.add_spec_bundle(
784 "@org/project/spec_a",
785 r#"spec @org/project/spec_a
786fact b: spec @org/project/spec_b
787
788spec @org/project/spec_b
789fact value: 99"#,
790 );
791
792 resolve_registry_references(
793 &mut store,
794 &mut sources,
795 ®istry,
796 &ResourceLimits::default(),
797 )
798 .await
799 .unwrap();
800
801 assert_eq!(store.len(), 3);
802 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
803 assert!(names.iter().any(|n| n == "main_spec"));
804 assert!(names.iter().any(|n| n == "@org/project/spec_a"));
805 assert!(names.iter().any(|n| n == "@org/project/spec_b"));
806 }
807
808 #[tokio::test]
809 async fn resolve_returns_registry_error_when_registry_fails() {
810 let local_source = r#"spec main_spec
811fact external: spec @org/project/missing"#;
812 let local_specs = crate::parse(local_source, "local.lemma", &ResourceLimits::default())
813 .unwrap()
814 .specs;
815 let mut store = Context::new();
816 for spec in local_specs {
817 store.insert_spec(Arc::new(spec), false).unwrap();
818 }
819 let mut sources = HashMap::new();
820 sources.insert("local.lemma".to_string(), local_source.to_string());
821
822 let registry = TestRegistry::new(); let result = resolve_registry_references(
825 &mut store,
826 &mut sources,
827 ®istry,
828 &ResourceLimits::default(),
829 )
830 .await;
831
832 assert!(result.is_err(), "Should fail when Registry cannot resolve");
833 let errs = result.unwrap_err();
834 let registry_err = errs
835 .iter()
836 .find(|e| matches!(e, Error::Registry { .. }))
837 .expect("expected at least one Registry error");
838 match registry_err {
839 Error::Registry {
840 identifier,
841 kind,
842 details,
843 } => {
844 assert_eq!(identifier, "@org/project/missing");
845 assert_eq!(*kind, RegistryErrorKind::NotFound);
846 assert!(
847 details.suggestion.is_some(),
848 "NotFound errors should include a suggestion"
849 );
850 }
851 _ => unreachable!(),
852 }
853
854 let error_message = errs
855 .iter()
856 .map(|e| e.to_string())
857 .collect::<Vec<_>>()
858 .join(" ");
859 assert!(
860 error_message.contains("org/project/missing"),
861 "Error should mention the identifier: {}",
862 error_message
863 );
864 }
865
866 #[tokio::test]
867 async fn resolve_returns_all_registry_errors_when_multiple_refs_fail() {
868 let local_source = r#"spec main_spec
869fact helper: spec @org/example/helper
870type money from @lemma/std/finance"#;
871 let local_specs = crate::parse(local_source, "local.lemma", &ResourceLimits::default())
872 .unwrap()
873 .specs;
874 let mut store = Context::new();
875 for spec in local_specs {
876 store.insert_spec(Arc::new(spec), false).unwrap();
877 }
878 let mut sources = HashMap::new();
879 sources.insert("local.lemma".to_string(), local_source.to_string());
880
881 let registry = TestRegistry::new(); let result = resolve_registry_references(
884 &mut store,
885 &mut sources,
886 ®istry,
887 &ResourceLimits::default(),
888 )
889 .await;
890
891 assert!(result.is_err(), "Should fail when Registry cannot resolve");
892 let errors = result.unwrap_err();
893 assert_eq!(
894 errors.len(),
895 2,
896 "Both spec ref and type import ref should produce a Registry error"
897 );
898 let identifiers: Vec<&str> = errors
899 .iter()
900 .filter_map(|e| {
901 if let Error::Registry { identifier, .. } = e {
902 Some(identifier.as_str())
903 } else {
904 None
905 }
906 })
907 .collect();
908 assert!(
909 identifiers.contains(&"@org/example/helper"),
910 "Should include spec ref error: {:?}",
911 identifiers
912 );
913 assert!(
914 identifiers.contains(&"@lemma/std/finance"),
915 "Should include type import error: {:?}",
916 identifiers
917 );
918 }
919
920 #[tokio::test]
921 async fn resolve_does_not_request_same_identifier_twice() {
922 let local_source = r#"spec spec_one
923fact a: spec @org/shared
924
925spec spec_two
926fact b: spec @org/shared"#;
927 let local_specs = crate::parse(local_source, "local.lemma", &ResourceLimits::default())
928 .unwrap()
929 .specs;
930 let mut store = Context::new();
931 for spec in local_specs {
932 store.insert_spec(Arc::new(spec), false).unwrap();
933 }
934 let mut sources = HashMap::new();
935 sources.insert("local.lemma".to_string(), local_source.to_string());
936
937 let mut registry = TestRegistry::new();
938 registry.add_spec_bundle(
939 "@org/shared",
940 r#"spec @org/shared
941fact value: 1"#,
942 );
943
944 resolve_registry_references(
945 &mut store,
946 &mut sources,
947 ®istry,
948 &ResourceLimits::default(),
949 )
950 .await
951 .unwrap();
952
953 assert_eq!(store.len(), 3);
955 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
956 assert!(names.iter().any(|n| n == "@org/shared"));
957 }
958
959 #[tokio::test]
960 async fn resolve_handles_type_import_from_registry() {
961 let local_source = r#"spec main_spec
962type money from @lemma/std/finance
963fact price: [money]"#;
964 let local_specs = crate::parse(local_source, "local.lemma", &ResourceLimits::default())
965 .unwrap()
966 .specs;
967 let mut store = Context::new();
968 for spec in local_specs {
969 store.insert_spec(Arc::new(spec), false).unwrap();
970 }
971 let mut sources = HashMap::new();
972 sources.insert("local.lemma".to_string(), local_source.to_string());
973
974 let mut registry = TestRegistry::new();
975 registry.add_spec_bundle(
976 "@lemma/std/finance",
977 r#"spec @lemma/std/finance
978type money: scale
979 -> unit eur 1.00
980 -> unit usd 1.10
981 -> decimals 2"#,
982 );
983
984 resolve_registry_references(
985 &mut store,
986 &mut sources,
987 ®istry,
988 &ResourceLimits::default(),
989 )
990 .await
991 .unwrap();
992
993 assert_eq!(store.len(), 2);
994 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
995 assert!(names.iter().any(|n| n == "main_spec"));
996 assert!(names.iter().any(|n| n == "@lemma/std/finance"));
997 }
998
999 #[cfg(feature = "registry")]
1004 mod lemmabase_tests {
1005 use super::super::*;
1006 use std::sync::{Arc, Mutex};
1007
1008 type HttpFetchHandler = Box<dyn Fn(&str) -> Result<String, HttpFetchError> + Send + Sync>;
1013
1014 struct MockHttpFetcher {
1015 handler: HttpFetchHandler,
1016 }
1017
1018 impl MockHttpFetcher {
1019 fn with_handler(
1021 handler: impl Fn(&str) -> Result<String, HttpFetchError> + Send + Sync + 'static,
1022 ) -> Self {
1023 Self {
1024 handler: Box::new(handler),
1025 }
1026 }
1027
1028 fn always_returning(body: &str) -> Self {
1030 let body = body.to_string();
1031 Self::with_handler(move |_| Ok(body.clone()))
1032 }
1033
1034 fn always_failing_with_status(code: u16) -> Self {
1036 Self::with_handler(move |_| {
1037 Err(HttpFetchError {
1038 status_code: Some(code),
1039 message: format!("HTTP {}", code),
1040 })
1041 })
1042 }
1043
1044 fn always_failing_with_network_error(msg: &str) -> Self {
1046 let msg = msg.to_string();
1047 Self::with_handler(move |_| {
1048 Err(HttpFetchError {
1049 status_code: None,
1050 message: msg.clone(),
1051 })
1052 })
1053 }
1054 }
1055
1056 #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
1057 #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
1058 impl HttpFetcher for MockHttpFetcher {
1059 async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
1060 (self.handler)(url)
1061 }
1062 }
1063
1064 #[test]
1069 fn source_url_without_effective() {
1070 let registry = LemmaBase::new();
1071 let url = registry.source_url("@user/workspace/somespec", None);
1072 assert_eq!(
1073 url,
1074 format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1075 );
1076 }
1077
1078 #[test]
1079 fn source_url_with_effective() {
1080 let registry = LemmaBase::new();
1081 let effective = DateTimeValue {
1082 year: 2026,
1083 month: 1,
1084 day: 15,
1085 hour: 0,
1086 minute: 0,
1087 second: 0,
1088 microsecond: 0,
1089 timezone: None,
1090 };
1091 let url = registry.source_url("@user/workspace/somespec", Some(&effective));
1092 assert_eq!(
1093 url,
1094 format!(
1095 "{}/@user/workspace/somespec.lemma?effective=2026-01-15",
1096 LemmaBase::BASE_URL
1097 )
1098 );
1099 }
1100
1101 #[test]
1102 fn source_url_for_deeply_nested_identifier() {
1103 let registry = LemmaBase::new();
1104 let url = registry.source_url("@org/team/project/subdir/spec", None);
1105 assert_eq!(
1106 url,
1107 format!(
1108 "{}/@org/team/project/subdir/spec.lemma",
1109 LemmaBase::BASE_URL
1110 )
1111 );
1112 }
1113
1114 #[test]
1115 fn navigation_url_without_effective() {
1116 let registry = LemmaBase::new();
1117 let url = registry.navigation_url("@user/workspace/somespec", None);
1118 assert_eq!(
1119 url,
1120 format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL)
1121 );
1122 }
1123
1124 #[test]
1125 fn navigation_url_with_effective() {
1126 let registry = LemmaBase::new();
1127 let effective = DateTimeValue {
1128 year: 2026,
1129 month: 1,
1130 day: 15,
1131 hour: 0,
1132 minute: 0,
1133 second: 0,
1134 microsecond: 0,
1135 timezone: None,
1136 };
1137 let url = registry.navigation_url("@user/workspace/somespec", Some(&effective));
1138 assert_eq!(
1139 url,
1140 format!(
1141 "{}/@user/workspace/somespec?effective=2026-01-15",
1142 LemmaBase::BASE_URL
1143 )
1144 );
1145 }
1146
1147 #[test]
1148 fn navigation_url_for_deeply_nested_identifier() {
1149 let registry = LemmaBase::new();
1150 let url = registry.navigation_url("@org/team/project/subdir/spec", None);
1151 assert_eq!(
1152 url,
1153 format!("{}/@org/team/project/subdir/spec", LemmaBase::BASE_URL)
1154 );
1155 }
1156
1157 #[test]
1158 fn url_for_id_returns_navigation_url() {
1159 let registry = LemmaBase::new();
1160 let url = registry.url_for_id("@user/workspace/somespec", None);
1161 assert_eq!(
1162 url,
1163 Some(format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL))
1164 );
1165 }
1166
1167 #[test]
1168 fn url_for_id_with_effective() {
1169 let registry = LemmaBase::new();
1170 let effective = DateTimeValue {
1171 year: 2026,
1172 month: 1,
1173 day: 1,
1174 hour: 0,
1175 minute: 0,
1176 second: 0,
1177 microsecond: 0,
1178 timezone: None,
1179 };
1180 let url = registry.url_for_id("@owner/repo/spec", Some(&effective));
1181 assert_eq!(
1182 url,
1183 Some(format!(
1184 "{}/@owner/repo/spec?effective=2026-01-01",
1185 LemmaBase::BASE_URL
1186 ))
1187 );
1188 }
1189
1190 #[test]
1191 fn url_for_id_returns_navigation_url_for_nested_path() {
1192 let registry = LemmaBase::new();
1193 let url = registry.url_for_id("@lemma/std/finance", None);
1194 assert_eq!(
1195 url,
1196 Some(format!("{}/@lemma/std/finance", LemmaBase::BASE_URL))
1197 );
1198 }
1199
1200 #[test]
1201 fn default_trait_creates_same_instance_as_new() {
1202 let from_new = LemmaBase::new();
1203 let from_default = LemmaBase::default();
1204 assert_eq!(
1205 from_new.url_for_id("test/spec", None),
1206 from_default.url_for_id("test/spec", None)
1207 );
1208 }
1209
1210 #[tokio::test]
1215 async fn fetch_source_returns_bundle_on_success() {
1216 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1217 "spec org/my_spec\nfact x: 1",
1218 )));
1219
1220 let bundle = registry.fetch_source("@org/my_spec").await.unwrap();
1221
1222 assert_eq!(bundle.lemma_source, "spec org/my_spec\nfact x: 1");
1223 assert_eq!(bundle.attribute, "@org/my_spec");
1224 }
1225
1226 #[tokio::test]
1227 async fn fetch_source_passes_correct_url_to_fetcher() {
1228 let captured_url = Arc::new(Mutex::new(String::new()));
1229 let captured = captured_url.clone();
1230 let mock = MockHttpFetcher::with_handler(move |url| {
1231 *captured.lock().unwrap() = url.to_string();
1232 Ok("spec test/spec\nfact x: 1".to_string())
1233 });
1234 let registry = LemmaBase::with_fetcher(Box::new(mock));
1235
1236 let _ = registry.fetch_source("@user/workspace/somespec").await;
1237
1238 assert_eq!(
1239 *captured_url.lock().unwrap(),
1240 format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1241 );
1242 }
1243
1244 #[tokio::test]
1245 async fn fetch_source_maps_http_404_to_not_found() {
1246 let registry =
1247 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(404)));
1248
1249 let err = registry.fetch_source("@org/missing").await.unwrap_err();
1250
1251 assert_eq!(err.kind, RegistryErrorKind::NotFound);
1252 assert!(
1253 err.message.contains("HTTP 404"),
1254 "Expected 'HTTP 404' in: {}",
1255 err.message
1256 );
1257 assert!(
1258 err.message.contains("@org/missing"),
1259 "Expected '@org/missing' in: {}",
1260 err.message
1261 );
1262 }
1263
1264 #[tokio::test]
1265 async fn fetch_source_maps_http_500_to_server_error() {
1266 let registry =
1267 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(500)));
1268
1269 let err = registry.fetch_source("@org/broken").await.unwrap_err();
1270
1271 assert_eq!(err.kind, RegistryErrorKind::ServerError);
1272 assert!(
1273 err.message.contains("HTTP 500"),
1274 "Expected 'HTTP 500' in: {}",
1275 err.message
1276 );
1277 }
1278
1279 #[tokio::test]
1280 async fn fetch_source_maps_http_502_to_server_error() {
1281 let registry =
1282 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(502)));
1283
1284 let err = registry.fetch_source("@org/broken").await.unwrap_err();
1285
1286 assert_eq!(err.kind, RegistryErrorKind::ServerError);
1287 }
1288
1289 #[tokio::test]
1290 async fn fetch_source_maps_http_401_to_unauthorized() {
1291 let registry =
1292 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(401)));
1293
1294 let err = registry.fetch_source("@org/secret").await.unwrap_err();
1295
1296 assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1297 assert!(err.message.contains("HTTP 401"));
1298 }
1299
1300 #[tokio::test]
1301 async fn fetch_source_maps_http_403_to_unauthorized() {
1302 let registry =
1303 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(403)));
1304
1305 let err = registry.fetch_source("@org/private").await.unwrap_err();
1306
1307 assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1308 assert!(
1309 err.message.contains("HTTP 403"),
1310 "Expected 'HTTP 403' in: {}",
1311 err.message
1312 );
1313 }
1314
1315 #[tokio::test]
1316 async fn fetch_source_maps_unexpected_status_to_other() {
1317 let registry =
1318 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(418)));
1319
1320 let err = registry.fetch_source("@org/teapot").await.unwrap_err();
1321
1322 assert_eq!(err.kind, RegistryErrorKind::Other);
1323 assert!(err.message.contains("HTTP 418"));
1324 }
1325
1326 #[tokio::test]
1327 async fn fetch_source_maps_network_error_to_network_error_kind() {
1328 let registry = LemmaBase::with_fetcher(Box::new(
1329 MockHttpFetcher::always_failing_with_network_error("connection refused"),
1330 ));
1331
1332 let err = registry.fetch_source("@org/unreachable").await.unwrap_err();
1333
1334 assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1335 assert!(
1336 err.message.contains("connection refused"),
1337 "Expected 'connection refused' in: {}",
1338 err.message
1339 );
1340 assert!(
1341 err.message.contains("@org/unreachable"),
1342 "Expected '@org/unreachable' in: {}",
1343 err.message
1344 );
1345 }
1346
1347 #[tokio::test]
1348 async fn fetch_source_maps_dns_error_to_network_error_kind() {
1349 let registry = LemmaBase::with_fetcher(Box::new(
1350 MockHttpFetcher::always_failing_with_network_error(
1351 "dns error: failed to lookup address",
1352 ),
1353 ));
1354
1355 let err = registry.fetch_source("@org/spec").await.unwrap_err();
1356
1357 assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1358 assert!(
1359 err.message.contains("dns error"),
1360 "Expected 'dns error' in: {}",
1361 err.message
1362 );
1363 assert!(
1364 err.message.contains("Failed to reach LemmaBase"),
1365 "Expected 'Failed to reach LemmaBase' in: {}",
1366 err.message
1367 );
1368 }
1369
1370 #[tokio::test]
1375 async fn get_specs_delegates_to_fetch_source() {
1376 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1377 "spec org/resolved\nfact a: 1",
1378 )));
1379
1380 let bundle = registry.get_specs("@org/resolved").await.unwrap();
1381
1382 assert_eq!(bundle.lemma_source, "spec org/resolved\nfact a: 1");
1383 assert_eq!(bundle.attribute, "@org/resolved");
1384 }
1385
1386 #[tokio::test]
1387 async fn get_types_delegates_to_fetch_source() {
1388 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1389 "spec lemma/std/finance\ntype money: scale\n -> unit eur 1.00",
1390 )));
1391
1392 let bundle = registry.get_types("@lemma/std/finance").await.unwrap();
1393
1394 assert_eq!(bundle.attribute, "@lemma/std/finance");
1395 assert!(
1396 bundle.lemma_source.contains("type money: scale"),
1397 "Expected source to contain 'type money: scale': {}",
1398 bundle.lemma_source
1399 );
1400 }
1401
1402 #[tokio::test]
1403 async fn get_specs_propagates_http_error() {
1404 let registry =
1405 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(404)));
1406
1407 let err = registry.get_specs("@org/missing").await.unwrap_err();
1408
1409 assert!(err.message.contains("HTTP 404"));
1410 }
1411
1412 #[tokio::test]
1413 async fn get_types_propagates_network_error() {
1414 let registry = LemmaBase::with_fetcher(Box::new(
1415 MockHttpFetcher::always_failing_with_network_error("timeout"),
1416 ));
1417
1418 let err = registry.get_types("@lemma/std/types").await.unwrap_err();
1419
1420 assert!(err.message.contains("timeout"));
1421 }
1422
1423 #[tokio::test]
1424 async fn fetch_source_returns_empty_body_as_valid_bundle() {
1425 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning("")));
1426
1427 let bundle = registry.fetch_source("@org/empty").await.unwrap();
1428
1429 assert_eq!(bundle.lemma_source, "");
1430 assert_eq!(bundle.attribute, "@org/empty");
1431 }
1432 }
1433}