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