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