1use crate::parsing::ast::{DateTimeValue, LemmaRepository};
14use std::fmt;
15use std::sync::Arc;
16
17#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
18use {
19 crate::engine::Context,
20 crate::error::Error,
21 crate::limits::ResourceLimits,
22 crate::parsing::ast::{DataValue, RepositoryQualifier, SpecRef},
23 crate::parsing::source::Source,
24 std::collections::{HashMap, HashSet},
25};
26
27#[derive(Debug, Clone)]
35pub struct RegistryBundle {
36 pub lemma_source: String,
38
39 pub source_type: crate::parsing::source::SourceType,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum RegistryErrorKind {
50 NotFound,
52 Unauthorized,
54 NetworkError,
56 ServerError,
58 Other,
60}
61
62impl fmt::Display for RegistryErrorKind {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 match self {
65 Self::NotFound => write!(f, "not found"),
66 Self::Unauthorized => write!(f, "unauthorized"),
67 Self::NetworkError => write!(f, "network error"),
68 Self::ServerError => write!(f, "server error"),
69 Self::Other => write!(f, "error"),
70 }
71 }
72}
73
74#[derive(Debug, Clone)]
76pub struct RegistryError {
77 pub message: String,
78 pub kind: RegistryErrorKind,
79}
80
81impl fmt::Display for RegistryError {
82 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
83 write!(formatter, "{}", self.message)
84 }
85}
86
87impl std::error::Error for RegistryError {}
88
89#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
101#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
102pub trait Registry: Send + Sync {
103 async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError>;
108
109 fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String>;
115}
116
117#[cfg(feature = "registry")]
128struct HttpFetchError {
129 status_code: Option<u16>,
131 message: String,
133}
134
135#[cfg(feature = "registry")]
139#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
140#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
141trait HttpFetcher: Send + Sync {
142 async fn get(&self, url: &str) -> Result<String, HttpFetchError>;
143}
144
145#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
147struct ReqwestHttpFetcher;
148
149#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
150#[async_trait::async_trait]
151impl HttpFetcher for ReqwestHttpFetcher {
152 async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
153 let response = reqwest::get(url).await.map_err(|e| HttpFetchError {
154 status_code: e.status().map(|s| s.as_u16()),
155 message: e.to_string(),
156 })?;
157 let status = response.status();
158 let body = response.text().await.map_err(|e| HttpFetchError {
159 status_code: None,
160 message: e.to_string(),
161 })?;
162 if !status.is_success() {
163 return Err(HttpFetchError {
164 status_code: Some(status.as_u16()),
165 message: format!("HTTP {}", status),
166 });
167 }
168 Ok(body)
169 }
170}
171
172#[cfg(all(feature = "registry", target_arch = "wasm32"))]
174struct WasmHttpFetcher;
175
176#[cfg(all(feature = "registry", target_arch = "wasm32"))]
177#[async_trait::async_trait(?Send)]
178impl HttpFetcher for WasmHttpFetcher {
179 async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
180 let response = gloo_net::http::Request::get(url)
181 .send()
182 .await
183 .map_err(|e| HttpFetchError {
184 status_code: None,
185 message: e.to_string(),
186 })?;
187 let status = response.status();
188 let ok = response.ok();
189 if !ok {
190 return Err(HttpFetchError {
191 status_code: Some(status),
192 message: format!("HTTP {}", status),
193 });
194 }
195 let text = response.text().await.map_err(|e| HttpFetchError {
196 status_code: None,
197 message: e.to_string(),
198 })?;
199 Ok(text)
200 }
201}
202
203#[cfg(feature = "registry")]
219pub struct LemmaBase {
220 fetcher: Box<dyn HttpFetcher>,
221}
222
223#[cfg(feature = "registry")]
224impl LemmaBase {
225 #[cfg(debug_assertions)]
230 pub const BASE_URL: &'static str = "http://localhost:4222";
231 #[cfg(not(debug_assertions))]
232 pub const BASE_URL: &'static str = "https://lemmabase.com";
233
234 pub fn new() -> Self {
236 Self {
237 #[cfg(not(target_arch = "wasm32"))]
238 fetcher: Box::new(ReqwestHttpFetcher),
239 #[cfg(target_arch = "wasm32")]
240 fetcher: Box::new(WasmHttpFetcher),
241 }
242 }
243
244 #[cfg(test)]
246 fn with_fetcher(fetcher: Box<dyn HttpFetcher>) -> Self {
247 Self { fetcher }
248 }
249
250 fn source_url(&self, name: &str, effective: Option<&DateTimeValue>) -> String {
252 let base = format!("{}/{}.lemma", Self::BASE_URL, name);
253 match effective {
254 None => base,
255 Some(d) => format!("{}?effective={}", base, d),
256 }
257 }
258
259 fn navigation_url(&self, name: &str, effective: Option<&DateTimeValue>) -> String {
261 let base = format!("{}/{}", Self::BASE_URL, name);
262 match effective {
263 None => base,
264 Some(d) => format!("{}?effective={}", base, d),
265 }
266 }
267
268 fn display_id(name: &str, effective: Option<&DateTimeValue>) -> String {
269 match effective {
270 None => name.to_string(),
271 Some(d) => format!("{name} {d}"),
272 }
273 }
274
275 async fn fetch_source(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
277 let url = self.source_url(name, None);
278 let display = Self::display_id(name, None);
279
280 let lemma_source = self.fetcher.get(&url).await.map_err(|error| {
281 if let Some(code) = error.status_code {
282 let kind = match code {
283 404 => RegistryErrorKind::NotFound,
284 401 | 403 => RegistryErrorKind::Unauthorized,
285 500..=599 => RegistryErrorKind::ServerError,
286 _ => RegistryErrorKind::Other,
287 };
288 RegistryError {
289 message: format!("LemmaBase returned HTTP {} {} for '{}'", code, url, display),
290 kind,
291 }
292 } else {
293 RegistryError {
294 message: format!(
295 "Failed to reach LemmaBase for '{}': {}",
296 display, error.message
297 ),
298 kind: RegistryErrorKind::NetworkError,
299 }
300 }
301 })?;
302
303 Ok(RegistryBundle {
304 lemma_source,
305 source_type: crate::parsing::source::SourceType::Registry(Arc::new(
306 LemmaRepository::new(Some(name.to_string())),
307 )),
308 })
309 }
310}
311
312#[cfg(feature = "registry")]
313impl Default for LemmaBase {
314 fn default() -> Self {
315 Self::new()
316 }
317}
318
319#[cfg(feature = "registry")]
320#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
321#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
322impl Registry for LemmaBase {
323 async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
324 self.fetch_source(name).await
325 }
326
327 fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
328 Some(self.navigation_url(name, effective))
329 }
330}
331
332#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
352pub async fn resolve_registry_references(
353 ctx: &mut Context,
354 sources: &mut HashMap<crate::parsing::source::SourceType, String>,
355 registry: &dyn Registry,
356 limits: &ResourceLimits,
357) -> Result<(), Vec<Error>> {
358 let mut already_requested: HashSet<String> = HashSet::new();
359
360 loop {
361 let unresolved = find_missing_repositories(ctx, &already_requested);
362
363 if unresolved.is_empty() {
364 break;
365 }
366
367 let mut round_errors: Vec<Error> = Vec::new();
368 for reference in &unresolved {
369 if already_requested.contains(&reference.repository.name) {
370 continue;
371 }
372 already_requested.insert(reference.repository.name.clone());
373
374 let bundle_result = registry.get(&reference.repository.name).await;
375
376 let bundle = match bundle_result {
377 Ok(b) => b,
378 Err(registry_error) => {
379 let suggestion = match ®istry_error.kind {
380 RegistryErrorKind::NotFound => Some(
381 "Check that the repository qualifier is spelled correctly and that the repository exists on the registry.".to_string(),
382 ),
383 RegistryErrorKind::Unauthorized => Some(
384 "Check your authentication credentials or permissions for this registry.".to_string(),
385 ),
386 RegistryErrorKind::NetworkError => Some(
387 "Check your network connection. To compile without registry access, disable the 'registry' feature.".to_string(),
388 ),
389 RegistryErrorKind::ServerError => Some(
390 "The registry server returned an internal error. Try again later.".to_string(),
391 ),
392 RegistryErrorKind::Other => None,
393 };
394 let spec_context = ctx
395 .iter()
396 .find(|s| s.source_type == Some(reference.source.source_type.clone()));
397 round_errors.push(Error::registry(
398 registry_error.message,
399 reference.source.clone(),
400 reference.repository.name.clone(),
401 registry_error.kind,
402 suggestion,
403 spec_context,
404 None,
405 ));
406 continue;
407 }
408 };
409
410 sources.insert(bundle.source_type.clone(), bundle.lemma_source.clone());
411
412 let parsed = match crate::parsing::parse(
413 &bundle.lemma_source,
414 bundle.source_type.clone(),
415 limits,
416 ) {
417 Ok(result) => result,
418 Err(e) => {
419 round_errors.push(e);
420 return Err(round_errors);
421 }
422 };
423
424 for (parsed_repo, specs) in parsed.repositories {
425 let repo_name = parsed_repo
426 .name
427 .clone()
428 .unwrap_or_else(|| reference.repository.name.clone());
429 let header = LemmaRepository::new(Some(repo_name))
430 .with_dependency(reference.repository.name.clone())
431 .with_start_line(parsed_repo.start_line)
432 .with_source_type(bundle.source_type.clone());
433 let repository_arc = Arc::new(header);
434
435 for spec in specs {
436 if let Err(e) = ctx.insert_spec(Arc::clone(&repository_arc), Arc::new(spec)) {
437 round_errors.push(e);
438 }
439 }
440 }
441 }
442
443 if !round_errors.is_empty() {
444 return Err(round_errors);
445 }
446 }
447
448 Ok(())
449}
450
451#[derive(Debug, Clone)]
453#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
454struct RegistryReference {
455 repository: RepositoryQualifier,
456 source: Source,
457}
458
459#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
460fn collect_repository_qualifiers_from_spec_ref(
461 spec_ref: &SpecRef,
462 source: &Source,
463 ctx: &Context,
464 already_requested: &HashSet<String>,
465 seen_in_this_round: &mut HashSet<String>,
466 out: &mut Vec<RegistryReference>,
467) {
468 let Some(qualifier) = spec_ref.repository.as_ref() else {
469 return;
470 };
471 if ctx.find_repository(&qualifier.name).is_some() {
472 return;
473 }
474 if already_requested.contains(&qualifier.name) {
475 return;
476 }
477 if !seen_in_this_round.insert(qualifier.name.clone()) {
478 return;
479 }
480 out.push(RegistryReference {
481 repository: qualifier.clone(),
482 source: source.clone(),
483 });
484}
485
486#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
488fn find_missing_repositories(
489 ctx: &Context,
490 already_requested: &HashSet<String>,
491) -> Vec<RegistryReference> {
492 let mut unresolved: Vec<RegistryReference> = Vec::new();
493 let mut seen_in_this_round: HashSet<String> = HashSet::new();
494
495 for spec in ctx.iter() {
496 let spec = spec.as_ref();
497
498 for data in &spec.data {
499 if let DataValue::Import(spec_ref) = &data.value {
501 collect_repository_qualifiers_from_spec_ref(
502 spec_ref,
503 &data.source_location,
504 ctx,
505 already_requested,
506 &mut seen_in_this_round,
507 &mut unresolved,
508 );
509 }
510 }
511 }
512
513 unresolved
514}
515
516#[cfg(test)]
521mod tests {
522 use super::*;
523 use crate::engine::Engine;
524 use crate::literals::DateGranularity;
525
526 struct TestRegistry {
528 bundles: HashMap<String, RegistryBundle>,
529 }
530
531 impl TestRegistry {
532 fn new() -> Self {
533 Self {
534 bundles: HashMap::new(),
535 }
536 }
537
538 fn add_spec_bundle(&mut self, identifier: &str, lemma_source: &str) {
540 self.bundles.insert(
541 identifier.to_string(),
542 RegistryBundle {
543 lemma_source: lemma_source.to_string(),
544 source_type: crate::parsing::source::SourceType::Registry(Arc::new(
545 LemmaRepository::new(Some(identifier.to_string())),
546 )),
547 },
548 );
549 }
550 }
551
552 #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
553 #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
554 impl Registry for TestRegistry {
555 async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
556 self.bundles
557 .get(name)
558 .cloned()
559 .ok_or_else(|| RegistryError {
560 message: format!("'{}' not found in test registry", name),
561 kind: RegistryErrorKind::NotFound,
562 })
563 }
564
565 fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
566 if self.bundles.contains_key(name) {
567 Some(match effective {
568 None => format!("https://test.registry/{}", name),
569 Some(d) => format!("https://test.registry/{}?effective={}", name, d),
570 })
571 } else {
572 None
573 }
574 }
575 }
576
577 #[tokio::test]
578 async fn resolve_with_no_registry_references_returns_local_specs_unchanged() {
579 let source = r#"spec example
580data price: 100"#;
581 let local_specs = crate::parse(
582 source,
583 crate::parsing::source::SourceType::Volatile,
584 &ResourceLimits::default(),
585 )
586 .unwrap()
587 .into_flattened_specs();
588 let mut engine = Engine::new();
589 let store = engine.specs_mut();
590 let local_repository = store.workspace();
591 for spec in &local_specs {
592 store
593 .insert_spec(Arc::clone(&local_repository), Arc::new(spec.clone()))
594 .unwrap();
595 }
596 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
597 sources.insert(
598 crate::parsing::source::SourceType::Volatile,
599 source.to_string(),
600 );
601
602 let registry = TestRegistry::new();
603 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
604 .await
605 .unwrap();
606
607 assert_eq!(store.len(), 2, "embedded spec units plus workspace example");
608 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
609 assert!(names.iter().any(|n| n == "example"));
610 assert!(names.iter().any(|n| n == "units"));
611 }
612
613 #[tokio::test]
614 async fn resolve_fetches_single_spec_from_registry() {
615 let local_source = r#"spec main_spec
616uses external: @org/project helper
617rule value: external.quantity"#;
618 let local_specs = crate::parse(
619 local_source,
620 crate::parsing::source::SourceType::Volatile,
621 &ResourceLimits::default(),
622 )
623 .unwrap()
624 .into_flattened_specs();
625 let mut engine = Engine::new();
626 let store = engine.specs_mut();
627 let local_repository = store.workspace();
628 for spec in local_specs {
629 store
630 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
631 .unwrap();
632 }
633 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
634 sources.insert(
635 crate::parsing::source::SourceType::Volatile,
636 local_source.to_string(),
637 );
638
639 let mut registry = TestRegistry::new();
640 registry.add_spec_bundle(
641 "@org/project",
642 r#"repo @org/project
643spec helper
644data quantity: 42"#,
645 );
646
647 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
648 .await
649 .unwrap();
650
651 assert_eq!(store.len(), 3);
652 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
653 assert!(names.iter().any(|n| n == "main_spec"));
654 assert!(names.iter().any(|n| n == "helper"));
655 assert!(names.iter().any(|n| n == "units"));
656 }
657
658 #[tokio::test]
659 async fn resolve_registry_bundle_without_repo_decl_uses_reference_repository_name() {
660 let local_source = r#"spec main_spec
661uses external: @org/project helper
662rule value: external.quantity"#;
663 let local_specs = crate::parse(
664 local_source,
665 crate::parsing::source::SourceType::Volatile,
666 &ResourceLimits::default(),
667 )
668 .unwrap()
669 .into_flattened_specs();
670 let mut engine = Engine::new();
671 let store = engine.specs_mut();
672 let local_repository = store.workspace();
673 for spec in local_specs {
674 store
675 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
676 .unwrap();
677 }
678 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
679 sources.insert(
680 crate::parsing::source::SourceType::Volatile,
681 local_source.to_string(),
682 );
683
684 let mut registry = TestRegistry::new();
685 registry.add_spec_bundle(
686 "@org/project",
687 r#"spec helper
688data quantity: 42"#,
689 );
690
691 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
692 .await
693 .unwrap();
694
695 let ext_repo = store
696 .find_repository("@org/project")
697 .expect("registry bundle must land under fetched @ id");
698 let spec_names: Vec<String> = store
699 .repositories()
700 .get(&ext_repo)
701 .expect("spec sets for @org/project")
702 .keys()
703 .cloned()
704 .collect();
705 assert!(
706 spec_names.iter().any(|n| n == "helper"),
707 "helper spec should live under @org/project, got {:?}",
708 spec_names
709 );
710 }
711
712 #[tokio::test]
713 async fn get_returns_all_zones_and_url_for_id_supports_effective() {
714 let effective = DateTimeValue {
715 year: 2026,
716 month: 1,
717 day: 15,
718 hour: 0,
719 minute: 0,
720 second: 0,
721 microsecond: 0,
722 timezone: None,
723
724 granularity: DateGranularity::Full,
725 };
726 let mut registry = TestRegistry::new();
727 registry.add_spec_bundle(
728 "@org/spec",
729 "spec org/spec 2025-01-01\ndata x: 1\n\nspec org/spec 2026-01-15\ndata x: 2",
730 );
731
732 let bundle = registry.get("@org/spec").await.unwrap();
733 assert!(bundle.lemma_source.contains("data x: 1"));
734 assert!(bundle.lemma_source.contains("data x: 2"));
735
736 assert_eq!(
737 registry.url_for_id("@org/spec", None),
738 Some("https://test.registry/@org/spec".to_string())
739 );
740 assert_eq!(
741 registry.url_for_id("@org/spec", Some(&effective)),
742 Some("https://test.registry/@org/spec?effective=2026-01-15".to_string())
743 );
744 }
745
746 #[tokio::test]
747 async fn resolve_fetches_transitive_dependencies() {
748 let local_source = r#"spec main_spec
749uses a: @org/project spec_a"#;
750 let local_specs = crate::parse(
751 local_source,
752 crate::parsing::source::SourceType::Volatile,
753 &ResourceLimits::default(),
754 )
755 .unwrap()
756 .into_flattened_specs();
757 let mut engine = Engine::new();
758 let store = engine.specs_mut();
759 let local_repository = store.workspace();
760 for spec in local_specs {
761 store
762 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
763 .unwrap();
764 }
765 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
766 sources.insert(
767 crate::parsing::source::SourceType::Volatile,
768 local_source.to_string(),
769 );
770
771 let mut registry = TestRegistry::new();
772 registry.add_spec_bundle(
773 "@org/project",
774 r#"repo @org/project
775spec spec_a
776uses b: @org/sub spec_b"#,
777 );
778 registry.add_spec_bundle(
779 "@org/sub",
780 r#"repo @org/sub
781spec spec_b
782data value: 99"#,
783 );
784
785 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
786 .await
787 .unwrap();
788
789 assert_eq!(store.len(), 4);
790 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
791 assert!(names.iter().any(|n| n == "main_spec"));
792 assert!(names.iter().any(|n| n == "spec_a"));
793 assert!(names.iter().any(|n| n == "spec_b"));
794 assert!(names.iter().any(|n| n == "units"));
795 }
796
797 #[tokio::test]
798 async fn resolve_handles_bundle_with_multiple_specs() {
799 let local_source = r#"spec main_spec
800uses a: @org/project spec_a"#;
801 let local_specs = crate::parse(
802 local_source,
803 crate::parsing::source::SourceType::Volatile,
804 &ResourceLimits::default(),
805 )
806 .unwrap()
807 .into_flattened_specs();
808 let mut engine = Engine::new();
809 let store = engine.specs_mut();
810 let local_repository = store.workspace();
811 for spec in local_specs {
812 store
813 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
814 .unwrap();
815 }
816 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
817 sources.insert(
818 crate::parsing::source::SourceType::Volatile,
819 local_source.to_string(),
820 );
821
822 let mut registry = TestRegistry::new();
823 registry.add_spec_bundle(
824 "@org/project",
825 r#"repo @org/project
826spec spec_a
827uses b: spec_b
828
829spec spec_b
830data value: 99"#,
831 );
832
833 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
834 .await
835 .unwrap();
836
837 assert_eq!(store.len(), 4);
838 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
839 assert!(names.iter().any(|n| n == "main_spec"));
840 assert!(names.iter().any(|n| n == "spec_a"));
841 assert!(names.iter().any(|n| n == "spec_b"));
842 assert!(names.iter().any(|n| n == "units"));
843 }
844
845 #[tokio::test]
846 async fn resolve_returns_registry_error_when_registry_fails() {
847 let local_source = r#"spec main_spec
848uses external: @org/project missing"#;
849 let local_specs = crate::parse(
850 local_source,
851 crate::parsing::source::SourceType::Volatile,
852 &ResourceLimits::default(),
853 )
854 .unwrap()
855 .into_flattened_specs();
856 let mut engine = Engine::new();
857 let store = engine.specs_mut();
858 let local_repository = store.workspace();
859 for spec in local_specs {
860 store
861 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
862 .unwrap();
863 }
864 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
865 sources.insert(
866 crate::parsing::source::SourceType::Volatile,
867 local_source.to_string(),
868 );
869
870 let registry = TestRegistry::new(); let result =
873 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
874 .await;
875
876 assert!(result.is_err(), "Should fail when Registry cannot resolve");
877 let errs = result.unwrap_err();
878 let registry_err = errs
879 .iter()
880 .find(|e| matches!(e, Error::Registry { .. }))
881 .expect("expected at least one Registry error");
882 match registry_err {
883 Error::Registry {
884 identifier,
885 kind,
886 details,
887 } => {
888 assert_eq!(identifier, "@org/project");
889 assert_eq!(*kind, RegistryErrorKind::NotFound);
890 assert!(
891 details.suggestion.is_some(),
892 "NotFound errors should include a suggestion"
893 );
894 }
895 _ => unreachable!(),
896 }
897
898 let error_message = errs
899 .iter()
900 .map(|e| e.to_string())
901 .collect::<Vec<_>>()
902 .join(" ");
903 assert!(
904 error_message.contains("@org/project"),
905 "Error should mention the identifier: {}",
906 error_message
907 );
908 }
909
910 #[tokio::test]
911 async fn resolve_returns_all_registry_errors_when_multiple_repositorys_fail() {
912 let local_source = r#"spec main_spec
913uses @org/example helper
914uses @lemma/std finance
915data money: finance.money"#;
916 let local_specs = crate::parse(
917 local_source,
918 crate::parsing::source::SourceType::Volatile,
919 &ResourceLimits::default(),
920 )
921 .unwrap()
922 .into_flattened_specs();
923 let mut engine = Engine::new();
924 let store = engine.specs_mut();
925 let local_repository = store.workspace();
926 for spec in local_specs {
927 store
928 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
929 .unwrap();
930 }
931 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
932 sources.insert(
933 crate::parsing::source::SourceType::Volatile,
934 local_source.to_string(),
935 );
936
937 let registry = TestRegistry::new(); let result =
940 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
941 .await;
942
943 assert!(result.is_err(), "Should fail when Registry cannot resolve");
944 let errors = result.unwrap_err();
945 let identifiers: Vec<&str> = errors
946 .iter()
947 .filter_map(|e| {
948 if let Error::Registry { identifier, .. } = e {
949 Some(identifier.as_str())
950 } else {
951 None
952 }
953 })
954 .collect();
955 assert!(
956 identifiers.contains(&"@org/example"),
957 "Should include repository error: {:?}",
958 identifiers
959 );
960 assert!(
961 identifiers.contains(&"@lemma/std"),
962 "Should include data import repository error: {:?}",
963 identifiers
964 );
965 }
966
967 #[tokio::test]
968 async fn resolve_does_not_request_same_repository_twice() {
969 let local_source = r#"spec spec_one
970uses a: @org/shared shared
971
972spec spec_two
973uses b: @org/shared shared"#;
974 let local_specs = crate::parse(
975 local_source,
976 crate::parsing::source::SourceType::Volatile,
977 &ResourceLimits::default(),
978 )
979 .unwrap()
980 .into_flattened_specs();
981 let mut engine = Engine::new();
982 let store = engine.specs_mut();
983 let local_repository = store.workspace();
984 for spec in local_specs {
985 store
986 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
987 .unwrap();
988 }
989 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
990 sources.insert(
991 crate::parsing::source::SourceType::Volatile,
992 local_source.to_string(),
993 );
994
995 let mut registry = TestRegistry::new();
996 registry.add_spec_bundle(
997 "@org/shared",
998 r#"repo @org/shared
999spec shared
1000data value: 1"#,
1001 );
1002
1003 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
1004 .await
1005 .unwrap();
1006
1007 assert_eq!(store.len(), 4);
1008 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1009 assert!(names.iter().any(|n| n == "shared"));
1010 assert!(names.iter().any(|n| n == "units"));
1011 }
1012
1013 #[tokio::test]
1014 async fn resolve_handles_data_import_from_registry() {
1015 let local_source = r#"spec main_spec
1016uses @lemma/std finance
1017data money: finance.money
1018data price: money"#;
1019 let local_specs = crate::parse(
1020 local_source,
1021 crate::parsing::source::SourceType::Volatile,
1022 &ResourceLimits::default(),
1023 )
1024 .unwrap()
1025 .into_flattened_specs();
1026 let mut engine = Engine::new();
1027 let store = engine.specs_mut();
1028 let local_repository = store.workspace();
1029 for spec in local_specs {
1030 store
1031 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
1032 .unwrap();
1033 }
1034 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
1035 sources.insert(
1036 crate::parsing::source::SourceType::Volatile,
1037 local_source.to_string(),
1038 );
1039
1040 let mut registry = TestRegistry::new();
1041 registry.add_spec_bundle(
1042 "@lemma/std",
1043 r#"repo @lemma/std
1044spec finance
1045data money: quantity
1046 -> unit eur 1.00
1047 -> unit usd 0.91
1048 -> decimals 2"#,
1049 );
1050
1051 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
1052 .await
1053 .unwrap();
1054
1055 assert_eq!(store.len(), 3);
1056 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1057 assert!(names.iter().any(|n| n == "main_spec"));
1058 assert!(names.iter().any(|n| n == "finance"));
1059 assert!(names.iter().any(|n| n == "units"));
1060 }
1061
1062 #[cfg(feature = "registry")]
1067 mod lemmabase_tests {
1068 use super::super::*;
1069 use crate::literals::DateGranularity;
1070 use std::sync::{Arc, Mutex};
1071
1072 type HttpFetchHandler = Box<dyn Fn(&str) -> Result<String, HttpFetchError> + Send + Sync>;
1077
1078 struct MockHttpFetcher {
1079 handler: HttpFetchHandler,
1080 }
1081
1082 impl MockHttpFetcher {
1083 fn with_handler(
1085 handler: impl Fn(&str) -> Result<String, HttpFetchError> + Send + Sync + 'static,
1086 ) -> Self {
1087 Self {
1088 handler: Box::new(handler),
1089 }
1090 }
1091
1092 fn always_returning(body: &str) -> Self {
1094 let body = body.to_string();
1095 Self::with_handler(move |_| Ok(body.clone()))
1096 }
1097
1098 fn always_failing_with_status(code: u16) -> Self {
1100 Self::with_handler(move |_| {
1101 Err(HttpFetchError {
1102 status_code: Some(code),
1103 message: format!("HTTP {}", code),
1104 })
1105 })
1106 }
1107
1108 fn always_failing_with_network_error(msg: &str) -> Self {
1110 let msg = msg.to_string();
1111 Self::with_handler(move |_| {
1112 Err(HttpFetchError {
1113 status_code: None,
1114 message: msg.clone(),
1115 })
1116 })
1117 }
1118 }
1119
1120 #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
1121 #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
1122 impl HttpFetcher for MockHttpFetcher {
1123 async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
1124 (self.handler)(url)
1125 }
1126 }
1127
1128 #[test]
1133 fn source_url_without_effective() {
1134 let registry = LemmaBase::new();
1135 let url = registry.source_url("@user/workspace/somespec", None);
1136 assert_eq!(
1137 url,
1138 format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1139 );
1140 }
1141
1142 #[test]
1143 fn source_url_with_effective() {
1144 let registry = LemmaBase::new();
1145 let effective = DateTimeValue {
1146 year: 2026,
1147 month: 1,
1148 day: 15,
1149 hour: 0,
1150 minute: 0,
1151 second: 0,
1152 microsecond: 0,
1153 timezone: None,
1154
1155 granularity: DateGranularity::Full,
1156 };
1157 let url = registry.source_url("@user/workspace/somespec", Some(&effective));
1158 assert_eq!(
1159 url,
1160 format!(
1161 "{}/@user/workspace/somespec.lemma?effective=2026-01-15",
1162 LemmaBase::BASE_URL
1163 )
1164 );
1165 }
1166
1167 #[test]
1168 fn source_url_for_deeply_nested_identifier() {
1169 let registry = LemmaBase::new();
1170 let url = registry.source_url("@org/team/project/subdir/spec", None);
1171 assert_eq!(
1172 url,
1173 format!(
1174 "{}/@org/team/project/subdir/spec.lemma",
1175 LemmaBase::BASE_URL
1176 )
1177 );
1178 }
1179
1180 #[test]
1181 fn navigation_url_without_effective() {
1182 let registry = LemmaBase::new();
1183 let url = registry.navigation_url("@user/workspace/somespec", None);
1184 assert_eq!(
1185 url,
1186 format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL)
1187 );
1188 }
1189
1190 #[test]
1191 fn navigation_url_with_effective() {
1192 let registry = LemmaBase::new();
1193 let effective = DateTimeValue {
1194 year: 2026,
1195 month: 1,
1196 day: 15,
1197 hour: 0,
1198 minute: 0,
1199 second: 0,
1200 microsecond: 0,
1201 timezone: None,
1202
1203 granularity: DateGranularity::Full,
1204 };
1205 let url = registry.navigation_url("@user/workspace/somespec", Some(&effective));
1206 assert_eq!(
1207 url,
1208 format!(
1209 "{}/@user/workspace/somespec?effective=2026-01-15",
1210 LemmaBase::BASE_URL
1211 )
1212 );
1213 }
1214
1215 #[test]
1216 fn url_for_id_returns_navigation_url() {
1217 let registry = LemmaBase::new();
1218 let url = registry.url_for_id("@user/workspace/somespec", None);
1219 assert_eq!(
1220 url,
1221 Some(format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL))
1222 );
1223 }
1224
1225 #[test]
1226 fn url_for_id_with_effective() {
1227 let registry = LemmaBase::new();
1228 let effective = DateTimeValue {
1229 year: 2026,
1230 month: 1,
1231 day: 1,
1232 hour: 0,
1233 minute: 0,
1234 second: 0,
1235 microsecond: 0,
1236 timezone: None,
1237
1238 granularity: DateGranularity::Full,
1239 };
1240 let url = registry.url_for_id("@owner/repo/spec", Some(&effective));
1241 assert_eq!(
1242 url,
1243 Some(format!(
1244 "{}/@owner/repo/spec?effective=2026-01-01",
1245 LemmaBase::BASE_URL
1246 ))
1247 );
1248 }
1249
1250 #[test]
1251 fn url_for_id_returns_navigation_url_for_nested_path() {
1252 let registry = LemmaBase::new();
1253 let url = registry.url_for_id("@lemma/std/finance", None);
1254 assert_eq!(
1255 url,
1256 Some(format!("{}/@lemma/std/finance", LemmaBase::BASE_URL))
1257 );
1258 }
1259
1260 #[tokio::test]
1265 async fn fetch_source_returns_bundle_on_success() {
1266 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1267 "spec org/my_spec\ndata x: 1",
1268 )));
1269
1270 let bundle = registry.fetch_source("@org/my_spec").await.unwrap();
1271
1272 assert_eq!(bundle.lemma_source, "spec org/my_spec\ndata x: 1");
1273 assert_eq!(bundle.source_type.to_string(), "@org/my_spec");
1274 }
1275
1276 #[tokio::test]
1277 async fn fetch_source_passes_correct_url_to_fetcher() {
1278 let captured_url = Arc::new(Mutex::new(String::new()));
1279 let captured = captured_url.clone();
1280 let mock = MockHttpFetcher::with_handler(move |url| {
1281 *captured.lock().unwrap() = url.to_string();
1282 Ok("spec test/spec\ndata x: 1".to_string())
1283 });
1284 let registry = LemmaBase::with_fetcher(Box::new(mock));
1285
1286 let _ = registry.fetch_source("@user/workspace/somespec").await;
1287
1288 assert_eq!(
1289 *captured_url.lock().unwrap(),
1290 format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1291 );
1292 }
1293
1294 #[tokio::test]
1295 async fn fetch_source_maps_http_404_to_not_found() {
1296 let registry =
1297 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(404)));
1298
1299 let err = registry.fetch_source("@org/missing").await.unwrap_err();
1300
1301 assert_eq!(err.kind, RegistryErrorKind::NotFound);
1302 assert!(
1303 err.message.contains("HTTP 404"),
1304 "Expected 'HTTP 404' in: {}",
1305 err.message
1306 );
1307 assert!(
1308 err.message.contains("@org/missing"),
1309 "Expected '@org/missing' in: {}",
1310 err.message
1311 );
1312 }
1313
1314 #[tokio::test]
1315 async fn fetch_source_maps_http_500_to_server_error() {
1316 let registry =
1317 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(500)));
1318
1319 let err = registry.fetch_source("@org/broken").await.unwrap_err();
1320
1321 assert_eq!(err.kind, RegistryErrorKind::ServerError);
1322 assert!(
1323 err.message.contains("HTTP 500"),
1324 "Expected 'HTTP 500' in: {}",
1325 err.message
1326 );
1327 }
1328
1329 #[tokio::test]
1330 async fn fetch_source_maps_http_401_to_unauthorized() {
1331 let registry =
1332 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(401)));
1333
1334 let err = registry.fetch_source("@org/secret").await.unwrap_err();
1335
1336 assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1337 assert!(err.message.contains("HTTP 401"));
1338 }
1339
1340 #[tokio::test]
1341 async fn fetch_source_maps_http_403_to_unauthorized() {
1342 let registry =
1343 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(403)));
1344
1345 let err = registry.fetch_source("@org/private").await.unwrap_err();
1346
1347 assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1348 assert!(
1349 err.message.contains("HTTP 403"),
1350 "Expected 'HTTP 403' in: {}",
1351 err.message
1352 );
1353 }
1354
1355 #[tokio::test]
1356 async fn fetch_source_maps_unexpected_status_to_other() {
1357 let registry =
1358 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(418)));
1359
1360 let err = registry.fetch_source("@org/teapot").await.unwrap_err();
1361
1362 assert_eq!(err.kind, RegistryErrorKind::Other);
1363 assert!(err.message.contains("HTTP 418"));
1364 }
1365
1366 #[tokio::test]
1367 async fn fetch_source_maps_network_error_to_network_error_kind() {
1368 let registry = LemmaBase::with_fetcher(Box::new(
1369 MockHttpFetcher::always_failing_with_network_error("connection refused"),
1370 ));
1371
1372 let err = registry.fetch_source("@org/unreachable").await.unwrap_err();
1373
1374 assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1375 assert!(
1376 err.message.contains("connection refused"),
1377 "Expected 'connection refused' in: {}",
1378 err.message
1379 );
1380 assert!(
1381 err.message.contains("@org/unreachable"),
1382 "Expected '@org/unreachable' in: {}",
1383 err.message
1384 );
1385 }
1386
1387 #[tokio::test]
1388 async fn fetch_source_maps_dns_error_to_network_error_kind() {
1389 let registry = LemmaBase::with_fetcher(Box::new(
1390 MockHttpFetcher::always_failing_with_network_error(
1391 "dns error: failed to lookup address",
1392 ),
1393 ));
1394
1395 let err = registry.fetch_source("@org/spec").await.unwrap_err();
1396
1397 assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1398 assert!(
1399 err.message.contains("dns error"),
1400 "Expected 'dns error' in: {}",
1401 err.message
1402 );
1403 assert!(
1404 err.message.contains("Failed to reach LemmaBase"),
1405 "Expected 'Failed to reach LemmaBase' in: {}",
1406 err.message
1407 );
1408 }
1409
1410 #[tokio::test]
1415 async fn get_delegates_to_fetch_source() {
1416 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1417 "spec org/resolved\ndata a: 1",
1418 )));
1419
1420 let bundle = registry.get("@org/resolved").await.unwrap();
1421
1422 assert_eq!(bundle.lemma_source, "spec org/resolved\ndata a: 1");
1423 assert_eq!(bundle.source_type.to_string(), "@org/resolved");
1424 }
1425
1426 #[tokio::test]
1427 async fn fetch_source_returns_empty_body_as_valid_bundle() {
1428 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning("")));
1429
1430 let bundle = registry.fetch_source("@org/empty").await.unwrap();
1431
1432 assert_eq!(bundle.lemma_source, "");
1433 assert_eq!(bundle.source_type.to_string(), "@org/empty");
1434 }
1435 }
1436}