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(&self, name: &str, effective: Option<&DateTimeValue>) -> String {
363 let base = format!("{}/{}.lemma", Self::BASE_URL, name);
364 match effective {
365 None => base,
366 Some(d) => format!("{}?effective={}", base, d),
367 }
368 }
369
370 fn navigation_url(&self, name: &str, effective: Option<&DateTimeValue>) -> String {
372 let base = format!("{}/{}", Self::BASE_URL, name);
373 match effective {
374 None => base,
375 Some(d) => format!("{}?effective={}", base, d),
376 }
377 }
378
379 fn display_id(name: &str, effective: Option<&DateTimeValue>) -> String {
380 match effective {
381 None => name.to_string(),
382 Some(d) => format!("{name} {d}"),
383 }
384 }
385
386 async fn fetch_source(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
388 let url = self.source_url(name, None);
389 let display = Self::display_id(name, None);
390
391 let source = self.fetcher.get(&url).await.map_err(|error| {
392 if let Some(code) = error.status_code {
393 let kind = match code {
394 404 => RegistryErrorKind::NotFound,
395 401 | 403 => RegistryErrorKind::Unauthorized,
396 500..=599 => RegistryErrorKind::ServerError,
397 _ => RegistryErrorKind::Other,
398 };
399 RegistryError {
400 message: format!("LemmaBase returned HTTP {} {} for '{}'", code, url, display),
401 kind,
402 }
403 } else {
404 RegistryError {
405 message: format!(
406 "Failed to reach LemmaBase for '{}': {}",
407 display, error.message
408 ),
409 kind: RegistryErrorKind::NetworkError,
410 }
411 }
412 })?;
413
414 Ok(RegistryBundle {
415 repository: name.to_string(),
416 source,
417 })
418 }
419}
420
421#[cfg(feature = "registry")]
422impl Default for LemmaBase {
423 fn default() -> Self {
424 Self::new()
425 }
426}
427
428#[cfg(feature = "registry")]
429#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
430#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
431impl Registry for LemmaBase {
432 async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
433 self.fetch_source(name).await
434 }
435
436 fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
437 Some(self.navigation_url(name, effective))
438 }
439}
440
441#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
461pub async fn resolve_registry_references(
462 ctx: &mut Context,
463 sources: &mut HashMap<crate::parsing::source::SourceType, String>,
464 registry: &dyn Registry,
465 limits: &ResourceLimits,
466) -> Result<(), Vec<Error>> {
467 let mut already_requested: HashSet<String> = HashSet::new();
468
469 loop {
470 let unresolved = find_missing_repositories(ctx, &already_requested);
471
472 if unresolved.is_empty() {
473 break;
474 }
475
476 let mut round_errors: Vec<Error> = Vec::new();
477 for reference in &unresolved {
478 if already_requested.contains(&reference.repository.name) {
479 continue;
480 }
481 already_requested.insert(reference.repository.name.clone());
482
483 let bundle_result = registry.get(&reference.repository.name).await;
484
485 let dependency = match bundle_result {
486 Ok(d) => d,
487 Err(registry_error) => {
488 let suggestion = match ®istry_error.kind {
489 RegistryErrorKind::NotFound => Some(
490 "Check that the repository qualifier is spelled correctly and that the repository exists on the registry.".to_string(),
491 ),
492 RegistryErrorKind::Unauthorized => Some(
493 "Check your authentication credentials or permissions for this registry.".to_string(),
494 ),
495 RegistryErrorKind::NetworkError => Some(
496 "Check your network connection. To compile without registry access, disable the 'registry' feature.".to_string(),
497 ),
498 RegistryErrorKind::ServerError => Some(
499 "The registry server returned an internal error. Try again later.".to_string(),
500 ),
501 RegistryErrorKind::Other => None,
502 };
503 let spec_context = ctx
504 .iter()
505 .find(|s| s.source_type == Some(reference.source.source_type.clone()));
506 round_errors.push(Error::registry(
507 registry_error.message,
508 reference.source.clone(),
509 reference.repository.name.clone(),
510 registry_error.kind,
511 suggestion,
512 spec_context,
513 None,
514 ));
515 continue;
516 }
517 };
518
519 let source_type =
520 crate::parsing::source::SourceType::Dependency(dependency.repository.clone());
521 sources.insert(source_type.clone(), dependency.source.clone());
522
523 let parsed =
524 match crate::parsing::parse(&dependency.source, source_type.clone(), limits) {
525 Ok(result) => result,
526 Err(e) => {
527 round_errors.push(e);
528 return Err(round_errors);
529 }
530 };
531
532 for (parsed_repo, specs) in parsed.repositories {
533 let repo_name = parsed_repo
534 .name
535 .clone()
536 .unwrap_or_else(|| reference.repository.name.clone());
537 let dep_id = reference.repository.name.clone();
538 let header = LemmaRepository::new(Some(repo_name))
539 .with_dependency(dep_id.clone())
540 .with_start_line(parsed_repo.start_line)
541 .with_source_type(source_type.clone());
542 let repository_arc = Arc::new(header);
543
544 for spec in specs {
545 if let Err(e) = ctx.insert_spec(Arc::clone(&repository_arc), spec) {
546 round_errors.push(e);
547 }
548 }
549 }
550 }
551
552 if !round_errors.is_empty() {
553 return Err(round_errors);
554 }
555 }
556
557 Ok(())
558}
559
560#[derive(Debug, Clone)]
562#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
563struct RegistryReference {
564 repository: RepositoryQualifier,
565 source: Source,
566}
567
568#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
569fn collect_repository_qualifiers_from_spec_ref(
570 spec_ref: &SpecRef,
571 source: &Source,
572 ctx: &Context,
573 already_requested: &HashSet<String>,
574 seen_in_this_round: &mut HashSet<String>,
575 out: &mut Vec<RegistryReference>,
576) {
577 let Some(qualifier) = spec_ref.repository.as_ref() else {
578 return;
579 };
580 if !qualifier.is_registry() {
581 return;
582 }
583 if ctx.find_repository(&qualifier.name).is_some() {
584 return;
585 }
586 if already_requested.contains(&qualifier.name) {
587 return;
588 }
589 if !seen_in_this_round.insert(qualifier.name.clone()) {
590 return;
591 }
592 out.push(RegistryReference {
593 repository: qualifier.clone(),
594 source: source.clone(),
595 });
596}
597
598#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
600fn find_missing_repositories(
601 ctx: &Context,
602 already_requested: &HashSet<String>,
603) -> Vec<RegistryReference> {
604 let mut unresolved: Vec<RegistryReference> = Vec::new();
605 let mut seen_in_this_round: HashSet<String> = HashSet::new();
606
607 for spec in ctx.iter() {
608 for data in &spec.data {
609 if let DataValue::Import(spec_ref) = &data.value {
611 collect_repository_qualifiers_from_spec_ref(
612 spec_ref,
613 &data.source_location,
614 ctx,
615 already_requested,
616 &mut seen_in_this_round,
617 &mut unresolved,
618 );
619 }
620 }
621 }
622
623 unresolved
624}
625
626#[cfg(test)]
631mod tests {
632 use super::*;
633 use crate::engine::Context;
634 use crate::literals::DateGranularity;
635
636 struct TestRegistry {
638 bundles: HashMap<String, RegistryBundle>,
639 }
640
641 impl TestRegistry {
642 fn new() -> Self {
643 Self {
644 bundles: HashMap::new(),
645 }
646 }
647
648 fn add_spec_bundle(&mut self, identifier: &str, source: &str) {
650 self.bundles.insert(
651 identifier.to_string(),
652 RegistryBundle {
653 repository: identifier.to_string(),
654 source: source.to_string(),
655 },
656 );
657 }
658 }
659
660 #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
661 #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
662 impl Registry for TestRegistry {
663 async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
664 self.bundles
665 .get(name)
666 .cloned()
667 .ok_or_else(|| RegistryError {
668 message: format!("'{}' not found in test registry", name),
669 kind: RegistryErrorKind::NotFound,
670 })
671 }
672
673 fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
674 if self.bundles.contains_key(name) {
675 Some(match effective {
676 None => format!("https://test.registry/{}", name),
677 Some(d) => format!("https://test.registry/{}?effective={}", name, d),
678 })
679 } else {
680 None
681 }
682 }
683 }
684
685 fn context_with_embedded_stdlib() -> Context {
686 use crate::engine::EMBEDDED_STDLIB_REPOSITORY;
687 use crate::parsing::ast::LemmaRepository;
688 use crate::parsing::source::SourceType;
689 use crate::stdlib::UNITS_LEMMA;
690
691 let mut ctx = Context::new();
692 let source_type = SourceType::Dependency(EMBEDDED_STDLIB_REPOSITORY.to_string());
693 let parsed = crate::parse(UNITS_LEMMA, source_type, &ResourceLimits::default())
694 .expect("BUG: embedded stdlib must parse");
695 for (parsed_repo, specs) in &parsed.repositories {
696 let repository_arc = Arc::new(
697 LemmaRepository::new(
698 parsed_repo
699 .name
700 .clone()
701 .or_else(|| Some(EMBEDDED_STDLIB_REPOSITORY.to_string())),
702 )
703 .with_dependency(EMBEDDED_STDLIB_REPOSITORY)
704 .with_start_line(parsed_repo.start_line),
705 );
706 for spec in specs {
707 ctx.insert_spec(Arc::clone(&repository_arc), spec.clone())
708 .expect("BUG: embedded stdlib must load");
709 }
710 }
711 ctx
712 }
713
714 #[tokio::test(flavor = "current_thread")]
715 async fn resolve_with_no_registry_references_returns_local_specs_unchanged() {
716 let source = r#"spec example
717data price: 100"#;
718 let local_specs = crate::parse(
719 source,
720 crate::parsing::source::SourceType::Volatile,
721 &ResourceLimits::default(),
722 )
723 .unwrap()
724 .into_flattened_specs();
725 let mut store = context_with_embedded_stdlib();
726 let local_repository = store.workspace();
727 for spec in &local_specs {
728 store
729 .insert_spec(Arc::clone(&local_repository), spec.clone())
730 .unwrap();
731 }
732 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
733 sources.insert(
734 crate::parsing::source::SourceType::Volatile,
735 source.to_string(),
736 );
737
738 let registry = TestRegistry::new();
739 resolve_registry_references(
740 &mut store,
741 &mut sources,
742 ®istry,
743 &ResourceLimits::default(),
744 )
745 .await
746 .unwrap();
747
748 assert_eq!(
749 store.iter().count(),
750 2,
751 "embedded spec units plus workspace example"
752 );
753 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
754 assert!(names.iter().any(|n| n == "example"));
755 assert!(names.iter().any(|n| n == "units"));
756 }
757
758 #[tokio::test(flavor = "current_thread")]
760 async fn resolve_does_not_fetch_non_at_qualified_repositories() {
761 let local_source = r#"spec burn_baby_burn
762uses lemma units
763rule x: 1 hour"#;
764 let local_specs = crate::parse(
765 local_source,
766 crate::parsing::source::SourceType::Volatile,
767 &ResourceLimits::default(),
768 )
769 .unwrap()
770 .into_flattened_specs();
771 let mut store = Context::new();
772 let local_repository = store.workspace();
773 for spec in local_specs {
774 store
775 .insert_spec(Arc::clone(&local_repository), spec)
776 .unwrap();
777 }
778 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
779 sources.insert(
780 crate::parsing::source::SourceType::Volatile,
781 local_source.to_string(),
782 );
783
784 let registry = TestRegistry::new();
785 let result = resolve_registry_references(
786 &mut store,
787 &mut sources,
788 ®istry,
789 &ResourceLimits::default(),
790 )
791 .await;
792
793 assert!(
794 result.is_ok(),
795 "non-@ repository qualifiers must not be sent to the registry, got: {:?}",
796 result.err()
797 );
798 }
799
800 #[tokio::test(flavor = "current_thread")]
801 async fn resolve_fetches_single_spec_from_registry() {
802 let local_source = r#"spec main_spec
803uses external: @org/project helper
804rule value: external.quantity"#;
805 let local_specs = crate::parse(
806 local_source,
807 crate::parsing::source::SourceType::Volatile,
808 &ResourceLimits::default(),
809 )
810 .unwrap()
811 .into_flattened_specs();
812 let mut store = context_with_embedded_stdlib();
813 let local_repository = store.workspace();
814 for spec in local_specs {
815 store
816 .insert_spec(Arc::clone(&local_repository), spec)
817 .unwrap();
818 }
819 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
820 sources.insert(
821 crate::parsing::source::SourceType::Volatile,
822 local_source.to_string(),
823 );
824
825 let mut registry = TestRegistry::new();
826 registry.add_spec_bundle(
827 "@org/project",
828 r#"repo @org/project
829spec helper
830data quantity: 42"#,
831 );
832
833 resolve_registry_references(
834 &mut store,
835 &mut sources,
836 ®istry,
837 &ResourceLimits::default(),
838 )
839 .await
840 .unwrap();
841
842 assert_eq!(store.iter().count(), 3);
843 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
844 assert!(names.iter().any(|n| n == "main_spec"));
845 assert!(names.iter().any(|n| n == "helper"));
846 assert!(names.iter().any(|n| n == "units"));
847 }
848
849 #[tokio::test(flavor = "current_thread")]
850 async fn resolve_registry_bundle_without_repo_decl_uses_reference_repository_name() {
851 let local_source = r#"spec main_spec
852uses external: @org/project helper
853rule value: external.quantity"#;
854 let local_specs = crate::parse(
855 local_source,
856 crate::parsing::source::SourceType::Volatile,
857 &ResourceLimits::default(),
858 )
859 .unwrap()
860 .into_flattened_specs();
861 let mut store = context_with_embedded_stdlib();
862 let local_repository = store.workspace();
863 for spec in local_specs {
864 store
865 .insert_spec(Arc::clone(&local_repository), spec)
866 .unwrap();
867 }
868 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
869 sources.insert(
870 crate::parsing::source::SourceType::Volatile,
871 local_source.to_string(),
872 );
873
874 let mut registry = TestRegistry::new();
875 registry.add_spec_bundle(
876 "@org/project",
877 r#"spec helper
878data quantity: 42"#,
879 );
880
881 resolve_registry_references(
882 &mut store,
883 &mut sources,
884 ®istry,
885 &ResourceLimits::default(),
886 )
887 .await
888 .unwrap();
889
890 let ext_repo = store
891 .find_repository("@org/project")
892 .expect("registry bundle must land under fetched @ id");
893 let spec_names: Vec<String> = store
894 .repositories()
895 .get(&ext_repo)
896 .expect("spec sets for @org/project")
897 .keys()
898 .cloned()
899 .collect();
900 assert!(
901 spec_names.iter().any(|n| n == "helper"),
902 "helper spec should live under @org/project, got {:?}",
903 spec_names
904 );
905 }
906
907 #[tokio::test(flavor = "current_thread")]
908 async fn get_returns_all_zones_and_url_for_id_supports_effective() {
909 let effective = DateTimeValue {
910 year: 2026,
911 month: 1,
912 day: 15,
913 hour: 0,
914 minute: 0,
915 second: 0,
916 microsecond: 0,
917 timezone: None,
918
919 granularity: DateGranularity::Full,
920 };
921 let mut registry = TestRegistry::new();
922 registry.add_spec_bundle(
923 "@org/spec",
924 "spec org/spec 2025-01-01\ndata x: 1\n\nspec org/spec 2026-01-15\ndata x: 2",
925 );
926
927 let bundle = registry.get("@org/spec").await.unwrap();
928 assert!(bundle.source.contains("data x: 1"));
929 assert!(bundle.source.contains("data x: 2"));
930
931 assert_eq!(
932 registry.url_for_id("@org/spec", None),
933 Some("https://test.registry/@org/spec".to_string())
934 );
935 assert_eq!(
936 registry.url_for_id("@org/spec", Some(&effective)),
937 Some("https://test.registry/@org/spec?effective=2026-01-15".to_string())
938 );
939 }
940
941 #[tokio::test(flavor = "current_thread")]
942 async fn resolve_fetches_transitive_dependencies() {
943 let local_source = r#"spec main_spec
944uses a: @org/project spec_a"#;
945 let local_specs = crate::parse(
946 local_source,
947 crate::parsing::source::SourceType::Volatile,
948 &ResourceLimits::default(),
949 )
950 .unwrap()
951 .into_flattened_specs();
952 let mut store = context_with_embedded_stdlib();
953 let local_repository = store.workspace();
954 for spec in local_specs {
955 store
956 .insert_spec(Arc::clone(&local_repository), spec)
957 .unwrap();
958 }
959 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
960 sources.insert(
961 crate::parsing::source::SourceType::Volatile,
962 local_source.to_string(),
963 );
964
965 let mut registry = TestRegistry::new();
966 registry.add_spec_bundle(
967 "@org/project",
968 r#"repo @org/project
969spec spec_a
970uses b: @org/sub spec_b"#,
971 );
972 registry.add_spec_bundle(
973 "@org/sub",
974 r#"repo @org/sub
975spec spec_b
976data value: 99"#,
977 );
978
979 resolve_registry_references(
980 &mut store,
981 &mut sources,
982 ®istry,
983 &ResourceLimits::default(),
984 )
985 .await
986 .unwrap();
987
988 assert_eq!(store.iter().count(), 4);
989 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
990 assert!(names.iter().any(|n| n == "main_spec"));
991 assert!(names.iter().any(|n| n == "spec_a"));
992 assert!(names.iter().any(|n| n == "spec_b"));
993 assert!(names.iter().any(|n| n == "units"));
994 }
995
996 #[tokio::test(flavor = "current_thread")]
997 async fn resolve_handles_bundle_with_multiple_specs() {
998 let local_source = r#"spec main_spec
999uses a: @org/project spec_a"#;
1000 let local_specs = crate::parse(
1001 local_source,
1002 crate::parsing::source::SourceType::Volatile,
1003 &ResourceLimits::default(),
1004 )
1005 .unwrap()
1006 .into_flattened_specs();
1007 let mut store = context_with_embedded_stdlib();
1008 let local_repository = store.workspace();
1009 for spec in local_specs {
1010 store
1011 .insert_spec(Arc::clone(&local_repository), spec)
1012 .unwrap();
1013 }
1014 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
1015 sources.insert(
1016 crate::parsing::source::SourceType::Volatile,
1017 local_source.to_string(),
1018 );
1019
1020 let mut registry = TestRegistry::new();
1021 registry.add_spec_bundle(
1022 "@org/project",
1023 r#"repo @org/project
1024spec spec_a
1025uses b: spec_b
1026
1027spec spec_b
1028data value: 99"#,
1029 );
1030
1031 resolve_registry_references(
1032 &mut store,
1033 &mut sources,
1034 ®istry,
1035 &ResourceLimits::default(),
1036 )
1037 .await
1038 .unwrap();
1039
1040 assert_eq!(store.iter().count(), 4);
1041 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1042 assert!(names.iter().any(|n| n == "main_spec"));
1043 assert!(names.iter().any(|n| n == "spec_a"));
1044 assert!(names.iter().any(|n| n == "spec_b"));
1045 assert!(names.iter().any(|n| n == "units"));
1046 }
1047
1048 #[tokio::test(flavor = "current_thread")]
1049 async fn resolve_returns_registry_error_when_registry_fails() {
1050 let local_source = r#"spec main_spec
1051uses external: @org/project missing"#;
1052 let local_specs = crate::parse(
1053 local_source,
1054 crate::parsing::source::SourceType::Volatile,
1055 &ResourceLimits::default(),
1056 )
1057 .unwrap()
1058 .into_flattened_specs();
1059 let mut store = context_with_embedded_stdlib();
1060 let local_repository = store.workspace();
1061 for spec in local_specs {
1062 store
1063 .insert_spec(Arc::clone(&local_repository), spec)
1064 .unwrap();
1065 }
1066 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
1067 sources.insert(
1068 crate::parsing::source::SourceType::Volatile,
1069 local_source.to_string(),
1070 );
1071
1072 let registry = TestRegistry::new(); let result = resolve_registry_references(
1075 &mut store,
1076 &mut sources,
1077 ®istry,
1078 &ResourceLimits::default(),
1079 )
1080 .await;
1081
1082 assert!(result.is_err(), "Should fail when Registry cannot resolve");
1083 let errs = result.unwrap_err();
1084 let registry_err = errs
1085 .iter()
1086 .find(|e| matches!(e, Error::Registry { .. }))
1087 .expect("expected at least one Registry error");
1088 match registry_err {
1089 Error::Registry {
1090 identifier,
1091 kind,
1092 details,
1093 } => {
1094 assert_eq!(identifier, "@org/project");
1095 assert_eq!(*kind, RegistryErrorKind::NotFound);
1096 assert!(
1097 details.suggestion.is_some(),
1098 "NotFound errors should include a suggestion"
1099 );
1100 }
1101 _ => unreachable!(),
1102 }
1103
1104 let error_message = errs
1105 .iter()
1106 .map(|e| e.to_string())
1107 .collect::<Vec<_>>()
1108 .join(" ");
1109 assert!(
1110 error_message.contains("@org/project"),
1111 "Error should mention the identifier: {}",
1112 error_message
1113 );
1114 }
1115
1116 #[tokio::test(flavor = "current_thread")]
1117 async fn resolve_returns_all_registry_errors_when_multiple_repositorys_fail() {
1118 let local_source = r#"spec main_spec
1119uses @org/example helper
1120uses @iso/countries alpha2
1121data country: alpha2.code"#;
1122 let local_specs = crate::parse(
1123 local_source,
1124 crate::parsing::source::SourceType::Volatile,
1125 &ResourceLimits::default(),
1126 )
1127 .unwrap()
1128 .into_flattened_specs();
1129 let mut store = context_with_embedded_stdlib();
1130 let local_repository = store.workspace();
1131 for spec in local_specs {
1132 store
1133 .insert_spec(Arc::clone(&local_repository), spec)
1134 .unwrap();
1135 }
1136 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
1137 sources.insert(
1138 crate::parsing::source::SourceType::Volatile,
1139 local_source.to_string(),
1140 );
1141
1142 let registry = TestRegistry::new(); let result = resolve_registry_references(
1145 &mut store,
1146 &mut sources,
1147 ®istry,
1148 &ResourceLimits::default(),
1149 )
1150 .await;
1151
1152 assert!(result.is_err(), "Should fail when Registry cannot resolve");
1153 let errors = result.unwrap_err();
1154 let identifiers: Vec<&str> = errors
1155 .iter()
1156 .filter_map(|e| {
1157 if let Error::Registry { identifier, .. } = e {
1158 Some(identifier.as_str())
1159 } else {
1160 None
1161 }
1162 })
1163 .collect();
1164 assert!(
1165 identifiers.contains(&"@org/example"),
1166 "Should include repository error: {:?}",
1167 identifiers
1168 );
1169 assert!(
1170 identifiers.contains(&"@iso/countries"),
1171 "Should include data import repository error: {:?}",
1172 identifiers
1173 );
1174 }
1175
1176 #[tokio::test(flavor = "current_thread")]
1177 async fn resolve_does_not_request_same_repository_twice() {
1178 let local_source = r#"spec spec_one
1179uses a: @org/shared shared
1180
1181spec spec_two
1182uses b: @org/shared shared"#;
1183 let local_specs = crate::parse(
1184 local_source,
1185 crate::parsing::source::SourceType::Volatile,
1186 &ResourceLimits::default(),
1187 )
1188 .unwrap()
1189 .into_flattened_specs();
1190 let mut store = context_with_embedded_stdlib();
1191 let local_repository = store.workspace();
1192 for spec in local_specs {
1193 store
1194 .insert_spec(Arc::clone(&local_repository), spec)
1195 .unwrap();
1196 }
1197 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
1198 sources.insert(
1199 crate::parsing::source::SourceType::Volatile,
1200 local_source.to_string(),
1201 );
1202
1203 let mut registry = TestRegistry::new();
1204 registry.add_spec_bundle(
1205 "@org/shared",
1206 r#"repo @org/shared
1207spec shared
1208data value: 1"#,
1209 );
1210
1211 resolve_registry_references(
1212 &mut store,
1213 &mut sources,
1214 ®istry,
1215 &ResourceLimits::default(),
1216 )
1217 .await
1218 .unwrap();
1219
1220 assert_eq!(store.iter().count(), 4);
1221 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1222 assert!(names.iter().any(|n| n == "shared"));
1223 assert!(names.iter().any(|n| n == "units"));
1224 }
1225
1226 #[tokio::test(flavor = "current_thread")]
1227 async fn resolve_handles_data_import_from_registry() {
1228 let local_source = r#"spec main_spec
1229uses @iso/countries alpha2
1230data country: alpha2.code
1231data home: country"#;
1232 let local_specs = crate::parse(
1233 local_source,
1234 crate::parsing::source::SourceType::Volatile,
1235 &ResourceLimits::default(),
1236 )
1237 .unwrap()
1238 .into_flattened_specs();
1239 let mut store = context_with_embedded_stdlib();
1240 let local_repository = store.workspace();
1241 for spec in local_specs {
1242 store
1243 .insert_spec(Arc::clone(&local_repository), spec)
1244 .unwrap();
1245 }
1246 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
1247 sources.insert(
1248 crate::parsing::source::SourceType::Volatile,
1249 local_source.to_string(),
1250 );
1251
1252 let mut registry = TestRegistry::new();
1253 registry.add_spec_bundle(
1254 "@iso/countries",
1255 r#"repo @iso/countries
1256spec alpha2
1257data code: text
1258 -> option "NL""#,
1259 );
1260
1261 resolve_registry_references(
1262 &mut store,
1263 &mut sources,
1264 ®istry,
1265 &ResourceLimits::default(),
1266 )
1267 .await
1268 .unwrap();
1269
1270 assert_eq!(store.iter().count(), 3);
1271 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1272 assert!(names.iter().any(|n| n == "main_spec"));
1273 assert!(names.iter().any(|n| n == "alpha2"));
1274 assert!(names.iter().any(|n| n == "units"));
1275 }
1276
1277 #[cfg(feature = "registry")]
1282 mod lemmabase_tests {
1283 fn lemma_base_with_fetcher(fetcher: Box<dyn HttpFetcher>) -> LemmaBase {
1284 LemmaBase { fetcher }
1285 }
1286
1287 use super::super::*;
1288 use crate::literals::DateGranularity;
1289 use std::sync::{Arc, Mutex};
1290
1291 type HttpFetchHandler = Box<dyn Fn(&str) -> Result<String, HttpFetchError> + Send + Sync>;
1296
1297 struct MockHttpFetcher {
1298 handler: HttpFetchHandler,
1299 }
1300
1301 impl MockHttpFetcher {
1302 fn with_handler(
1304 handler: impl Fn(&str) -> Result<String, HttpFetchError> + Send + Sync + 'static,
1305 ) -> Self {
1306 Self {
1307 handler: Box::new(handler),
1308 }
1309 }
1310
1311 fn always_returning(body: &str) -> Self {
1313 let body = body.to_string();
1314 Self::with_handler(move |_| Ok(body.clone()))
1315 }
1316
1317 fn always_failing_with_status(code: u16) -> Self {
1319 Self::with_handler(move |_| {
1320 Err(HttpFetchError {
1321 status_code: Some(code),
1322 message: format!("HTTP {}", code),
1323 })
1324 })
1325 }
1326
1327 fn always_failing_with_network_error(msg: &str) -> Self {
1329 let msg = msg.to_string();
1330 Self::with_handler(move |_| {
1331 Err(HttpFetchError {
1332 status_code: None,
1333 message: msg.clone(),
1334 })
1335 })
1336 }
1337 }
1338
1339 #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
1340 #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
1341 impl HttpFetcher for MockHttpFetcher {
1342 async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
1343 (self.handler)(url)
1344 }
1345 }
1346
1347 #[test]
1352 fn source_url_without_effective() {
1353 let registry = LemmaBase::new();
1354 let url = registry.source_url("@user/workspace/somespec", None);
1355 assert_eq!(
1356 url,
1357 format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1358 );
1359 }
1360
1361 #[test]
1362 fn source_url_with_effective() {
1363 let registry = LemmaBase::new();
1364 let effective = DateTimeValue {
1365 year: 2026,
1366 month: 1,
1367 day: 15,
1368 hour: 0,
1369 minute: 0,
1370 second: 0,
1371 microsecond: 0,
1372 timezone: None,
1373
1374 granularity: DateGranularity::Full,
1375 };
1376 let url = registry.source_url("@user/workspace/somespec", Some(&effective));
1377 assert_eq!(
1378 url,
1379 format!(
1380 "{}/@user/workspace/somespec.lemma?effective=2026-01-15",
1381 LemmaBase::BASE_URL
1382 )
1383 );
1384 }
1385
1386 #[test]
1387 fn source_url_for_deeply_nested_identifier() {
1388 let registry = LemmaBase::new();
1389 let url = registry.source_url("@org/team/project/subdir/spec", None);
1390 assert_eq!(
1391 url,
1392 format!(
1393 "{}/@org/team/project/subdir/spec.lemma",
1394 LemmaBase::BASE_URL
1395 )
1396 );
1397 }
1398
1399 #[test]
1400 fn navigation_url_without_effective() {
1401 let registry = LemmaBase::new();
1402 let url = registry.navigation_url("@user/workspace/somespec", None);
1403 assert_eq!(
1404 url,
1405 format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL)
1406 );
1407 }
1408
1409 #[test]
1410 fn navigation_url_with_effective() {
1411 let registry = LemmaBase::new();
1412 let effective = DateTimeValue {
1413 year: 2026,
1414 month: 1,
1415 day: 15,
1416 hour: 0,
1417 minute: 0,
1418 second: 0,
1419 microsecond: 0,
1420 timezone: None,
1421
1422 granularity: DateGranularity::Full,
1423 };
1424 let url = registry.navigation_url("@user/workspace/somespec", Some(&effective));
1425 assert_eq!(
1426 url,
1427 format!(
1428 "{}/@user/workspace/somespec?effective=2026-01-15",
1429 LemmaBase::BASE_URL
1430 )
1431 );
1432 }
1433
1434 #[test]
1435 fn url_for_id_returns_navigation_url() {
1436 let registry = LemmaBase::new();
1437 let url = registry.url_for_id("@user/workspace/somespec", None);
1438 assert_eq!(
1439 url,
1440 Some(format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL))
1441 );
1442 }
1443
1444 #[test]
1445 fn url_for_id_with_effective() {
1446 let registry = LemmaBase::new();
1447 let effective = DateTimeValue {
1448 year: 2026,
1449 month: 1,
1450 day: 1,
1451 hour: 0,
1452 minute: 0,
1453 second: 0,
1454 microsecond: 0,
1455 timezone: None,
1456
1457 granularity: DateGranularity::Full,
1458 };
1459 let url = registry.url_for_id("@owner/repo/spec", Some(&effective));
1460 assert_eq!(
1461 url,
1462 Some(format!(
1463 "{}/@owner/repo/spec?effective=2026-01-01",
1464 LemmaBase::BASE_URL
1465 ))
1466 );
1467 }
1468
1469 #[test]
1470 fn url_for_id_returns_navigation_url_for_nested_path() {
1471 let registry = LemmaBase::new();
1472 let url = registry.url_for_id("@iso/countries/alpha2", None);
1473 assert_eq!(
1474 url,
1475 Some(format!("{}/@iso/countries/alpha2", LemmaBase::BASE_URL))
1476 );
1477 }
1478
1479 #[tokio::test(flavor = "current_thread")]
1484 async fn test_mode_serves_bundled_fixtures() {
1485 let registry = LemmaBase::test();
1486 let iso = registry.get("@iso/countries").await.unwrap();
1487 assert!(iso.source.contains("spec alpha2"));
1488 }
1489
1490 #[tokio::test(flavor = "current_thread")]
1491 async fn fetch_source_returns_bundle_on_success() {
1492 let registry = lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_returning(
1493 "spec org/my_spec\ndata x: 1",
1494 )));
1495
1496 let bundle = registry.fetch_source("@org/my_spec").await.unwrap();
1497
1498 assert_eq!(bundle.source, "spec org/my_spec\ndata x: 1");
1499 assert_eq!(bundle.repository, "@org/my_spec");
1500 }
1501
1502 #[tokio::test(flavor = "current_thread")]
1503 async fn fetch_source_passes_correct_url_to_fetcher() {
1504 let captured_url = Arc::new(Mutex::new(String::new()));
1505 let captured = captured_url.clone();
1506 let mock = MockHttpFetcher::with_handler(move |url| {
1507 *captured.lock().unwrap() = url.to_string();
1508 Ok("spec test/spec\ndata x: 1".to_string())
1509 });
1510 let registry = lemma_base_with_fetcher(Box::new(mock));
1511
1512 let _ = registry.fetch_source("@user/workspace/somespec").await;
1513
1514 assert_eq!(
1515 *captured_url.lock().unwrap(),
1516 format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1517 );
1518 }
1519
1520 #[tokio::test(flavor = "current_thread")]
1521 async fn fetch_source_maps_http_404_to_not_found() {
1522 let registry =
1523 lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(404)));
1524
1525 let err = registry.fetch_source("@org/missing").await.unwrap_err();
1526
1527 assert_eq!(err.kind, RegistryErrorKind::NotFound);
1528 assert!(
1529 err.message.contains("HTTP 404"),
1530 "Expected 'HTTP 404' in: {}",
1531 err.message
1532 );
1533 assert!(
1534 err.message.contains("@org/missing"),
1535 "Expected '@org/missing' in: {}",
1536 err.message
1537 );
1538 }
1539
1540 #[tokio::test(flavor = "current_thread")]
1541 async fn fetch_source_maps_http_500_to_server_error() {
1542 let registry =
1543 lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(500)));
1544
1545 let err = registry.fetch_source("@org/broken").await.unwrap_err();
1546
1547 assert_eq!(err.kind, RegistryErrorKind::ServerError);
1548 assert!(
1549 err.message.contains("HTTP 500"),
1550 "Expected 'HTTP 500' in: {}",
1551 err.message
1552 );
1553 }
1554
1555 #[tokio::test(flavor = "current_thread")]
1556 async fn fetch_source_maps_http_401_to_unauthorized() {
1557 let registry =
1558 lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(401)));
1559
1560 let err = registry.fetch_source("@org/secret").await.unwrap_err();
1561
1562 assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1563 assert!(err.message.contains("HTTP 401"));
1564 }
1565
1566 #[tokio::test(flavor = "current_thread")]
1567 async fn fetch_source_maps_http_403_to_unauthorized() {
1568 let registry =
1569 lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(403)));
1570
1571 let err = registry.fetch_source("@org/private").await.unwrap_err();
1572
1573 assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1574 assert!(
1575 err.message.contains("HTTP 403"),
1576 "Expected 'HTTP 403' in: {}",
1577 err.message
1578 );
1579 }
1580
1581 #[tokio::test(flavor = "current_thread")]
1582 async fn fetch_source_maps_unexpected_status_to_other() {
1583 let registry =
1584 lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(418)));
1585
1586 let err = registry.fetch_source("@org/teapot").await.unwrap_err();
1587
1588 assert_eq!(err.kind, RegistryErrorKind::Other);
1589 assert!(err.message.contains("HTTP 418"));
1590 }
1591
1592 #[tokio::test(flavor = "current_thread")]
1593 async fn fetch_source_maps_network_error_to_network_error_kind() {
1594 let registry = lemma_base_with_fetcher(Box::new(
1595 MockHttpFetcher::always_failing_with_network_error("connection refused"),
1596 ));
1597
1598 let err = registry.fetch_source("@org/unreachable").await.unwrap_err();
1599
1600 assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1601 assert!(
1602 err.message.contains("connection refused"),
1603 "Expected 'connection refused' in: {}",
1604 err.message
1605 );
1606 assert!(
1607 err.message.contains("@org/unreachable"),
1608 "Expected '@org/unreachable' in: {}",
1609 err.message
1610 );
1611 }
1612
1613 #[tokio::test(flavor = "current_thread")]
1614 async fn fetch_source_maps_dns_error_to_network_error_kind() {
1615 let registry = lemma_base_with_fetcher(Box::new(
1616 MockHttpFetcher::always_failing_with_network_error(
1617 "dns error: failed to lookup address",
1618 ),
1619 ));
1620
1621 let err = registry.fetch_source("@org/spec").await.unwrap_err();
1622
1623 assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1624 assert!(
1625 err.message.contains("dns error"),
1626 "Expected 'dns error' in: {}",
1627 err.message
1628 );
1629 assert!(
1630 err.message.contains("Failed to reach LemmaBase"),
1631 "Expected 'Failed to reach LemmaBase' in: {}",
1632 err.message
1633 );
1634 }
1635
1636 #[tokio::test(flavor = "current_thread")]
1641 async fn get_delegates_to_fetch_source() {
1642 let registry = lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_returning(
1643 "spec org/resolved\ndata a: 1",
1644 )));
1645
1646 let bundle = registry.get("@org/resolved").await.unwrap();
1647
1648 assert_eq!(bundle.source, "spec org/resolved\ndata a: 1");
1649 assert_eq!(bundle.repository, "@org/resolved");
1650 }
1651
1652 #[tokio::test(flavor = "current_thread")]
1653 async fn fetch_source_returns_empty_body_as_valid_bundle() {
1654 let registry = lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_returning("")));
1655
1656 let bundle = registry.fetch_source("@org/empty").await.unwrap();
1657
1658 assert_eq!(bundle.source, "");
1659 assert_eq!(bundle.repository, "@org/empty");
1660 }
1661 }
1662}