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