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