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