1#[cfg(feature = "registry")]
14use crate::parsing::ast::DateTimeValue;
15#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
16use crate::parsing::ast::LemmaRepository;
17use std::fmt;
18#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
19use std::sync::Arc;
20
21#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
22use std::path::{Path, PathBuf};
23
24#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
25use {
26 crate::engine::Context,
27 crate::error::Error,
28 crate::limits::ResourceLimits,
29 crate::parsing::ast::{DataValue, RepositoryQualifier, SpecRef},
30 crate::parsing::source::Source,
31 std::collections::{HashMap, HashSet},
32};
33
34#[cfg(feature = "registry")]
42#[derive(Debug, Clone)]
43pub struct RegistryBundle {
44 pub repository: String,
45 pub source: String,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
54#[serde(rename_all = "snake_case")]
55pub enum RegistryErrorKind {
56 NotFound,
58 Unauthorized,
60 NetworkError,
62 ServerError,
64 Other,
66}
67
68impl fmt::Display for RegistryErrorKind {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 match self {
71 Self::NotFound => write!(f, "not found"),
72 Self::Unauthorized => write!(f, "unauthorized"),
73 Self::NetworkError => write!(f, "network error"),
74 Self::ServerError => write!(f, "server error"),
75 Self::Other => write!(f, "error"),
76 }
77 }
78}
79
80#[cfg(feature = "registry")]
82#[derive(Debug, Clone)]
83pub struct RegistryError {
84 pub message: String,
85 pub kind: RegistryErrorKind,
86}
87
88#[cfg(feature = "registry")]
89impl fmt::Display for RegistryError {
90 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
91 write!(formatter, "{}", self.message)
92 }
93}
94
95#[cfg(feature = "registry")]
96impl std::error::Error for RegistryError {}
97
98#[cfg(feature = "registry")]
110#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
111#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
112pub trait Registry: Send + Sync {
113 async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError>;
118
119 fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String>;
125}
126
127#[cfg(feature = "registry")]
138struct HttpFetchError {
139 status_code: Option<u16>,
141 message: String,
143}
144
145#[cfg(feature = "registry")]
149#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
150#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
151trait HttpFetcher: Send + Sync {
152 async fn get(&self, url: &str) -> Result<String, HttpFetchError>;
153}
154
155#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
157struct ReqwestHttpFetcher;
158
159#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
160#[async_trait::async_trait]
161impl HttpFetcher for ReqwestHttpFetcher {
162 async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
163 let response = reqwest::get(url).await.map_err(|e| HttpFetchError {
164 status_code: e.status().map(|s| s.as_u16()),
165 message: e.to_string(),
166 })?;
167 let status = response.status();
168 let body = response.text().await.map_err(|e| HttpFetchError {
169 status_code: None,
170 message: e.to_string(),
171 })?;
172 if !status.is_success() {
173 return Err(HttpFetchError {
174 status_code: Some(status.as_u16()),
175 message: format!("HTTP {}", status),
176 });
177 }
178 Ok(body)
179 }
180}
181
182#[cfg(all(feature = "registry", target_arch = "wasm32"))]
184struct WasmHttpFetcher;
185
186#[cfg(all(feature = "registry", target_arch = "wasm32"))]
187#[async_trait::async_trait(?Send)]
188impl HttpFetcher for WasmHttpFetcher {
189 async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
190 let response = gloo_net::http::Request::get(url)
191 .send()
192 .await
193 .map_err(|e| HttpFetchError {
194 status_code: None,
195 message: e.to_string(),
196 })?;
197 let status = response.status();
198 let ok = response.ok();
199 if !ok {
200 return Err(HttpFetchError {
201 status_code: Some(status),
202 message: format!("HTTP {}", status),
203 });
204 }
205 let text = response.text().await.map_err(|e| HttpFetchError {
206 status_code: None,
207 message: e.to_string(),
208 })?;
209 Ok(text)
210 }
211}
212
213#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
217fn registry_identifier_from_source_url(url: &str) -> Option<String> {
218 let without_suffix = url.strip_suffix(".lemma")?;
219 let path = without_suffix
220 .split_once("://")
221 .map_or(without_suffix, |(_, rest)| {
222 rest.split_once('/').map_or(rest, |(_, p)| p)
223 });
224 if path.is_empty() {
225 None
226 } else {
227 Some(path.to_string())
228 }
229}
230
231#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
233struct FixtureDirFetcher {
234 fixtures: std::collections::HashMap<String, String>,
235}
236
237#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
238impl FixtureDirFetcher {
239 fn from_dir(dir: &Path) -> Self {
240 let mut fixtures = std::collections::HashMap::new();
241 collect_fixture_files(dir, dir, &mut fixtures);
242 Self { fixtures }
243 }
244}
245
246#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
247fn collect_fixture_files(
248 dir: &Path,
249 base: &Path,
250 fixtures: &mut std::collections::HashMap<String, String>,
251) {
252 let entries = std::fs::read_dir(dir)
253 .unwrap_or_else(|e| panic!("BUG: read fixture dir {}: {e}", dir.display()));
254 for entry in entries {
255 let entry =
256 entry.unwrap_or_else(|e| panic!("BUG: fixture dir entry in {}: {e}", dir.display()));
257 let path = entry.path();
258 if path.is_dir() {
259 collect_fixture_files(&path, base, fixtures);
260 continue;
261 }
262 if path.extension().is_none_or(|e| e != "lemma") {
263 continue;
264 }
265 let relative = path
266 .strip_prefix(base)
267 .unwrap_or_else(|_| panic!("BUG: fixture path not under base: {}", path.display()));
268 let identifier = relative
269 .with_extension("")
270 .to_string_lossy()
271 .replace('\\', "/");
272 let content = std::fs::read_to_string(&path)
273 .unwrap_or_else(|e| panic!("BUG: read fixture {}: {e}", path.display()));
274 fixtures.insert(identifier, content);
275 }
276}
277
278#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
279#[async_trait::async_trait]
280impl HttpFetcher for FixtureDirFetcher {
281 async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
282 let identifier =
283 registry_identifier_from_source_url(url).ok_or_else(|| HttpFetchError {
284 status_code: None,
285 message: format!("fixture URL must end with .lemma: {url}"),
286 })?;
287 self.fixtures
288 .get(&identifier)
289 .cloned()
290 .ok_or_else(|| HttpFetchError {
291 status_code: Some(404),
292 message: format!("no fixture for \"{identifier}\" (url {url})"),
293 })
294 }
295}
296
297#[cfg(feature = "registry")]
313pub struct LemmaBase {
314 fetcher: Box<dyn HttpFetcher>,
315}
316
317#[cfg(feature = "registry")]
318impl LemmaBase {
319 #[cfg(debug_assertions)]
324 pub const BASE_URL: &'static str = "http://localhost:4222";
325 #[cfg(not(debug_assertions))]
326 pub const BASE_URL: &'static str = "https://lemmabase.com";
327
328 pub fn new() -> Self {
330 Self {
331 #[cfg(not(target_arch = "wasm32"))]
332 fetcher: Box::new(ReqwestHttpFetcher),
333 #[cfg(target_arch = "wasm32")]
334 fetcher: Box::new(WasmHttpFetcher),
335 }
336 }
337
338 #[cfg(not(target_arch = "wasm32"))]
343 pub fn test() -> Self {
344 Self::with_fixture_dir(Self::test_fixtures_dir())
345 }
346
347 #[cfg(not(target_arch = "wasm32"))]
349 pub fn test_fixtures_dir() -> PathBuf {
350 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/registry_fixtures")
351 }
352
353 #[cfg(not(target_arch = "wasm32"))]
355 pub fn with_fixture_dir(dir: impl AsRef<Path>) -> Self {
356 Self {
357 fetcher: Box::new(FixtureDirFetcher::from_dir(dir.as_ref())),
358 }
359 }
360
361 fn source_url(
363 &self,
364 name: &str,
365 effective: Option<&DateTimeValue>,
366 ) -> Result<String, RegistryError> {
367 if !name.starts_with('@') {
368 return Err(RegistryError {
369 message: format!("Registry identifier must start with '@' (got '{name}')"),
370 kind: RegistryErrorKind::Other,
371 });
372 }
373 let base = format!("{}/{}.lemma", Self::BASE_URL, name);
374 Ok(match effective {
375 None => base,
376 Some(d) => format!("{}?effective={}", base, d),
377 })
378 }
379
380 fn navigation_url(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
382 if !name.starts_with('@') {
383 return None;
384 }
385 let base = format!("{}/{}", Self::BASE_URL, name);
386 Some(match effective {
387 None => base,
388 Some(d) => format!("{}?effective={}", base, d),
389 })
390 }
391
392 fn display_id(name: &str, effective: Option<&DateTimeValue>) -> String {
393 match effective {
394 None => name.to_string(),
395 Some(d) => format!("{name} {d}"),
396 }
397 }
398
399 async fn fetch_source(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
401 let url = self.source_url(name, None)?;
402 let display = Self::display_id(name, None);
403
404 let source = self.fetcher.get(&url).await.map_err(|error| {
405 if let Some(code) = error.status_code {
406 let kind = match code {
407 404 => RegistryErrorKind::NotFound,
408 401 | 403 => RegistryErrorKind::Unauthorized,
409 500..=599 => RegistryErrorKind::ServerError,
410 _ => RegistryErrorKind::Other,
411 };
412 RegistryError {
413 message: format!("LemmaBase returned HTTP {} {} for '{}'", code, url, display),
414 kind,
415 }
416 } else {
417 RegistryError {
418 message: format!(
419 "Failed to reach LemmaBase for '{}': {}",
420 display, error.message
421 ),
422 kind: RegistryErrorKind::NetworkError,
423 }
424 }
425 })?;
426
427 Ok(RegistryBundle {
428 repository: name.to_string(),
429 source,
430 })
431 }
432}
433
434#[cfg(feature = "registry")]
435impl Default for LemmaBase {
436 fn default() -> Self {
437 Self::new()
438 }
439}
440
441#[cfg(feature = "registry")]
442#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
443#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
444impl Registry for LemmaBase {
445 async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
446 self.fetch_source(name).await
447 }
448
449 fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
450 self.navigation_url(name, effective)
451 }
452}
453
454#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
474pub async fn resolve_registry_references(
475 ctx: &mut Context,
476 sources: &mut HashMap<crate::parsing::source::SourceType, String>,
477 registry: &dyn Registry,
478 limits: &ResourceLimits,
479) -> Result<(), Vec<Error>> {
480 let mut already_requested: HashSet<String> = HashSet::new();
481
482 loop {
483 let unresolved = find_missing_repositories(ctx, &already_requested);
484
485 if unresolved.is_empty() {
486 break;
487 }
488
489 let mut round_errors: Vec<Error> = Vec::new();
490 for reference in &unresolved {
491 if already_requested.contains(&reference.repository.name) {
492 continue;
493 }
494 already_requested.insert(reference.repository.name.clone());
495
496 let bundle_result = registry.get(&reference.repository.name).await;
497
498 let dependency = match bundle_result {
499 Ok(d) => d,
500 Err(registry_error) => {
501 let suggestion = match ®istry_error.kind {
502 RegistryErrorKind::NotFound => Some(
503 "Check that the repository qualifier is spelled correctly and that the repository exists on the registry.".to_string(),
504 ),
505 RegistryErrorKind::Unauthorized => Some(
506 "Check your authentication credentials or permissions for this registry.".to_string(),
507 ),
508 RegistryErrorKind::NetworkError => Some(
509 "Check your network connection. To compile without registry access, disable the 'registry' feature.".to_string(),
510 ),
511 RegistryErrorKind::ServerError => Some(
512 "The registry server returned an internal error. Try again later.".to_string(),
513 ),
514 RegistryErrorKind::Other => None,
515 };
516 let spec_context = ctx
517 .iter()
518 .find(|s| s.source_type == Some(reference.source.source_type.clone()));
519 round_errors.push(Error::registry(
520 registry_error.message,
521 reference.source.clone(),
522 reference.repository.name.clone(),
523 registry_error.kind,
524 suggestion,
525 spec_context,
526 None,
527 ));
528 continue;
529 }
530 };
531
532 let source_type =
533 crate::parsing::source::SourceType::Dependency(dependency.repository.clone());
534 sources.insert(source_type.clone(), dependency.source.clone());
535
536 let parsed =
537 match crate::parsing::parse(&dependency.source, source_type.clone(), limits) {
538 Ok(result) => result,
539 Err(e) => {
540 round_errors.push(e);
541 return Err(round_errors);
542 }
543 };
544
545 for (parsed_repo, specs) in parsed.repositories {
546 let repo_name = parsed_repo
547 .name
548 .clone()
549 .unwrap_or_else(|| reference.repository.name.clone());
550 let dep_id = reference.repository.name.clone();
551 let header = LemmaRepository::new(Some(repo_name))
552 .with_dependency(dep_id.clone())
553 .with_start_line(parsed_repo.start_line)
554 .with_source_type(source_type.clone());
555 let repository_arc = Arc::new(header);
556
557 for spec in specs {
558 if let Err(e) = ctx.insert_spec(Arc::clone(&repository_arc), spec) {
559 round_errors.push(e);
560 }
561 }
562 }
563 }
564
565 if !round_errors.is_empty() {
566 return Err(round_errors);
567 }
568 }
569
570 Ok(())
571}
572
573#[derive(Debug, Clone)]
575#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
576struct RegistryReference {
577 repository: RepositoryQualifier,
578 source: Source,
579}
580
581#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
582fn collect_repository_qualifiers_from_spec_ref(
583 spec_ref: &SpecRef,
584 source: &Source,
585 ctx: &Context,
586 already_requested: &HashSet<String>,
587 seen_in_this_round: &mut HashSet<String>,
588 out: &mut Vec<RegistryReference>,
589) {
590 let Some(qualifier) = spec_ref.repository.as_ref() else {
591 return;
592 };
593 if !qualifier.is_registry() {
594 return;
595 }
596 if ctx.find_repository(&qualifier.name).is_some() {
597 return;
598 }
599 if already_requested.contains(&qualifier.name) {
600 return;
601 }
602 if !seen_in_this_round.insert(qualifier.name.clone()) {
603 return;
604 }
605 out.push(RegistryReference {
606 repository: qualifier.clone(),
607 source: source.clone(),
608 });
609}
610
611#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
613fn find_missing_repositories(
614 ctx: &Context,
615 already_requested: &HashSet<String>,
616) -> Vec<RegistryReference> {
617 let mut unresolved: Vec<RegistryReference> = Vec::new();
618 let mut seen_in_this_round: HashSet<String> = HashSet::new();
619
620 for spec in ctx.iter() {
621 for data in &spec.data {
622 if let DataValue::Import(spec_ref) = &data.value {
624 collect_repository_qualifiers_from_spec_ref(
625 spec_ref,
626 &data.source_location,
627 ctx,
628 already_requested,
629 &mut seen_in_this_round,
630 &mut unresolved,
631 );
632 }
633 }
634 }
635
636 unresolved
637}
638
639#[cfg(test)]
644mod tests {
645 use super::*;
646 use crate::engine::Context;
647 use crate::literals::DateGranularity;
648
649 struct TestRegistry {
651 bundles: HashMap<String, RegistryBundle>,
652 }
653
654 impl TestRegistry {
655 fn new() -> Self {
656 Self {
657 bundles: HashMap::new(),
658 }
659 }
660
661 fn add_spec_bundle(&mut self, identifier: &str, source: &str) {
663 self.bundles.insert(
664 identifier.to_string(),
665 RegistryBundle {
666 repository: identifier.to_string(),
667 source: source.to_string(),
668 },
669 );
670 }
671 }
672
673 #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
674 #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
675 impl Registry for TestRegistry {
676 async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
677 self.bundles
678 .get(name)
679 .cloned()
680 .ok_or_else(|| RegistryError {
681 message: format!("'{}' not found in test registry", name),
682 kind: RegistryErrorKind::NotFound,
683 })
684 }
685
686 fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
687 if self.bundles.contains_key(name) {
688 Some(match effective {
689 None => format!("https://test.registry/{}", name),
690 Some(d) => format!("https://test.registry/{}?effective={}", name, d),
691 })
692 } else {
693 None
694 }
695 }
696 }
697
698 fn context_with_embedded_stdlib() -> Context {
699 use crate::engine::EMBEDDED_STDLIB_REPOSITORY;
700 use crate::parsing::ast::LemmaRepository;
701 use crate::parsing::source::SourceType;
702 use crate::stdlib::UNITS_LEMMA;
703
704 let mut ctx = Context::new();
705 let source_type = SourceType::Dependency(EMBEDDED_STDLIB_REPOSITORY.to_string());
706 let parsed = crate::parse(UNITS_LEMMA, source_type, &ResourceLimits::default())
707 .expect("BUG: embedded stdlib must parse");
708 for (parsed_repo, specs) in &parsed.repositories {
709 let repository_arc = Arc::new(
710 LemmaRepository::new(
711 parsed_repo
712 .name
713 .clone()
714 .or_else(|| Some(EMBEDDED_STDLIB_REPOSITORY.to_string())),
715 )
716 .with_dependency(EMBEDDED_STDLIB_REPOSITORY)
717 .with_start_line(parsed_repo.start_line),
718 );
719 for spec in specs {
720 ctx.insert_spec(Arc::clone(&repository_arc), spec.clone())
721 .expect("BUG: embedded stdlib must load");
722 }
723 }
724 ctx
725 }
726
727 #[tokio::test(flavor = "current_thread")]
728 async fn resolve_with_no_registry_references_returns_local_specs_unchanged() {
729 let source = r#"spec example
730data price: 100"#;
731 let local_specs = crate::parse(
732 source,
733 crate::parsing::source::SourceType::Volatile,
734 &ResourceLimits::default(),
735 )
736 .unwrap()
737 .into_flattened_specs();
738 let mut store = context_with_embedded_stdlib();
739 let local_repository = store.workspace();
740 for spec in &local_specs {
741 store
742 .insert_spec(Arc::clone(&local_repository), spec.clone())
743 .unwrap();
744 }
745 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
746 sources.insert(
747 crate::parsing::source::SourceType::Volatile,
748 source.to_string(),
749 );
750
751 let registry = TestRegistry::new();
752 resolve_registry_references(
753 &mut store,
754 &mut sources,
755 ®istry,
756 &ResourceLimits::default(),
757 )
758 .await
759 .unwrap();
760
761 assert_eq!(
762 store.iter().count(),
763 2,
764 "embedded spec units plus workspace example"
765 );
766 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
767 assert!(names.iter().any(|n| n == "example"));
768 assert!(names.iter().any(|n| n == "units"));
769 }
770
771 #[tokio::test(flavor = "current_thread")]
773 async fn resolve_does_not_fetch_non_at_qualified_repositories() {
774 let local_source = r#"spec burn_baby_burn
775uses lemma units
776rule x: 1 hour"#;
777 let local_specs = crate::parse(
778 local_source,
779 crate::parsing::source::SourceType::Volatile,
780 &ResourceLimits::default(),
781 )
782 .unwrap()
783 .into_flattened_specs();
784 let mut store = Context::new();
785 let local_repository = store.workspace();
786 for spec in local_specs {
787 store
788 .insert_spec(Arc::clone(&local_repository), spec)
789 .unwrap();
790 }
791 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
792 sources.insert(
793 crate::parsing::source::SourceType::Volatile,
794 local_source.to_string(),
795 );
796
797 let registry = TestRegistry::new();
798 let result = resolve_registry_references(
799 &mut store,
800 &mut sources,
801 ®istry,
802 &ResourceLimits::default(),
803 )
804 .await;
805
806 assert!(
807 result.is_ok(),
808 "non-@ repository qualifiers must not be sent to the registry, got: {:?}",
809 result.err()
810 );
811 }
812
813 #[tokio::test(flavor = "current_thread")]
814 async fn resolve_fetches_single_spec_from_registry() {
815 let local_source = r#"spec main_spec
816uses external: @org/project helper
817rule value: external.quantity"#;
818 let local_specs = crate::parse(
819 local_source,
820 crate::parsing::source::SourceType::Volatile,
821 &ResourceLimits::default(),
822 )
823 .unwrap()
824 .into_flattened_specs();
825 let mut store = context_with_embedded_stdlib();
826 let local_repository = store.workspace();
827 for spec in local_specs {
828 store
829 .insert_spec(Arc::clone(&local_repository), spec)
830 .unwrap();
831 }
832 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
833 sources.insert(
834 crate::parsing::source::SourceType::Volatile,
835 local_source.to_string(),
836 );
837
838 let mut registry = TestRegistry::new();
839 registry.add_spec_bundle(
840 "@org/project",
841 r#"repo @org/project
842spec helper
843data quantity: 42"#,
844 );
845
846 resolve_registry_references(
847 &mut store,
848 &mut sources,
849 ®istry,
850 &ResourceLimits::default(),
851 )
852 .await
853 .unwrap();
854
855 assert_eq!(store.iter().count(), 3);
856 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
857 assert!(names.iter().any(|n| n == "main_spec"));
858 assert!(names.iter().any(|n| n == "helper"));
859 assert!(names.iter().any(|n| n == "units"));
860 }
861
862 #[tokio::test(flavor = "current_thread")]
863 async fn resolve_registry_bundle_without_repo_decl_uses_reference_repository_name() {
864 let local_source = r#"spec main_spec
865uses external: @org/project helper
866rule value: external.quantity"#;
867 let local_specs = crate::parse(
868 local_source,
869 crate::parsing::source::SourceType::Volatile,
870 &ResourceLimits::default(),
871 )
872 .unwrap()
873 .into_flattened_specs();
874 let mut store = context_with_embedded_stdlib();
875 let local_repository = store.workspace();
876 for spec in local_specs {
877 store
878 .insert_spec(Arc::clone(&local_repository), spec)
879 .unwrap();
880 }
881 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
882 sources.insert(
883 crate::parsing::source::SourceType::Volatile,
884 local_source.to_string(),
885 );
886
887 let mut registry = TestRegistry::new();
888 registry.add_spec_bundle(
889 "@org/project",
890 r#"spec helper
891data quantity: 42"#,
892 );
893
894 resolve_registry_references(
895 &mut store,
896 &mut sources,
897 ®istry,
898 &ResourceLimits::default(),
899 )
900 .await
901 .unwrap();
902
903 let ext_repo = store
904 .find_repository("@org/project")
905 .expect("registry bundle must land under fetched @ id");
906 let spec_names: Vec<String> = store
907 .repositories()
908 .get(&ext_repo)
909 .expect("spec sets for @org/project")
910 .keys()
911 .cloned()
912 .collect();
913 assert!(
914 spec_names.iter().any(|n| n == "helper"),
915 "helper spec should live under @org/project, got {:?}",
916 spec_names
917 );
918 }
919
920 #[tokio::test(flavor = "current_thread")]
921 async fn get_returns_all_zones_and_url_for_id_supports_effective() {
922 let effective = DateTimeValue {
923 year: 2026,
924 month: 1,
925 day: 15,
926 hour: 0,
927 minute: 0,
928 second: 0,
929 microsecond: 0,
930 timezone: None,
931
932 granularity: DateGranularity::Full,
933 };
934 let mut registry = TestRegistry::new();
935 registry.add_spec_bundle(
936 "@org/spec",
937 "spec org/spec 2025-01-01\ndata x: 1\n\nspec org/spec 2026-01-15\ndata x: 2",
938 );
939
940 let bundle = registry.get("@org/spec").await.unwrap();
941 assert!(bundle.source.contains("data x: 1"));
942 assert!(bundle.source.contains("data x: 2"));
943
944 assert_eq!(
945 registry.url_for_id("@org/spec", None),
946 Some("https://test.registry/@org/spec".to_string())
947 );
948 assert_eq!(
949 registry.url_for_id("@org/spec", Some(&effective)),
950 Some("https://test.registry/@org/spec?effective=2026-01-15".to_string())
951 );
952 }
953
954 #[tokio::test(flavor = "current_thread")]
955 async fn resolve_fetches_transitive_dependencies() {
956 let local_source = r#"spec main_spec
957uses a: @org/project spec_a"#;
958 let local_specs = crate::parse(
959 local_source,
960 crate::parsing::source::SourceType::Volatile,
961 &ResourceLimits::default(),
962 )
963 .unwrap()
964 .into_flattened_specs();
965 let mut store = context_with_embedded_stdlib();
966 let local_repository = store.workspace();
967 for spec in local_specs {
968 store
969 .insert_spec(Arc::clone(&local_repository), spec)
970 .unwrap();
971 }
972 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
973 sources.insert(
974 crate::parsing::source::SourceType::Volatile,
975 local_source.to_string(),
976 );
977
978 let mut registry = TestRegistry::new();
979 registry.add_spec_bundle(
980 "@org/project",
981 r#"repo @org/project
982spec spec_a
983uses b: @org/sub spec_b"#,
984 );
985 registry.add_spec_bundle(
986 "@org/sub",
987 r#"repo @org/sub
988spec spec_b
989data value: 99"#,
990 );
991
992 resolve_registry_references(
993 &mut store,
994 &mut sources,
995 ®istry,
996 &ResourceLimits::default(),
997 )
998 .await
999 .unwrap();
1000
1001 assert_eq!(store.iter().count(), 4);
1002 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1003 assert!(names.iter().any(|n| n == "main_spec"));
1004 assert!(names.iter().any(|n| n == "spec_a"));
1005 assert!(names.iter().any(|n| n == "spec_b"));
1006 assert!(names.iter().any(|n| n == "units"));
1007 }
1008
1009 #[tokio::test(flavor = "current_thread")]
1010 async fn resolve_handles_bundle_with_multiple_specs() {
1011 let local_source = r#"spec main_spec
1012uses a: @org/project spec_a"#;
1013 let local_specs = crate::parse(
1014 local_source,
1015 crate::parsing::source::SourceType::Volatile,
1016 &ResourceLimits::default(),
1017 )
1018 .unwrap()
1019 .into_flattened_specs();
1020 let mut store = context_with_embedded_stdlib();
1021 let local_repository = store.workspace();
1022 for spec in local_specs {
1023 store
1024 .insert_spec(Arc::clone(&local_repository), spec)
1025 .unwrap();
1026 }
1027 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
1028 sources.insert(
1029 crate::parsing::source::SourceType::Volatile,
1030 local_source.to_string(),
1031 );
1032
1033 let mut registry = TestRegistry::new();
1034 registry.add_spec_bundle(
1035 "@org/project",
1036 r#"repo @org/project
1037spec spec_a
1038uses b: spec_b
1039
1040spec spec_b
1041data value: 99"#,
1042 );
1043
1044 resolve_registry_references(
1045 &mut store,
1046 &mut sources,
1047 ®istry,
1048 &ResourceLimits::default(),
1049 )
1050 .await
1051 .unwrap();
1052
1053 assert_eq!(store.iter().count(), 4);
1054 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1055 assert!(names.iter().any(|n| n == "main_spec"));
1056 assert!(names.iter().any(|n| n == "spec_a"));
1057 assert!(names.iter().any(|n| n == "spec_b"));
1058 assert!(names.iter().any(|n| n == "units"));
1059 }
1060
1061 #[tokio::test(flavor = "current_thread")]
1062 async fn resolve_returns_registry_error_when_registry_fails() {
1063 let local_source = r#"spec main_spec
1064uses external: @org/project missing"#;
1065 let local_specs = crate::parse(
1066 local_source,
1067 crate::parsing::source::SourceType::Volatile,
1068 &ResourceLimits::default(),
1069 )
1070 .unwrap()
1071 .into_flattened_specs();
1072 let mut store = context_with_embedded_stdlib();
1073 let local_repository = store.workspace();
1074 for spec in local_specs {
1075 store
1076 .insert_spec(Arc::clone(&local_repository), spec)
1077 .unwrap();
1078 }
1079 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
1080 sources.insert(
1081 crate::parsing::source::SourceType::Volatile,
1082 local_source.to_string(),
1083 );
1084
1085 let registry = TestRegistry::new(); let result = resolve_registry_references(
1088 &mut store,
1089 &mut sources,
1090 ®istry,
1091 &ResourceLimits::default(),
1092 )
1093 .await;
1094
1095 assert!(result.is_err(), "Should fail when Registry cannot resolve");
1096 let errs = result.unwrap_err();
1097 let registry_err = errs
1098 .iter()
1099 .find(|e| matches!(e, Error::Registry { .. }))
1100 .expect("expected at least one Registry error");
1101 match registry_err {
1102 Error::Registry {
1103 identifier,
1104 kind,
1105 details,
1106 } => {
1107 assert_eq!(identifier, "@org/project");
1108 assert_eq!(*kind, RegistryErrorKind::NotFound);
1109 assert!(
1110 details.suggestion.is_some(),
1111 "NotFound errors should include a suggestion"
1112 );
1113 }
1114 _ => unreachable!(),
1115 }
1116
1117 let error_message = errs
1118 .iter()
1119 .map(|e| e.to_string())
1120 .collect::<Vec<_>>()
1121 .join(" ");
1122 assert!(
1123 error_message.contains("@org/project"),
1124 "Error should mention the identifier: {}",
1125 error_message
1126 );
1127 }
1128
1129 #[tokio::test(flavor = "current_thread")]
1130 async fn resolve_returns_all_registry_errors_when_multiple_repositorys_fail() {
1131 let local_source = r#"spec main_spec
1132uses @org/example helper
1133uses @iso/countries alpha2
1134data country: alpha2.code"#;
1135 let local_specs = crate::parse(
1136 local_source,
1137 crate::parsing::source::SourceType::Volatile,
1138 &ResourceLimits::default(),
1139 )
1140 .unwrap()
1141 .into_flattened_specs();
1142 let mut store = context_with_embedded_stdlib();
1143 let local_repository = store.workspace();
1144 for spec in local_specs {
1145 store
1146 .insert_spec(Arc::clone(&local_repository), spec)
1147 .unwrap();
1148 }
1149 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
1150 sources.insert(
1151 crate::parsing::source::SourceType::Volatile,
1152 local_source.to_string(),
1153 );
1154
1155 let registry = TestRegistry::new(); let result = resolve_registry_references(
1158 &mut store,
1159 &mut sources,
1160 ®istry,
1161 &ResourceLimits::default(),
1162 )
1163 .await;
1164
1165 assert!(result.is_err(), "Should fail when Registry cannot resolve");
1166 let errors = result.unwrap_err();
1167 let identifiers: Vec<&str> = errors
1168 .iter()
1169 .filter_map(|e| {
1170 if let Error::Registry { identifier, .. } = e {
1171 Some(identifier.as_str())
1172 } else {
1173 None
1174 }
1175 })
1176 .collect();
1177 assert!(
1178 identifiers.contains(&"@org/example"),
1179 "Should include repository error: {:?}",
1180 identifiers
1181 );
1182 assert!(
1183 identifiers.contains(&"@iso/countries"),
1184 "Should include data import repository error: {:?}",
1185 identifiers
1186 );
1187 }
1188
1189 #[tokio::test(flavor = "current_thread")]
1190 async fn resolve_does_not_request_same_repository_twice() {
1191 let local_source = r#"spec spec_one
1192uses a: @org/shared shared
1193
1194spec spec_two
1195uses b: @org/shared shared"#;
1196 let local_specs = crate::parse(
1197 local_source,
1198 crate::parsing::source::SourceType::Volatile,
1199 &ResourceLimits::default(),
1200 )
1201 .unwrap()
1202 .into_flattened_specs();
1203 let mut store = context_with_embedded_stdlib();
1204 let local_repository = store.workspace();
1205 for spec in local_specs {
1206 store
1207 .insert_spec(Arc::clone(&local_repository), spec)
1208 .unwrap();
1209 }
1210 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
1211 sources.insert(
1212 crate::parsing::source::SourceType::Volatile,
1213 local_source.to_string(),
1214 );
1215
1216 let mut registry = TestRegistry::new();
1217 registry.add_spec_bundle(
1218 "@org/shared",
1219 r#"repo @org/shared
1220spec shared
1221data value: 1"#,
1222 );
1223
1224 resolve_registry_references(
1225 &mut store,
1226 &mut sources,
1227 ®istry,
1228 &ResourceLimits::default(),
1229 )
1230 .await
1231 .unwrap();
1232
1233 assert_eq!(store.iter().count(), 4);
1234 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1235 assert!(names.iter().any(|n| n == "shared"));
1236 assert!(names.iter().any(|n| n == "units"));
1237 }
1238
1239 #[tokio::test(flavor = "current_thread")]
1240 async fn resolve_handles_data_import_from_registry() {
1241 let local_source = r#"spec main_spec
1242uses @iso/countries alpha2
1243data country: alpha2.code
1244data home: country"#;
1245 let local_specs = crate::parse(
1246 local_source,
1247 crate::parsing::source::SourceType::Volatile,
1248 &ResourceLimits::default(),
1249 )
1250 .unwrap()
1251 .into_flattened_specs();
1252 let mut store = context_with_embedded_stdlib();
1253 let local_repository = store.workspace();
1254 for spec in local_specs {
1255 store
1256 .insert_spec(Arc::clone(&local_repository), spec)
1257 .unwrap();
1258 }
1259 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
1260 sources.insert(
1261 crate::parsing::source::SourceType::Volatile,
1262 local_source.to_string(),
1263 );
1264
1265 let mut registry = TestRegistry::new();
1266 registry.add_spec_bundle(
1267 "@iso/countries",
1268 r#"repo @iso/countries
1269spec alpha2
1270data code: text
1271 -> option "NL""#,
1272 );
1273
1274 resolve_registry_references(
1275 &mut store,
1276 &mut sources,
1277 ®istry,
1278 &ResourceLimits::default(),
1279 )
1280 .await
1281 .unwrap();
1282
1283 assert_eq!(store.iter().count(), 3);
1284 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1285 assert!(names.iter().any(|n| n == "main_spec"));
1286 assert!(names.iter().any(|n| n == "alpha2"));
1287 assert!(names.iter().any(|n| n == "units"));
1288 }
1289
1290 #[cfg(feature = "registry")]
1295 mod lemmabase_tests {
1296 fn lemma_base_with_fetcher(fetcher: Box<dyn HttpFetcher>) -> LemmaBase {
1297 LemmaBase { fetcher }
1298 }
1299
1300 use super::super::*;
1301 use crate::literals::DateGranularity;
1302 use std::sync::{Arc, Mutex};
1303
1304 type HttpFetchHandler = Box<dyn Fn(&str) -> Result<String, HttpFetchError> + Send + Sync>;
1309
1310 struct MockHttpFetcher {
1311 handler: HttpFetchHandler,
1312 }
1313
1314 impl MockHttpFetcher {
1315 fn with_handler(
1317 handler: impl Fn(&str) -> Result<String, HttpFetchError> + Send + Sync + 'static,
1318 ) -> Self {
1319 Self {
1320 handler: Box::new(handler),
1321 }
1322 }
1323
1324 fn always_returning(body: &str) -> Self {
1326 let body = body.to_string();
1327 Self::with_handler(move |_| Ok(body.clone()))
1328 }
1329
1330 fn always_failing_with_status(code: u16) -> Self {
1332 Self::with_handler(move |_| {
1333 Err(HttpFetchError {
1334 status_code: Some(code),
1335 message: format!("HTTP {}", code),
1336 })
1337 })
1338 }
1339
1340 fn always_failing_with_network_error(msg: &str) -> Self {
1342 let msg = msg.to_string();
1343 Self::with_handler(move |_| {
1344 Err(HttpFetchError {
1345 status_code: None,
1346 message: msg.clone(),
1347 })
1348 })
1349 }
1350 }
1351
1352 #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
1353 #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
1354 impl HttpFetcher for MockHttpFetcher {
1355 async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
1356 (self.handler)(url)
1357 }
1358 }
1359
1360 #[test]
1365 fn source_url_without_effective() {
1366 let registry = LemmaBase::new();
1367 let url = registry
1368 .source_url("@user/workspace/somespec", None)
1369 .unwrap();
1370 assert_eq!(
1371 url,
1372 format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1373 );
1374 }
1375
1376 #[test]
1377 fn source_url_with_effective() {
1378 let registry = LemmaBase::new();
1379 let effective = DateTimeValue {
1380 year: 2026,
1381 month: 1,
1382 day: 15,
1383 hour: 0,
1384 minute: 0,
1385 second: 0,
1386 microsecond: 0,
1387 timezone: None,
1388
1389 granularity: DateGranularity::Full,
1390 };
1391 let url = registry
1392 .source_url("@user/workspace/somespec", Some(&effective))
1393 .unwrap();
1394 assert_eq!(
1395 url,
1396 format!(
1397 "{}/@user/workspace/somespec.lemma?effective=2026-01-15",
1398 LemmaBase::BASE_URL
1399 )
1400 );
1401 }
1402
1403 #[test]
1404 fn source_url_for_deeply_nested_identifier() {
1405 let registry = LemmaBase::new();
1406 let url = registry
1407 .source_url("@org/team/project/subdir/spec", None)
1408 .unwrap();
1409 assert_eq!(
1410 url,
1411 format!(
1412 "{}/@org/team/project/subdir/spec.lemma",
1413 LemmaBase::BASE_URL
1414 )
1415 );
1416 }
1417
1418 #[test]
1419 fn source_url_rejects_id_without_at() {
1420 let registry = LemmaBase::new();
1421 let err = registry
1422 .source_url("not-a-registry-id", None)
1423 .expect_err("non-@ id must be a registry error");
1424 assert_eq!(err.kind, RegistryErrorKind::Other);
1425 assert!(
1426 err.message.contains("must start with '@'"),
1427 "got: {}",
1428 err.message
1429 );
1430 assert!(
1431 err.message.contains("not-a-registry-id"),
1432 "got: {}",
1433 err.message
1434 );
1435 }
1436
1437 #[test]
1438 fn navigation_url_without_effective() {
1439 let registry = LemmaBase::new();
1440 let url = registry
1441 .navigation_url("@user/workspace/somespec", None)
1442 .unwrap();
1443 assert_eq!(
1444 url,
1445 format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL)
1446 );
1447 }
1448
1449 #[test]
1450 fn navigation_url_with_effective() {
1451 let registry = LemmaBase::new();
1452 let effective = DateTimeValue {
1453 year: 2026,
1454 month: 1,
1455 day: 15,
1456 hour: 0,
1457 minute: 0,
1458 second: 0,
1459 microsecond: 0,
1460 timezone: None,
1461
1462 granularity: DateGranularity::Full,
1463 };
1464 let url = registry
1465 .navigation_url("@user/workspace/somespec", Some(&effective))
1466 .unwrap();
1467 assert_eq!(
1468 url,
1469 format!(
1470 "{}/@user/workspace/somespec?effective=2026-01-15",
1471 LemmaBase::BASE_URL
1472 )
1473 );
1474 }
1475
1476 #[test]
1477 fn navigation_url_rejects_id_without_at() {
1478 let registry = LemmaBase::new();
1479 assert!(registry.navigation_url("iso/countries", None).is_none());
1480 }
1481
1482 #[test]
1483 fn url_for_id_returns_navigation_url() {
1484 let registry = LemmaBase::new();
1485 let url = registry.url_for_id("@user/workspace/somespec", None);
1486 assert_eq!(
1487 url,
1488 Some(format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL))
1489 );
1490 }
1491
1492 #[test]
1493 fn url_for_id_with_effective() {
1494 let registry = LemmaBase::new();
1495 let effective = DateTimeValue {
1496 year: 2026,
1497 month: 1,
1498 day: 1,
1499 hour: 0,
1500 minute: 0,
1501 second: 0,
1502 microsecond: 0,
1503 timezone: None,
1504
1505 granularity: DateGranularity::Full,
1506 };
1507 let url = registry.url_for_id("@owner/repo/spec", Some(&effective));
1508 assert_eq!(
1509 url,
1510 Some(format!(
1511 "{}/@owner/repo/spec?effective=2026-01-01",
1512 LemmaBase::BASE_URL
1513 ))
1514 );
1515 }
1516
1517 #[test]
1518 fn url_for_id_returns_navigation_url_for_nested_path() {
1519 let registry = LemmaBase::new();
1520 let url = registry.url_for_id("@iso/countries/alpha2", None);
1521 assert_eq!(
1522 url,
1523 Some(format!("{}/@iso/countries/alpha2", LemmaBase::BASE_URL))
1524 );
1525 }
1526
1527 #[tokio::test(flavor = "current_thread")]
1532 async fn test_mode_serves_bundled_fixtures() {
1533 let registry = LemmaBase::test();
1534 let iso = registry.get("@iso/countries").await.unwrap();
1535 assert!(iso.source.contains("spec alpha2"));
1536 }
1537
1538 #[tokio::test(flavor = "current_thread")]
1539 async fn fetch_source_rejects_id_without_at() {
1540 let registry =
1541 lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_returning("unused")));
1542 let err = registry
1543 .fetch_source("not-a-registry-id")
1544 .await
1545 .expect_err("non-@ id must not hit the network");
1546 assert_eq!(err.kind, RegistryErrorKind::Other);
1547 assert!(
1548 err.message.contains("must start with '@'"),
1549 "got: {}",
1550 err.message
1551 );
1552 }
1553
1554 #[tokio::test(flavor = "current_thread")]
1555 async fn fetch_source_returns_bundle_on_success() {
1556 let registry = lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_returning(
1557 "spec org/my_spec\ndata x: 1",
1558 )));
1559
1560 let bundle = registry.fetch_source("@org/my_spec").await.unwrap();
1561
1562 assert_eq!(bundle.source, "spec org/my_spec\ndata x: 1");
1563 assert_eq!(bundle.repository, "@org/my_spec");
1564 }
1565
1566 #[tokio::test(flavor = "current_thread")]
1567 async fn fetch_source_passes_correct_url_to_fetcher() {
1568 let captured_url = Arc::new(Mutex::new(String::new()));
1569 let captured = captured_url.clone();
1570 let mock = MockHttpFetcher::with_handler(move |url| {
1571 *captured.lock().unwrap() = url.to_string();
1572 Ok("spec test/spec\ndata x: 1".to_string())
1573 });
1574 let registry = lemma_base_with_fetcher(Box::new(mock));
1575
1576 let _ = registry.fetch_source("@user/workspace/somespec").await;
1577
1578 assert_eq!(
1579 *captured_url.lock().unwrap(),
1580 format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1581 );
1582 }
1583
1584 #[tokio::test(flavor = "current_thread")]
1585 async fn fetch_source_maps_http_404_to_not_found() {
1586 let registry =
1587 lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(404)));
1588
1589 let err = registry.fetch_source("@org/missing").await.unwrap_err();
1590
1591 assert_eq!(err.kind, RegistryErrorKind::NotFound);
1592 assert!(
1593 err.message.contains("HTTP 404"),
1594 "Expected 'HTTP 404' in: {}",
1595 err.message
1596 );
1597 assert!(
1598 err.message.contains("@org/missing"),
1599 "Expected '@org/missing' in: {}",
1600 err.message
1601 );
1602 }
1603
1604 #[tokio::test(flavor = "current_thread")]
1605 async fn fetch_source_maps_http_500_to_server_error() {
1606 let registry =
1607 lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(500)));
1608
1609 let err = registry.fetch_source("@org/broken").await.unwrap_err();
1610
1611 assert_eq!(err.kind, RegistryErrorKind::ServerError);
1612 assert!(
1613 err.message.contains("HTTP 500"),
1614 "Expected 'HTTP 500' in: {}",
1615 err.message
1616 );
1617 }
1618
1619 #[tokio::test(flavor = "current_thread")]
1620 async fn fetch_source_maps_http_401_to_unauthorized() {
1621 let registry =
1622 lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(401)));
1623
1624 let err = registry.fetch_source("@org/secret").await.unwrap_err();
1625
1626 assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1627 assert!(err.message.contains("HTTP 401"));
1628 }
1629
1630 #[tokio::test(flavor = "current_thread")]
1631 async fn fetch_source_maps_http_403_to_unauthorized() {
1632 let registry =
1633 lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(403)));
1634
1635 let err = registry.fetch_source("@org/private").await.unwrap_err();
1636
1637 assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1638 assert!(
1639 err.message.contains("HTTP 403"),
1640 "Expected 'HTTP 403' in: {}",
1641 err.message
1642 );
1643 }
1644
1645 #[tokio::test(flavor = "current_thread")]
1646 async fn fetch_source_maps_unexpected_status_to_other() {
1647 let registry =
1648 lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(418)));
1649
1650 let err = registry.fetch_source("@org/teapot").await.unwrap_err();
1651
1652 assert_eq!(err.kind, RegistryErrorKind::Other);
1653 assert!(err.message.contains("HTTP 418"));
1654 }
1655
1656 #[tokio::test(flavor = "current_thread")]
1657 async fn fetch_source_maps_network_error_to_network_error_kind() {
1658 let registry = lemma_base_with_fetcher(Box::new(
1659 MockHttpFetcher::always_failing_with_network_error("connection refused"),
1660 ));
1661
1662 let err = registry.fetch_source("@org/unreachable").await.unwrap_err();
1663
1664 assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1665 assert!(
1666 err.message.contains("connection refused"),
1667 "Expected 'connection refused' in: {}",
1668 err.message
1669 );
1670 assert!(
1671 err.message.contains("@org/unreachable"),
1672 "Expected '@org/unreachable' in: {}",
1673 err.message
1674 );
1675 }
1676
1677 #[tokio::test(flavor = "current_thread")]
1678 async fn fetch_source_maps_dns_error_to_network_error_kind() {
1679 let registry = lemma_base_with_fetcher(Box::new(
1680 MockHttpFetcher::always_failing_with_network_error(
1681 "dns error: failed to lookup address",
1682 ),
1683 ));
1684
1685 let err = registry.fetch_source("@org/spec").await.unwrap_err();
1686
1687 assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1688 assert!(
1689 err.message.contains("dns error"),
1690 "Expected 'dns error' in: {}",
1691 err.message
1692 );
1693 assert!(
1694 err.message.contains("Failed to reach LemmaBase"),
1695 "Expected 'Failed to reach LemmaBase' in: {}",
1696 err.message
1697 );
1698 }
1699
1700 #[tokio::test(flavor = "current_thread")]
1705 async fn get_delegates_to_fetch_source() {
1706 let registry = lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_returning(
1707 "spec org/resolved\ndata a: 1",
1708 )));
1709
1710 let bundle = registry.get("@org/resolved").await.unwrap();
1711
1712 assert_eq!(bundle.source, "spec org/resolved\ndata a: 1");
1713 assert_eq!(bundle.repository, "@org/resolved");
1714 }
1715
1716 #[tokio::test(flavor = "current_thread")]
1717 async fn fetch_source_returns_empty_body_as_valid_bundle() {
1718 let registry = lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_returning("")));
1719
1720 let bundle = registry.fetch_source("@org/empty").await.unwrap();
1721
1722 assert_eq!(bundle.source, "");
1723 assert_eq!(bundle.repository, "@org/empty");
1724 }
1725 }
1726}