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 !qualifier.is_registry() {
472 return;
473 }
474 if ctx.find_repository(&qualifier.name).is_some() {
475 return;
476 }
477 if already_requested.contains(&qualifier.name) {
478 return;
479 }
480 if !seen_in_this_round.insert(qualifier.name.clone()) {
481 return;
482 }
483 out.push(RegistryReference {
484 repository: qualifier.clone(),
485 source: source.clone(),
486 });
487}
488
489#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
491fn find_missing_repositories(
492 ctx: &Context,
493 already_requested: &HashSet<String>,
494) -> Vec<RegistryReference> {
495 let mut unresolved: Vec<RegistryReference> = Vec::new();
496 let mut seen_in_this_round: HashSet<String> = HashSet::new();
497
498 for spec in ctx.iter() {
499 let spec = spec.as_ref();
500
501 for data in &spec.data {
502 if let DataValue::Import(spec_ref) = &data.value {
504 collect_repository_qualifiers_from_spec_ref(
505 spec_ref,
506 &data.source_location,
507 ctx,
508 already_requested,
509 &mut seen_in_this_round,
510 &mut unresolved,
511 );
512 }
513 }
514 }
515
516 unresolved
517}
518
519#[cfg(test)]
524mod tests {
525 use super::*;
526 use crate::engine::{Context, Engine};
527 use crate::literals::DateGranularity;
528
529 struct TestRegistry {
531 bundles: HashMap<String, RegistryBundle>,
532 }
533
534 impl TestRegistry {
535 fn new() -> Self {
536 Self {
537 bundles: HashMap::new(),
538 }
539 }
540
541 fn add_spec_bundle(&mut self, identifier: &str, lemma_source: &str) {
543 self.bundles.insert(
544 identifier.to_string(),
545 RegistryBundle {
546 lemma_source: lemma_source.to_string(),
547 source_type: crate::parsing::source::SourceType::Registry(Arc::new(
548 LemmaRepository::new(Some(identifier.to_string())),
549 )),
550 },
551 );
552 }
553 }
554
555 #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
556 #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
557 impl Registry for TestRegistry {
558 async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
559 self.bundles
560 .get(name)
561 .cloned()
562 .ok_or_else(|| RegistryError {
563 message: format!("'{}' not found in test registry", name),
564 kind: RegistryErrorKind::NotFound,
565 })
566 }
567
568 fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
569 if self.bundles.contains_key(name) {
570 Some(match effective {
571 None => format!("https://test.registry/{}", name),
572 Some(d) => format!("https://test.registry/{}?effective={}", name, d),
573 })
574 } else {
575 None
576 }
577 }
578 }
579
580 #[tokio::test]
581 async fn resolve_with_no_registry_references_returns_local_specs_unchanged() {
582 let source = r#"spec example
583data price: 100"#;
584 let local_specs = crate::parse(
585 source,
586 crate::parsing::source::SourceType::Volatile,
587 &ResourceLimits::default(),
588 )
589 .unwrap()
590 .into_flattened_specs();
591 let mut engine = Engine::new();
592 let store = engine.specs_mut();
593 let local_repository = store.workspace();
594 for spec in &local_specs {
595 store
596 .insert_spec(Arc::clone(&local_repository), Arc::new(spec.clone()))
597 .unwrap();
598 }
599 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
600 sources.insert(
601 crate::parsing::source::SourceType::Volatile,
602 source.to_string(),
603 );
604
605 let registry = TestRegistry::new();
606 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
607 .await
608 .unwrap();
609
610 assert_eq!(store.len(), 2, "embedded spec units plus workspace example");
611 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
612 assert!(names.iter().any(|n| n == "example"));
613 assert!(names.iter().any(|n| n == "units"));
614 }
615
616 #[tokio::test]
618 async fn resolve_does_not_fetch_non_at_qualified_repositories() {
619 let local_source = r#"spec burn_baby_burn
620uses lemma units
621rule x: 1 hour"#;
622 let local_specs = crate::parse(
623 local_source,
624 crate::parsing::source::SourceType::Volatile,
625 &ResourceLimits::default(),
626 )
627 .unwrap()
628 .into_flattened_specs();
629 let mut store = Context::new();
630 let local_repository = store.workspace();
631 for spec in local_specs {
632 store
633 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
634 .unwrap();
635 }
636 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
637 sources.insert(
638 crate::parsing::source::SourceType::Volatile,
639 local_source.to_string(),
640 );
641
642 let registry = TestRegistry::new();
643 let result = resolve_registry_references(
644 &mut store,
645 &mut sources,
646 ®istry,
647 &ResourceLimits::default(),
648 )
649 .await;
650
651 assert!(
652 result.is_ok(),
653 "non-@ repository qualifiers must not be sent to the registry, got: {:?}",
654 result.err()
655 );
656 }
657
658 #[tokio::test]
659 async fn resolve_fetches_single_spec_from_registry() {
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#"repo @org/project
688spec helper
689data quantity: 42"#,
690 );
691
692 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
693 .await
694 .unwrap();
695
696 assert_eq!(store.len(), 3);
697 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
698 assert!(names.iter().any(|n| n == "main_spec"));
699 assert!(names.iter().any(|n| n == "helper"));
700 assert!(names.iter().any(|n| n == "units"));
701 }
702
703 #[tokio::test]
704 async fn resolve_registry_bundle_without_repo_decl_uses_reference_repository_name() {
705 let local_source = r#"spec main_spec
706uses external: @org/project helper
707rule value: external.quantity"#;
708 let local_specs = crate::parse(
709 local_source,
710 crate::parsing::source::SourceType::Volatile,
711 &ResourceLimits::default(),
712 )
713 .unwrap()
714 .into_flattened_specs();
715 let mut engine = Engine::new();
716 let store = engine.specs_mut();
717 let local_repository = store.workspace();
718 for spec in local_specs {
719 store
720 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
721 .unwrap();
722 }
723 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
724 sources.insert(
725 crate::parsing::source::SourceType::Volatile,
726 local_source.to_string(),
727 );
728
729 let mut registry = TestRegistry::new();
730 registry.add_spec_bundle(
731 "@org/project",
732 r#"spec helper
733data quantity: 42"#,
734 );
735
736 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
737 .await
738 .unwrap();
739
740 let ext_repo = store
741 .find_repository("@org/project")
742 .expect("registry bundle must land under fetched @ id");
743 let spec_names: Vec<String> = store
744 .repositories()
745 .get(&ext_repo)
746 .expect("spec sets for @org/project")
747 .keys()
748 .cloned()
749 .collect();
750 assert!(
751 spec_names.iter().any(|n| n == "helper"),
752 "helper spec should live under @org/project, got {:?}",
753 spec_names
754 );
755 }
756
757 #[tokio::test]
758 async fn get_returns_all_zones_and_url_for_id_supports_effective() {
759 let effective = DateTimeValue {
760 year: 2026,
761 month: 1,
762 day: 15,
763 hour: 0,
764 minute: 0,
765 second: 0,
766 microsecond: 0,
767 timezone: None,
768
769 granularity: DateGranularity::Full,
770 };
771 let mut registry = TestRegistry::new();
772 registry.add_spec_bundle(
773 "@org/spec",
774 "spec org/spec 2025-01-01\ndata x: 1\n\nspec org/spec 2026-01-15\ndata x: 2",
775 );
776
777 let bundle = registry.get("@org/spec").await.unwrap();
778 assert!(bundle.lemma_source.contains("data x: 1"));
779 assert!(bundle.lemma_source.contains("data x: 2"));
780
781 assert_eq!(
782 registry.url_for_id("@org/spec", None),
783 Some("https://test.registry/@org/spec".to_string())
784 );
785 assert_eq!(
786 registry.url_for_id("@org/spec", Some(&effective)),
787 Some("https://test.registry/@org/spec?effective=2026-01-15".to_string())
788 );
789 }
790
791 #[tokio::test]
792 async fn resolve_fetches_transitive_dependencies() {
793 let local_source = r#"spec main_spec
794uses a: @org/project spec_a"#;
795 let local_specs = crate::parse(
796 local_source,
797 crate::parsing::source::SourceType::Volatile,
798 &ResourceLimits::default(),
799 )
800 .unwrap()
801 .into_flattened_specs();
802 let mut engine = Engine::new();
803 let store = engine.specs_mut();
804 let local_repository = store.workspace();
805 for spec in local_specs {
806 store
807 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
808 .unwrap();
809 }
810 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
811 sources.insert(
812 crate::parsing::source::SourceType::Volatile,
813 local_source.to_string(),
814 );
815
816 let mut registry = TestRegistry::new();
817 registry.add_spec_bundle(
818 "@org/project",
819 r#"repo @org/project
820spec spec_a
821uses b: @org/sub spec_b"#,
822 );
823 registry.add_spec_bundle(
824 "@org/sub",
825 r#"repo @org/sub
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_handles_bundle_with_multiple_specs() {
844 let local_source = r#"spec main_spec
845uses a: @org/project spec_a"#;
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 mut registry = TestRegistry::new();
868 registry.add_spec_bundle(
869 "@org/project",
870 r#"repo @org/project
871spec spec_a
872uses b: spec_b
873
874spec spec_b
875data value: 99"#,
876 );
877
878 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
879 .await
880 .unwrap();
881
882 assert_eq!(store.len(), 4);
883 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
884 assert!(names.iter().any(|n| n == "main_spec"));
885 assert!(names.iter().any(|n| n == "spec_a"));
886 assert!(names.iter().any(|n| n == "spec_b"));
887 assert!(names.iter().any(|n| n == "units"));
888 }
889
890 #[tokio::test]
891 async fn resolve_returns_registry_error_when_registry_fails() {
892 let local_source = r#"spec main_spec
893uses external: @org/project missing"#;
894 let local_specs = crate::parse(
895 local_source,
896 crate::parsing::source::SourceType::Volatile,
897 &ResourceLimits::default(),
898 )
899 .unwrap()
900 .into_flattened_specs();
901 let mut engine = Engine::new();
902 let store = engine.specs_mut();
903 let local_repository = store.workspace();
904 for spec in local_specs {
905 store
906 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
907 .unwrap();
908 }
909 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
910 sources.insert(
911 crate::parsing::source::SourceType::Volatile,
912 local_source.to_string(),
913 );
914
915 let registry = TestRegistry::new(); let result =
918 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
919 .await;
920
921 assert!(result.is_err(), "Should fail when Registry cannot resolve");
922 let errs = result.unwrap_err();
923 let registry_err = errs
924 .iter()
925 .find(|e| matches!(e, Error::Registry { .. }))
926 .expect("expected at least one Registry error");
927 match registry_err {
928 Error::Registry {
929 identifier,
930 kind,
931 details,
932 } => {
933 assert_eq!(identifier, "@org/project");
934 assert_eq!(*kind, RegistryErrorKind::NotFound);
935 assert!(
936 details.suggestion.is_some(),
937 "NotFound errors should include a suggestion"
938 );
939 }
940 _ => unreachable!(),
941 }
942
943 let error_message = errs
944 .iter()
945 .map(|e| e.to_string())
946 .collect::<Vec<_>>()
947 .join(" ");
948 assert!(
949 error_message.contains("@org/project"),
950 "Error should mention the identifier: {}",
951 error_message
952 );
953 }
954
955 #[tokio::test]
956 async fn resolve_returns_all_registry_errors_when_multiple_repositorys_fail() {
957 let local_source = r#"spec main_spec
958uses @org/example helper
959uses @lemma/std finance
960data money: finance.money"#;
961 let local_specs = crate::parse(
962 local_source,
963 crate::parsing::source::SourceType::Volatile,
964 &ResourceLimits::default(),
965 )
966 .unwrap()
967 .into_flattened_specs();
968 let mut engine = Engine::new();
969 let store = engine.specs_mut();
970 let local_repository = store.workspace();
971 for spec in local_specs {
972 store
973 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
974 .unwrap();
975 }
976 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
977 sources.insert(
978 crate::parsing::source::SourceType::Volatile,
979 local_source.to_string(),
980 );
981
982 let registry = TestRegistry::new(); let result =
985 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
986 .await;
987
988 assert!(result.is_err(), "Should fail when Registry cannot resolve");
989 let errors = result.unwrap_err();
990 let identifiers: Vec<&str> = errors
991 .iter()
992 .filter_map(|e| {
993 if let Error::Registry { identifier, .. } = e {
994 Some(identifier.as_str())
995 } else {
996 None
997 }
998 })
999 .collect();
1000 assert!(
1001 identifiers.contains(&"@org/example"),
1002 "Should include repository error: {:?}",
1003 identifiers
1004 );
1005 assert!(
1006 identifiers.contains(&"@lemma/std"),
1007 "Should include data import repository error: {:?}",
1008 identifiers
1009 );
1010 }
1011
1012 #[tokio::test]
1013 async fn resolve_does_not_request_same_repository_twice() {
1014 let local_source = r#"spec spec_one
1015uses a: @org/shared shared
1016
1017spec spec_two
1018uses b: @org/shared shared"#;
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 "@org/shared",
1043 r#"repo @org/shared
1044spec shared
1045data value: 1"#,
1046 );
1047
1048 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
1049 .await
1050 .unwrap();
1051
1052 assert_eq!(store.len(), 4);
1053 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1054 assert!(names.iter().any(|n| n == "shared"));
1055 assert!(names.iter().any(|n| n == "units"));
1056 }
1057
1058 #[tokio::test]
1059 async fn resolve_handles_data_import_from_registry() {
1060 let local_source = r#"spec main_spec
1061uses @lemma/std finance
1062data money: finance.money
1063data price: money"#;
1064 let local_specs = crate::parse(
1065 local_source,
1066 crate::parsing::source::SourceType::Volatile,
1067 &ResourceLimits::default(),
1068 )
1069 .unwrap()
1070 .into_flattened_specs();
1071 let mut engine = Engine::new();
1072 let store = engine.specs_mut();
1073 let local_repository = store.workspace();
1074 for spec in local_specs {
1075 store
1076 .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
1077 .unwrap();
1078 }
1079 let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
1080 sources.insert(
1081 crate::parsing::source::SourceType::Volatile,
1082 local_source.to_string(),
1083 );
1084
1085 let mut registry = TestRegistry::new();
1086 registry.add_spec_bundle(
1087 "@lemma/std",
1088 r#"repo @lemma/std
1089spec finance
1090data money: quantity
1091 -> unit eur 1.00
1092 -> unit usd 0.91
1093 -> decimals 2"#,
1094 );
1095
1096 resolve_registry_references(store, &mut sources, ®istry, &ResourceLimits::default())
1097 .await
1098 .unwrap();
1099
1100 assert_eq!(store.len(), 3);
1101 let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1102 assert!(names.iter().any(|n| n == "main_spec"));
1103 assert!(names.iter().any(|n| n == "finance"));
1104 assert!(names.iter().any(|n| n == "units"));
1105 }
1106
1107 #[cfg(feature = "registry")]
1112 mod lemmabase_tests {
1113 use super::super::*;
1114 use crate::literals::DateGranularity;
1115 use std::sync::{Arc, Mutex};
1116
1117 type HttpFetchHandler = Box<dyn Fn(&str) -> Result<String, HttpFetchError> + Send + Sync>;
1122
1123 struct MockHttpFetcher {
1124 handler: HttpFetchHandler,
1125 }
1126
1127 impl MockHttpFetcher {
1128 fn with_handler(
1130 handler: impl Fn(&str) -> Result<String, HttpFetchError> + Send + Sync + 'static,
1131 ) -> Self {
1132 Self {
1133 handler: Box::new(handler),
1134 }
1135 }
1136
1137 fn always_returning(body: &str) -> Self {
1139 let body = body.to_string();
1140 Self::with_handler(move |_| Ok(body.clone()))
1141 }
1142
1143 fn always_failing_with_status(code: u16) -> Self {
1145 Self::with_handler(move |_| {
1146 Err(HttpFetchError {
1147 status_code: Some(code),
1148 message: format!("HTTP {}", code),
1149 })
1150 })
1151 }
1152
1153 fn always_failing_with_network_error(msg: &str) -> Self {
1155 let msg = msg.to_string();
1156 Self::with_handler(move |_| {
1157 Err(HttpFetchError {
1158 status_code: None,
1159 message: msg.clone(),
1160 })
1161 })
1162 }
1163 }
1164
1165 #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
1166 #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
1167 impl HttpFetcher for MockHttpFetcher {
1168 async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
1169 (self.handler)(url)
1170 }
1171 }
1172
1173 #[test]
1178 fn source_url_without_effective() {
1179 let registry = LemmaBase::new();
1180 let url = registry.source_url("@user/workspace/somespec", None);
1181 assert_eq!(
1182 url,
1183 format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1184 );
1185 }
1186
1187 #[test]
1188 fn source_url_with_effective() {
1189 let registry = LemmaBase::new();
1190 let effective = DateTimeValue {
1191 year: 2026,
1192 month: 1,
1193 day: 15,
1194 hour: 0,
1195 minute: 0,
1196 second: 0,
1197 microsecond: 0,
1198 timezone: None,
1199
1200 granularity: DateGranularity::Full,
1201 };
1202 let url = registry.source_url("@user/workspace/somespec", Some(&effective));
1203 assert_eq!(
1204 url,
1205 format!(
1206 "{}/@user/workspace/somespec.lemma?effective=2026-01-15",
1207 LemmaBase::BASE_URL
1208 )
1209 );
1210 }
1211
1212 #[test]
1213 fn source_url_for_deeply_nested_identifier() {
1214 let registry = LemmaBase::new();
1215 let url = registry.source_url("@org/team/project/subdir/spec", None);
1216 assert_eq!(
1217 url,
1218 format!(
1219 "{}/@org/team/project/subdir/spec.lemma",
1220 LemmaBase::BASE_URL
1221 )
1222 );
1223 }
1224
1225 #[test]
1226 fn navigation_url_without_effective() {
1227 let registry = LemmaBase::new();
1228 let url = registry.navigation_url("@user/workspace/somespec", None);
1229 assert_eq!(
1230 url,
1231 format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL)
1232 );
1233 }
1234
1235 #[test]
1236 fn navigation_url_with_effective() {
1237 let registry = LemmaBase::new();
1238 let effective = DateTimeValue {
1239 year: 2026,
1240 month: 1,
1241 day: 15,
1242 hour: 0,
1243 minute: 0,
1244 second: 0,
1245 microsecond: 0,
1246 timezone: None,
1247
1248 granularity: DateGranularity::Full,
1249 };
1250 let url = registry.navigation_url("@user/workspace/somespec", Some(&effective));
1251 assert_eq!(
1252 url,
1253 format!(
1254 "{}/@user/workspace/somespec?effective=2026-01-15",
1255 LemmaBase::BASE_URL
1256 )
1257 );
1258 }
1259
1260 #[test]
1261 fn url_for_id_returns_navigation_url() {
1262 let registry = LemmaBase::new();
1263 let url = registry.url_for_id("@user/workspace/somespec", None);
1264 assert_eq!(
1265 url,
1266 Some(format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL))
1267 );
1268 }
1269
1270 #[test]
1271 fn url_for_id_with_effective() {
1272 let registry = LemmaBase::new();
1273 let effective = DateTimeValue {
1274 year: 2026,
1275 month: 1,
1276 day: 1,
1277 hour: 0,
1278 minute: 0,
1279 second: 0,
1280 microsecond: 0,
1281 timezone: None,
1282
1283 granularity: DateGranularity::Full,
1284 };
1285 let url = registry.url_for_id("@owner/repo/spec", Some(&effective));
1286 assert_eq!(
1287 url,
1288 Some(format!(
1289 "{}/@owner/repo/spec?effective=2026-01-01",
1290 LemmaBase::BASE_URL
1291 ))
1292 );
1293 }
1294
1295 #[test]
1296 fn url_for_id_returns_navigation_url_for_nested_path() {
1297 let registry = LemmaBase::new();
1298 let url = registry.url_for_id("@lemma/std/finance", None);
1299 assert_eq!(
1300 url,
1301 Some(format!("{}/@lemma/std/finance", LemmaBase::BASE_URL))
1302 );
1303 }
1304
1305 #[tokio::test]
1310 async fn fetch_source_returns_bundle_on_success() {
1311 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1312 "spec org/my_spec\ndata x: 1",
1313 )));
1314
1315 let bundle = registry.fetch_source("@org/my_spec").await.unwrap();
1316
1317 assert_eq!(bundle.lemma_source, "spec org/my_spec\ndata x: 1");
1318 assert_eq!(bundle.source_type.to_string(), "@org/my_spec");
1319 }
1320
1321 #[tokio::test]
1322 async fn fetch_source_passes_correct_url_to_fetcher() {
1323 let captured_url = Arc::new(Mutex::new(String::new()));
1324 let captured = captured_url.clone();
1325 let mock = MockHttpFetcher::with_handler(move |url| {
1326 *captured.lock().unwrap() = url.to_string();
1327 Ok("spec test/spec\ndata x: 1".to_string())
1328 });
1329 let registry = LemmaBase::with_fetcher(Box::new(mock));
1330
1331 let _ = registry.fetch_source("@user/workspace/somespec").await;
1332
1333 assert_eq!(
1334 *captured_url.lock().unwrap(),
1335 format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1336 );
1337 }
1338
1339 #[tokio::test]
1340 async fn fetch_source_maps_http_404_to_not_found() {
1341 let registry =
1342 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(404)));
1343
1344 let err = registry.fetch_source("@org/missing").await.unwrap_err();
1345
1346 assert_eq!(err.kind, RegistryErrorKind::NotFound);
1347 assert!(
1348 err.message.contains("HTTP 404"),
1349 "Expected 'HTTP 404' in: {}",
1350 err.message
1351 );
1352 assert!(
1353 err.message.contains("@org/missing"),
1354 "Expected '@org/missing' in: {}",
1355 err.message
1356 );
1357 }
1358
1359 #[tokio::test]
1360 async fn fetch_source_maps_http_500_to_server_error() {
1361 let registry =
1362 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(500)));
1363
1364 let err = registry.fetch_source("@org/broken").await.unwrap_err();
1365
1366 assert_eq!(err.kind, RegistryErrorKind::ServerError);
1367 assert!(
1368 err.message.contains("HTTP 500"),
1369 "Expected 'HTTP 500' in: {}",
1370 err.message
1371 );
1372 }
1373
1374 #[tokio::test]
1375 async fn fetch_source_maps_http_401_to_unauthorized() {
1376 let registry =
1377 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(401)));
1378
1379 let err = registry.fetch_source("@org/secret").await.unwrap_err();
1380
1381 assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1382 assert!(err.message.contains("HTTP 401"));
1383 }
1384
1385 #[tokio::test]
1386 async fn fetch_source_maps_http_403_to_unauthorized() {
1387 let registry =
1388 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(403)));
1389
1390 let err = registry.fetch_source("@org/private").await.unwrap_err();
1391
1392 assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1393 assert!(
1394 err.message.contains("HTTP 403"),
1395 "Expected 'HTTP 403' in: {}",
1396 err.message
1397 );
1398 }
1399
1400 #[tokio::test]
1401 async fn fetch_source_maps_unexpected_status_to_other() {
1402 let registry =
1403 LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(418)));
1404
1405 let err = registry.fetch_source("@org/teapot").await.unwrap_err();
1406
1407 assert_eq!(err.kind, RegistryErrorKind::Other);
1408 assert!(err.message.contains("HTTP 418"));
1409 }
1410
1411 #[tokio::test]
1412 async fn fetch_source_maps_network_error_to_network_error_kind() {
1413 let registry = LemmaBase::with_fetcher(Box::new(
1414 MockHttpFetcher::always_failing_with_network_error("connection refused"),
1415 ));
1416
1417 let err = registry.fetch_source("@org/unreachable").await.unwrap_err();
1418
1419 assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1420 assert!(
1421 err.message.contains("connection refused"),
1422 "Expected 'connection refused' in: {}",
1423 err.message
1424 );
1425 assert!(
1426 err.message.contains("@org/unreachable"),
1427 "Expected '@org/unreachable' in: {}",
1428 err.message
1429 );
1430 }
1431
1432 #[tokio::test]
1433 async fn fetch_source_maps_dns_error_to_network_error_kind() {
1434 let registry = LemmaBase::with_fetcher(Box::new(
1435 MockHttpFetcher::always_failing_with_network_error(
1436 "dns error: failed to lookup address",
1437 ),
1438 ));
1439
1440 let err = registry.fetch_source("@org/spec").await.unwrap_err();
1441
1442 assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1443 assert!(
1444 err.message.contains("dns error"),
1445 "Expected 'dns error' in: {}",
1446 err.message
1447 );
1448 assert!(
1449 err.message.contains("Failed to reach LemmaBase"),
1450 "Expected 'Failed to reach LemmaBase' in: {}",
1451 err.message
1452 );
1453 }
1454
1455 #[tokio::test]
1460 async fn get_delegates_to_fetch_source() {
1461 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1462 "spec org/resolved\ndata a: 1",
1463 )));
1464
1465 let bundle = registry.get("@org/resolved").await.unwrap();
1466
1467 assert_eq!(bundle.lemma_source, "spec org/resolved\ndata a: 1");
1468 assert_eq!(bundle.source_type.to_string(), "@org/resolved");
1469 }
1470
1471 #[tokio::test]
1472 async fn fetch_source_returns_empty_body_as_valid_bundle() {
1473 let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning("")));
1474
1475 let bundle = registry.fetch_source("@org/empty").await.unwrap();
1476
1477 assert_eq!(bundle.lemma_source, "");
1478 assert_eq!(bundle.source_type.to_string(), "@org/empty");
1479 }
1480 }
1481}