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
525 struct TestRegistry {
527 bundles: HashMap<String, RegistryBundle>,
528 }
529
530 impl TestRegistry {
531 fn new() -> Self {
532 Self {
533 bundles: HashMap::new(),
534 }
535 }
536
537 fn add_spec_bundle(&mut self, identifier: &str, lemma_source: &str) {
539 self.bundles.insert(
540 identifier.to_string(),
541 RegistryBundle {
542 lemma_source: lemma_source.to_string(),
543 source_type: crate::parsing::source::SourceType::Registry(Arc::new(
544 LemmaRepository::new(Some(identifier.to_string())),
545 )),
546 },
547 );
548 }
549 }
550
551 #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
552 #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
553 impl Registry for TestRegistry {
554 async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
555 self.bundles
556 .get(name)
557 .cloned()
558 .ok_or_else(|| RegistryError {
559 message: format!("'{}' not found in test registry", name),
560 kind: RegistryErrorKind::NotFound,
561 })
562 }
563
564 fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
565 if self.bundles.contains_key(name) {
566 Some(match effective {
567 None => format!("https://test.registry/{}", name),
568 Some(d) => format!("https://test.registry/{}?effective={}", name, d),
569 })
570 } else {
571 None
572 }
573 }
574 }
575
576 #[tokio::test]
577 async fn resolve_with_no_registry_references_returns_local_specs_unchanged() {
578 let source = r#"spec example
579data price: 100"#;
580 let local_specs = crate::parse(
581 source,
582 crate::parsing::source::SourceType::Volatile,
583 &ResourceLimits::default(),
584 )
585 .unwrap()
586 .into_flattened_specs();
587 let mut engine = Engine::new();
588 let store = engine.specs_mut();
589 let local_repository = store.workspace();
590 for spec in &local_specs {
591 store
592 .insert_spec(Arc::clone(&local_repository), Arc::new(spec.clone()))
593 .unwrap();
594 }
595 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
596 sources.insert(
597 crate::parsing::source::SourceType::Volatile,
598 source.to_string(),
599 );
600
601 let registry = TestRegistry::new();
602 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
603 .await
604 .unwrap();
605
606 assert_eq!(store.len(), 2, "embedded spec units plus workspace example");
607 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
608 assert!(names.iter().any(|n| n == "example"));
609 assert!(names.iter().any(|n| n == "units"));
610 }
611
612 #[tokio::test]
613 async fn resolve_fetches_single_spec_from_registry() {
614 let local_source = r#"spec main_spec
615uses external: @org/project helper
616rule value: external.quantity"#;
617 let local_specs = crate::parse(
618 local_source,
619 crate::parsing::source::SourceType::Volatile,
620 &ResourceLimits::default(),
621 )
622 .unwrap()
623 .into_flattened_specs();
624 let mut engine = Engine::new();
625 let store = engine.specs_mut();
626 let local_repository = store.workspace();
627 for spec in local_specs {
628 store
629 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
630 .unwrap();
631 }
632 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
633 sources.insert(
634 crate::parsing::source::SourceType::Volatile,
635 local_source.to_string(),
636 );
637
638 let mut registry = TestRegistry::new();
639 registry.add_spec_bundle(
640 "@org/project",
641 r#"repo @org/project
642spec helper
643data quantity: 42"#,
644 );
645
646 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
647 .await
648 .unwrap();
649
650 assert_eq!(store.len(), 3);
651 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
652 assert!(names.iter().any(|n| n == "main_spec"));
653 assert!(names.iter().any(|n| n == "helper"));
654 assert!(names.iter().any(|n| n == "units"));
655 }
656
657 #[tokio::test]
658 async fn resolve_registry_bundle_without_repo_decl_uses_reference_repository_name() {
659 let local_source = r#"spec main_spec
660uses external: @org/project helper
661rule value: external.quantity"#;
662 let local_specs = crate::parse(
663 local_source,
664 crate::parsing::source::SourceType::Volatile,
665 &ResourceLimits::default(),
666 )
667 .unwrap()
668 .into_flattened_specs();
669 let mut engine = Engine::new();
670 let store = engine.specs_mut();
671 let local_repository = store.workspace();
672 for spec in local_specs {
673 store
674 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
675 .unwrap();
676 }
677 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
678 sources.insert(
679 crate::parsing::source::SourceType::Volatile,
680 local_source.to_string(),
681 );
682
683 let mut registry = TestRegistry::new();
684 registry.add_spec_bundle(
685 "@org/project",
686 r#"spec helper
687data quantity: 42"#,
688 );
689
690 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
691 .await
692 .unwrap();
693
694 let ext_repo = store
695 .find_repository("@org/project")
696 .expect("registry bundle must land under fetched @ id");
697 let spec_names: Vec<String> = store
698 .repositories()
699 .get(&ext_repo)
700 .expect("spec sets for @org/project")
701 .keys()
702 .cloned()
703 .collect();
704 assert!(
705 spec_names.iter().any(|n| n == "helper"),
706 "helper spec should live under @org/project, got {:?}",
707 spec_names
708 );
709 }
710
711 #[tokio::test]
712 async fn get_returns_all_zones_and_url_for_id_supports_effective() {
713 let effective = DateTimeValue {
714 year: 2026,
715 month: 1,
716 day: 15,
717 hour: 0,
718 minute: 0,
719 second: 0,
720 microsecond: 0,
721 timezone: None,
722 };
723 let mut registry = TestRegistry::new();
724 registry.add_spec_bundle(
725 "@org/spec",
726 "spec org/spec 2025-01-01\ndata x: 1\n\nspec org/spec 2026-01-15\ndata x: 2",
727 );
728
729 let bundle = registry.get("@org/spec").await.unwrap();
730 assert!(bundle.lemma_source.contains("data x: 1"));
731 assert!(bundle.lemma_source.contains("data x: 2"));
732
733 assert_eq!(
734 registry.url_for_id("@org/spec", None),
735 Some("https://test.registry/@org/spec".to_string())
736 );
737 assert_eq!(
738 registry.url_for_id("@org/spec", Some(&effective)),
739 Some("https://test.registry/@org/spec?effective=2026-01-15".to_string())
740 );
741 }
742
743 #[tokio::test]
744 async fn resolve_fetches_transitive_dependencies() {
745 let local_source = r#"spec main_spec
746uses a: @org/project spec_a"#;
747 let local_specs = crate::parse(
748 local_source,
749 crate::parsing::source::SourceType::Volatile,
750 &ResourceLimits::default(),
751 )
752 .unwrap()
753 .into_flattened_specs();
754 let mut engine = Engine::new();
755 let store = engine.specs_mut();
756 let local_repository = store.workspace();
757 for spec in local_specs {
758 store
759 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
760 .unwrap();
761 }
762 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
763 sources.insert(
764 crate::parsing::source::SourceType::Volatile,
765 local_source.to_string(),
766 );
767
768 let mut registry = TestRegistry::new();
769 registry.add_spec_bundle(
770 "@org/project",
771 r#"repo @org/project
772spec spec_a
773uses b: @org/sub spec_b"#,
774 );
775 registry.add_spec_bundle(
776 "@org/sub",
777 r#"repo @org/sub
778spec spec_b
779data value: 99"#,
780 );
781
782 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
783 .await
784 .unwrap();
785
786 assert_eq!(store.len(), 4);
787 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
788 assert!(names.iter().any(|n| n == "main_spec"));
789 assert!(names.iter().any(|n| n == "spec_a"));
790 assert!(names.iter().any(|n| n == "spec_b"));
791 assert!(names.iter().any(|n| n == "units"));
792 }
793
794 #[tokio::test]
795 async fn resolve_handles_bundle_with_multiple_specs() {
796 let local_source = r#"spec main_spec
797uses a: @org/project spec_a"#;
798 let local_specs = crate::parse(
799 local_source,
800 crate::parsing::source::SourceType::Volatile,
801 &ResourceLimits::default(),
802 )
803 .unwrap()
804 .into_flattened_specs();
805 let mut engine = Engine::new();
806 let store = engine.specs_mut();
807 let local_repository = store.workspace();
808 for spec in local_specs {
809 store
810 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
811 .unwrap();
812 }
813 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
814 sources.insert(
815 crate::parsing::source::SourceType::Volatile,
816 local_source.to_string(),
817 );
818
819 let mut registry = TestRegistry::new();
820 registry.add_spec_bundle(
821 "@org/project",
822 r#"repo @org/project
823spec spec_a
824uses b: spec_b
825
826spec spec_b
827data value: 99"#,
828 );
829
830 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
831 .await
832 .unwrap();
833
834 assert_eq!(store.len(), 4);
835 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
836 assert!(names.iter().any(|n| n == "main_spec"));
837 assert!(names.iter().any(|n| n == "spec_a"));
838 assert!(names.iter().any(|n| n == "spec_b"));
839 assert!(names.iter().any(|n| n == "units"));
840 }
841
842 #[tokio::test]
843 async fn resolve_returns_registry_error_when_registry_fails() {
844 let local_source = r#"spec main_spec
845uses external: @org/project missing"#;
846 let local_specs = crate::parse(
847 local_source,
848 crate::parsing::source::SourceType::Volatile,
849 &ResourceLimits::default(),
850 )
851 .unwrap()
852 .into_flattened_specs();
853 let mut engine = Engine::new();
854 let store = engine.specs_mut();
855 let local_repository = store.workspace();
856 for spec in local_specs {
857 store
858 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
859 .unwrap();
860 }
861 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
862 sources.insert(
863 crate::parsing::source::SourceType::Volatile,
864 local_source.to_string(),
865 );
866
867 let registry = TestRegistry::new(); let result =
870 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
871 .await;
872
873 assert!(result.is_err(), "Should fail when Registry cannot resolve");
874 let errs = result.unwrap_err();
875 let registry_err = errs
876 .iter()
877 .find(|e| matches!(e, Error::Registry { .. }))
878 .expect("expected at least one Registry error");
879 match registry_err {
880 Error::Registry {
881 identifier,
882 kind,
883 details,
884 } => {
885 assert_eq!(identifier, "@org/project");
886 assert_eq!(*kind, RegistryErrorKind::NotFound);
887 assert!(
888 details.suggestion.is_some(),
889 "NotFound errors should include a suggestion"
890 );
891 }
892 _ => unreachable!(),
893 }
894
895 let error_message = errs
896 .iter()
897 .map(|e| e.to_string())
898 .collect::<Vec<_>>()
899 .join(" ");
900 assert!(
901 error_message.contains("@org/project"),
902 "Error should mention the identifier: {}",
903 error_message
904 );
905 }
906
907 #[tokio::test]
908 async fn resolve_returns_all_registry_errors_when_multiple_repositorys_fail() {
909 let local_source = r#"spec main_spec
910uses @org/example helper
911uses @lemma/std finance
912data money: finance.money"#;
913 let local_specs = crate::parse(
914 local_source,
915 crate::parsing::source::SourceType::Volatile,
916 &ResourceLimits::default(),
917 )
918 .unwrap()
919 .into_flattened_specs();
920 let mut engine = Engine::new();
921 let store = engine.specs_mut();
922 let local_repository = store.workspace();
923 for spec in local_specs {
924 store
925 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
926 .unwrap();
927 }
928 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
929 sources.insert(
930 crate::parsing::source::SourceType::Volatile,
931 local_source.to_string(),
932 );
933
934 let registry = TestRegistry::new(); let result =
937 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
938 .await;
939
940 assert!(result.is_err(), "Should fail when Registry cannot resolve");
941 let errors = result.unwrap_err();
942 let identifiers: Vec<&str> = errors
943 .iter()
944 .filter_map(|e| {
945 if let Error::Registry { identifier, .. } = e {
946 Some(identifier.as_str())
947 } else {
948 None
949 }
950 })
951 .collect();
952 assert!(
953 identifiers.contains(&"@org/example"),
954 "Should include repository error: {:?}",
955 identifiers
956 );
957 assert!(
958 identifiers.contains(&"@lemma/std"),
959 "Should include data import repository error: {:?}",
960 identifiers
961 );
962 }
963
964 #[tokio::test]
965 async fn resolve_does_not_request_same_repository_twice() {
966 let local_source = r#"spec spec_one
967uses a: @org/shared shared
968
969spec spec_two
970uses b: @org/shared shared"#;
971 let local_specs = crate::parse(
972 local_source,
973 crate::parsing::source::SourceType::Volatile,
974 &ResourceLimits::default(),
975 )
976 .unwrap()
977 .into_flattened_specs();
978 let mut engine = Engine::new();
979 let store = engine.specs_mut();
980 let local_repository = store.workspace();
981 for spec in local_specs {
982 store
983 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
984 .unwrap();
985 }
986 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
987 sources.insert(
988 crate::parsing::source::SourceType::Volatile,
989 local_source.to_string(),
990 );
991
992 let mut registry = TestRegistry::new();
993 registry.add_spec_bundle(
994 "@org/shared",
995 r#"repo @org/shared
996spec shared
997data value: 1"#,
998 );
999
1000 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
1001 .await
1002 .unwrap();
1003
1004 assert_eq!(store.len(), 4);
1005 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1006 assert!(names.iter().any(|n| n == "shared"));
1007 assert!(names.iter().any(|n| n == "units"));
1008 }
1009
1010 #[tokio::test]
1011 async fn resolve_handles_data_import_from_registry() {
1012 let local_source = r#"spec main_spec
1013uses @lemma/std finance
1014data money: finance.money
1015data price: money"#;
1016 let local_specs = crate::parse(
1017 local_source,
1018 crate::parsing::source::SourceType::Volatile,
1019 &ResourceLimits::default(),
1020 )
1021 .unwrap()
1022 .into_flattened_specs();
1023 let mut engine = Engine::new();
1024 let store = engine.specs_mut();
1025 let local_repository = store.workspace();
1026 for spec in local_specs {
1027 store
1028 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
1029 .unwrap();
1030 }
1031 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
1032 sources.insert(
1033 crate::parsing::source::SourceType::Volatile,
1034 local_source.to_string(),
1035 );
1036
1037 let mut registry = TestRegistry::new();
1038 registry.add_spec_bundle(
1039 "@lemma/std",
1040 r#"repo @lemma/std
1041spec finance
1042data money: quantity
1043 -> unit eur 1.00
1044 -> unit usd 0.91
1045 -> decimals 2"#,
1046 );
1047
1048 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
1049 .await
1050 .unwrap();
1051
1052 assert_eq!(store.len(), 3);
1053 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1054 assert!(names.iter().any(|n| n == "main_spec"));
1055 assert!(names.iter().any(|n| n == "finance"));
1056 assert!(names.iter().any(|n| n == "units"));
1057 }
1058
1059 #[cfg(feature = "registry")]
1064 mod lemmabase_tests {
1065 use super::super::*;
1066 use std::sync::{Arc, Mutex};
1067
1068 type HttpFetchHandler = Box<dyn Fn(&str) -> Result<String, HttpFetchError> + Send + Sync>;
1073
1074 struct MockHttpFetcher {
1075 handler: HttpFetchHandler,
1076 }
1077
1078 impl MockHttpFetcher {
1079 fn with_handler(
1081 handler: impl Fn(&str) -> Result<String, HttpFetchError> + Send + Sync + 'static,
1082 ) -> Self {
1083 Self {
1084 handler: Box::new(handler),
1085 }
1086 }
1087
1088 fn always_returning(body: &str) -> Self {
1090 let body = body.to_string();
1091 Self::with_handler(move |_| Ok(body.clone()))
1092 }
1093
1094 fn always_failing_with_status(code: u16) -> Self {
1096 Self::with_handler(move |_| {
1097 Err(HttpFetchError {
1098 status_code: Some(code),
1099 message: format!("HTTP {}", code),
1100 })
1101 })
1102 }
1103
1104 fn always_failing_with_network_error(msg: &str) -> Self {
1106 let msg = msg.to_string();
1107 Self::with_handler(move |_| {
1108 Err(HttpFetchError {
1109 status_code: None,
1110 message: msg.clone(),
1111 })
1112 })
1113 }
1114 }
1115
1116 #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
1117 #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
1118 impl HttpFetcher for MockHttpFetcher {
1119 async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
1120 (self.handler)(url)
1121 }
1122 }
1123
1124 #[test]
1129 fn source_url_without_effective() {
1130 let registry = LemmaBase::new();
1131 let url = registry.source_url("@user/workspace/somespec", None);
1132 assert_eq!(
1133 url,
1134 format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1135 );
1136 }
1137
1138 #[test]
1139 fn source_url_with_effective() {
1140 let registry = LemmaBase::new();
1141 let effective = DateTimeValue {
1142 year: 2026,
1143 month: 1,
1144 day: 15,
1145 hour: 0,
1146 minute: 0,
1147 second: 0,
1148 microsecond: 0,
1149 timezone: None,
1150 };
1151 let url = registry.source_url("@user/workspace/somespec", Some(&effective));
1152 assert_eq!(
1153 url,
1154 format!(
1155 "{}/@user/workspace/somespec.lemma?effective=2026-01-15",
1156 LemmaBase::BASE_URL
1157 )
1158 );
1159 }
1160
1161 #[test]
1162 fn source_url_for_deeply_nested_identifier() {
1163 let registry = LemmaBase::new();
1164 let url = registry.source_url("@org/team/project/subdir/spec", None);
1165 assert_eq!(
1166 url,
1167 format!(
1168 "{}/@org/team/project/subdir/spec.lemma",
1169 LemmaBase::BASE_URL
1170 )
1171 );
1172 }
1173
1174 #[test]
1175 fn navigation_url_without_effective() {
1176 let registry = LemmaBase::new();
1177 let url = registry.navigation_url("@user/workspace/somespec", None);
1178 assert_eq!(
1179 url,
1180 format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL)
1181 );
1182 }
1183
1184 #[test]
1185 fn navigation_url_with_effective() {
1186 let registry = LemmaBase::new();
1187 let effective = DateTimeValue {
1188 year: 2026,
1189 month: 1,
1190 day: 15,
1191 hour: 0,
1192 minute: 0,
1193 second: 0,
1194 microsecond: 0,
1195 timezone: None,
1196 };
1197 let url = registry.navigation_url("@user/workspace/somespec", Some(&effective));
1198 assert_eq!(
1199 url,
1200 format!(
1201 "{}/@user/workspace/somespec?effective=2026-01-15",
1202 LemmaBase::BASE_URL
1203 )
1204 );
1205 }
1206
1207 #[test]
1208 fn url_for_id_returns_navigation_url() {
1209 let registry = LemmaBase::new();
1210 let url = registry.url_for_id("@user/workspace/somespec", None);
1211 assert_eq!(
1212 url,
1213 Some(format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL))
1214 );
1215 }
1216
1217 #[test]
1218 fn url_for_id_with_effective() {
1219 let registry = LemmaBase::new();
1220 let effective = DateTimeValue {
1221 year: 2026,
1222 month: 1,
1223 day: 1,
1224 hour: 0,
1225 minute: 0,
1226 second: 0,
1227 microsecond: 0,
1228 timezone: None,
1229 };
1230 let url = registry.url_for_id("@owner/repo/spec", Some(&effective));
1231 assert_eq!(
1232 url,
1233 Some(format!(
1234 "{}/@owner/repo/spec?effective=2026-01-01",
1235 LemmaBase::BASE_URL
1236 ))
1237 );
1238 }
1239
1240 #[test]
1241 fn url_for_id_returns_navigation_url_for_nested_path() {
1242 let registry = LemmaBase::new();
1243 let url = registry.url_for_id("@lemma/std/finance", None);
1244 assert_eq!(
1245 url,
1246 Some(format!("{}/@lemma/std/finance", LemmaBase::BASE_URL))
1247 );
1248 }
1249
1250 #[tokio::test]
1255 async fn fetch_source_returns_bundle_on_success() {
1256 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1257 "spec org/my_spec\ndata x: 1",
1258 )));
1259
1260 let bundle = registry.fetch_source("@org/my_spec").await.unwrap();
1261
1262 assert_eq!(bundle.lemma_source, "spec org/my_spec\ndata x: 1");
1263 assert_eq!(bundle.source_type.to_string(), "@org/my_spec");
1264 }
1265
1266 #[tokio::test]
1267 async fn fetch_source_passes_correct_url_to_fetcher() {
1268 let captured_url = Arc::new(Mutex::new(String::new()));
1269 let captured = captured_url.clone();
1270 let mock = MockHttpFetcher::with_handler(move |url| {
1271 *captured.lock().unwrap() = url.to_string();
1272 Ok("spec test/spec\ndata x: 1".to_string())
1273 });
1274 let registry = LemmaBase::with_fetcher(Box::new(mock));
1275
1276 let _ = registry.fetch_source("@user/workspace/somespec").await;
1277
1278 assert_eq!(
1279 *captured_url.lock().unwrap(),
1280 format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1281 );
1282 }
1283
1284 #[tokio::test]
1285 async fn fetch_source_maps_http_404_to_not_found() {
1286 let registry =
1287 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(404)));
1288
1289 let err = registry.fetch_source("@org/missing").await.unwrap_err();
1290
1291 assert_eq!(err.kind, RegistryErrorKind::NotFound);
1292 assert!(
1293 err.message.contains("HTTP 404"),
1294 "Expected 'HTTP 404' in: {}",
1295 err.message
1296 );
1297 assert!(
1298 err.message.contains("@org/missing"),
1299 "Expected '@org/missing' in: {}",
1300 err.message
1301 );
1302 }
1303
1304 #[tokio::test]
1305 async fn fetch_source_maps_http_500_to_server_error() {
1306 let registry =
1307 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(500)));
1308
1309 let err = registry.fetch_source("@org/broken").await.unwrap_err();
1310
1311 assert_eq!(err.kind, RegistryErrorKind::ServerError);
1312 assert!(
1313 err.message.contains("HTTP 500"),
1314 "Expected 'HTTP 500' in: {}",
1315 err.message
1316 );
1317 }
1318
1319 #[tokio::test]
1320 async fn fetch_source_maps_http_401_to_unauthorized() {
1321 let registry =
1322 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(401)));
1323
1324 let err = registry.fetch_source("@org/secret").await.unwrap_err();
1325
1326 assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1327 assert!(err.message.contains("HTTP 401"));
1328 }
1329
1330 #[tokio::test]
1331 async fn fetch_source_maps_http_403_to_unauthorized() {
1332 let registry =
1333 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(403)));
1334
1335 let err = registry.fetch_source("@org/private").await.unwrap_err();
1336
1337 assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1338 assert!(
1339 err.message.contains("HTTP 403"),
1340 "Expected 'HTTP 403' in: {}",
1341 err.message
1342 );
1343 }
1344
1345 #[tokio::test]
1346 async fn fetch_source_maps_unexpected_status_to_other() {
1347 let registry =
1348 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(418)));
1349
1350 let err = registry.fetch_source("@org/teapot").await.unwrap_err();
1351
1352 assert_eq!(err.kind, RegistryErrorKind::Other);
1353 assert!(err.message.contains("HTTP 418"));
1354 }
1355
1356 #[tokio::test]
1357 async fn fetch_source_maps_network_error_to_network_error_kind() {
1358 let registry = LemmaBase::with_fetcher(Box::new(
1359 MockHttpFetcher::always_failing_with_network_error("connection refused"),
1360 ));
1361
1362 let err = registry.fetch_source("@org/unreachable").await.unwrap_err();
1363
1364 assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1365 assert!(
1366 err.message.contains("connection refused"),
1367 "Expected 'connection refused' in: {}",
1368 err.message
1369 );
1370 assert!(
1371 err.message.contains("@org/unreachable"),
1372 "Expected '@org/unreachable' in: {}",
1373 err.message
1374 );
1375 }
1376
1377 #[tokio::test]
1378 async fn fetch_source_maps_dns_error_to_network_error_kind() {
1379 let registry = LemmaBase::with_fetcher(Box::new(
1380 MockHttpFetcher::always_failing_with_network_error(
1381 "dns error: failed to lookup address",
1382 ),
1383 ));
1384
1385 let err = registry.fetch_source("@org/spec").await.unwrap_err();
1386
1387 assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1388 assert!(
1389 err.message.contains("dns error"),
1390 "Expected 'dns error' in: {}",
1391 err.message
1392 );
1393 assert!(
1394 err.message.contains("Failed to reach LemmaBase"),
1395 "Expected 'Failed to reach LemmaBase' in: {}",
1396 err.message
1397 );
1398 }
1399
1400 #[tokio::test]
1405 async fn get_delegates_to_fetch_source() {
1406 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1407 "spec org/resolved\ndata a: 1",
1408 )));
1409
1410 let bundle = registry.get("@org/resolved").await.unwrap();
1411
1412 assert_eq!(bundle.lemma_source, "spec org/resolved\ndata a: 1");
1413 assert_eq!(bundle.source_type.to_string(), "@org/resolved");
1414 }
1415
1416 #[tokio::test]
1417 async fn fetch_source_returns_empty_body_as_valid_bundle() {
1418 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning("")));
1419
1420 let bundle = registry.fetch_source("@org/empty").await.unwrap();
1421
1422 assert_eq!(bundle.lemma_source, "");
1423 assert_eq!(bundle.source_type.to_string(), "@org/empty");
1424 }
1425 }
1426}