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