use crate::engine::Context;
use crate::error::Error;
use crate::limits::ResourceLimits;
use crate::parsing::ast::{
DataValue, DateTimeValue, LemmaRepository, RepositoryQualifier, SpecRef,
};
use crate::parsing::source::Source;
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct RegistryBundle {
pub lemma_source: String,
pub source_type: crate::parsing::source::SourceType,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegistryErrorKind {
NotFound,
Unauthorized,
NetworkError,
ServerError,
Other,
}
impl fmt::Display for RegistryErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotFound => write!(f, "not found"),
Self::Unauthorized => write!(f, "unauthorized"),
Self::NetworkError => write!(f, "network error"),
Self::ServerError => write!(f, "server error"),
Self::Other => write!(f, "error"),
}
}
}
#[derive(Debug, Clone)]
pub struct RegistryError {
pub message: String,
pub kind: RegistryErrorKind,
}
impl fmt::Display for RegistryError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}", self.message)
}
}
impl std::error::Error for RegistryError {}
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
pub trait Registry: Send + Sync {
async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError>;
fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String>;
}
#[cfg(feature = "registry")]
struct HttpFetchError {
status_code: Option<u16>,
message: String,
}
#[cfg(feature = "registry")]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
trait HttpFetcher: Send + Sync {
async fn get(&self, url: &str) -> Result<String, HttpFetchError>;
}
#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
struct ReqwestHttpFetcher;
#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
#[async_trait::async_trait]
impl HttpFetcher for ReqwestHttpFetcher {
async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
let response = reqwest::get(url).await.map_err(|e| HttpFetchError {
status_code: e.status().map(|s| s.as_u16()),
message: e.to_string(),
})?;
let status = response.status();
let body = response.text().await.map_err(|e| HttpFetchError {
status_code: None,
message: e.to_string(),
})?;
if !status.is_success() {
return Err(HttpFetchError {
status_code: Some(status.as_u16()),
message: format!("HTTP {}", status),
});
}
Ok(body)
}
}
#[cfg(all(feature = "registry", target_arch = "wasm32"))]
struct WasmHttpFetcher;
#[cfg(all(feature = "registry", target_arch = "wasm32"))]
#[async_trait::async_trait(?Send)]
impl HttpFetcher for WasmHttpFetcher {
async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
let response = gloo_net::http::Request::get(url)
.send()
.await
.map_err(|e| HttpFetchError {
status_code: None,
message: e.to_string(),
})?;
let status = response.status();
let ok = response.ok();
if !ok {
return Err(HttpFetchError {
status_code: Some(status),
message: format!("HTTP {}", status),
});
}
let text = response.text().await.map_err(|e| HttpFetchError {
status_code: None,
message: e.to_string(),
})?;
Ok(text)
}
}
#[cfg(feature = "registry")]
pub struct LemmaBase {
fetcher: Box<dyn HttpFetcher>,
}
#[cfg(feature = "registry")]
impl LemmaBase {
#[cfg(debug_assertions)]
pub const BASE_URL: &'static str = "http://localhost:4222";
#[cfg(not(debug_assertions))]
pub const BASE_URL: &'static str = "https://lemmabase.com";
pub fn new() -> Self {
Self {
#[cfg(not(target_arch = "wasm32"))]
fetcher: Box::new(ReqwestHttpFetcher),
#[cfg(target_arch = "wasm32")]
fetcher: Box::new(WasmHttpFetcher),
}
}
#[cfg(test)]
fn with_fetcher(fetcher: Box<dyn HttpFetcher>) -> Self {
Self { fetcher }
}
fn source_url(&self, name: &str, effective: Option<&DateTimeValue>) -> String {
let base = format!("{}/{}.lemma", Self::BASE_URL, name);
match effective {
None => base,
Some(d) => format!("{}?effective={}", base, d),
}
}
fn navigation_url(&self, name: &str, effective: Option<&DateTimeValue>) -> String {
let base = format!("{}/{}", Self::BASE_URL, name);
match effective {
None => base,
Some(d) => format!("{}?effective={}", base, d),
}
}
fn display_id(name: &str, effective: Option<&DateTimeValue>) -> String {
match effective {
None => name.to_string(),
Some(d) => format!("{name} {d}"),
}
}
async fn fetch_source(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
let url = self.source_url(name, None);
let display = Self::display_id(name, None);
let lemma_source = self.fetcher.get(&url).await.map_err(|error| {
if let Some(code) = error.status_code {
let kind = match code {
404 => RegistryErrorKind::NotFound,
401 | 403 => RegistryErrorKind::Unauthorized,
500..=599 => RegistryErrorKind::ServerError,
_ => RegistryErrorKind::Other,
};
RegistryError {
message: format!("LemmaBase returned HTTP {} {} for '{}'", code, url, display),
kind,
}
} else {
RegistryError {
message: format!(
"Failed to reach LemmaBase for '{}': {}",
display, error.message
),
kind: RegistryErrorKind::NetworkError,
}
}
})?;
Ok(RegistryBundle {
lemma_source,
source_type: crate::parsing::source::SourceType::Registry(Arc::new(
LemmaRepository::new(Some(name.to_string())),
)),
})
}
}
#[cfg(feature = "registry")]
impl Default for LemmaBase {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "registry")]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
impl Registry for LemmaBase {
async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
self.fetch_source(name).await
}
fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
Some(self.navigation_url(name, effective))
}
}
#[cfg(feature = "registry")]
pub async fn resolve_registry_references(
ctx: &mut Context,
sources: &mut HashMap<crate::parsing::source::SourceType, String>,
registry: &dyn Registry,
limits: &ResourceLimits,
) -> Result<(), Vec<Error>> {
let mut already_requested: HashSet<String> = HashSet::new();
loop {
let unresolved = find_missing_repositories(ctx, &already_requested);
if unresolved.is_empty() {
break;
}
let mut round_errors: Vec<Error> = Vec::new();
for reference in &unresolved {
if already_requested.contains(&reference.repository.name) {
continue;
}
already_requested.insert(reference.repository.name.clone());
let bundle_result = registry.get(&reference.repository.name).await;
let bundle = match bundle_result {
Ok(b) => b,
Err(registry_error) => {
let suggestion = match ®istry_error.kind {
RegistryErrorKind::NotFound => Some(
"Check that the repository qualifier is spelled correctly and that the repository exists on the registry.".to_string(),
),
RegistryErrorKind::Unauthorized => Some(
"Check your authentication credentials or permissions for this registry.".to_string(),
),
RegistryErrorKind::NetworkError => Some(
"Check your network connection. To compile without registry access, disable the 'registry' feature.".to_string(),
),
RegistryErrorKind::ServerError => Some(
"The registry server returned an internal error. Try again later.".to_string(),
),
RegistryErrorKind::Other => None,
};
let spec_context = ctx
.iter()
.find(|s| s.source_type == Some(reference.source.source_type.clone()));
round_errors.push(Error::registry(
registry_error.message,
reference.source.clone(),
reference.repository.name.clone(),
registry_error.kind,
suggestion,
spec_context,
None,
));
continue;
}
};
sources.insert(bundle.source_type.clone(), bundle.lemma_source.clone());
let parsed = match crate::parsing::parse(
&bundle.lemma_source,
bundle.source_type.clone(),
limits,
) {
Ok(result) => result,
Err(e) => {
round_errors.push(e);
return Err(round_errors);
}
};
for (parsed_repo, specs) in parsed.repositories {
let repo_name = parsed_repo
.name
.clone()
.unwrap_or_else(|| reference.repository.name.clone());
let header = LemmaRepository::new(Some(repo_name))
.with_dependency(reference.repository.name.clone())
.with_start_line(parsed_repo.start_line)
.with_source_type(bundle.source_type.clone());
let repository_arc = Arc::new(header);
for spec in specs {
if let Err(e) = ctx.insert_spec(Arc::clone(&repository_arc), Arc::new(spec)) {
round_errors.push(e);
}
}
}
}
if !round_errors.is_empty() {
return Err(round_errors);
}
}
Ok(())
}
#[derive(Debug, Clone)]
struct RegistryReference {
repository: RepositoryQualifier,
source: Source,
}
fn collect_repository_qualifiers_from_spec_ref(
spec_ref: &SpecRef,
source: &Source,
ctx: &Context,
already_requested: &HashSet<String>,
seen_in_this_round: &mut HashSet<String>,
out: &mut Vec<RegistryReference>,
) {
let Some(qualifier) = spec_ref.repository.as_ref() else {
return;
};
if ctx.find_repository(&qualifier.name).is_some() {
return;
}
if already_requested.contains(&qualifier.name) {
return;
}
if !seen_in_this_round.insert(qualifier.name.clone()) {
return;
}
out.push(RegistryReference {
repository: qualifier.clone(),
source: source.clone(),
});
}
fn find_missing_repositories(
ctx: &Context,
already_requested: &HashSet<String>,
) -> Vec<RegistryReference> {
let mut unresolved: Vec<RegistryReference> = Vec::new();
let mut seen_in_this_round: HashSet<String> = HashSet::new();
for spec in ctx.iter() {
let spec = spec.as_ref();
for data in &spec.data {
match &data.value {
DataValue::Import(spec_ref) => {
collect_repository_qualifiers_from_spec_ref(
spec_ref,
&data.source_location,
ctx,
already_requested,
&mut seen_in_this_round,
&mut unresolved,
);
}
DataValue::Definition {
from: Some(from_ref),
..
} => {
collect_repository_qualifiers_from_spec_ref(
from_ref,
&data.source_location,
ctx,
already_requested,
&mut seen_in_this_round,
&mut unresolved,
);
}
_ => {}
}
}
}
unresolved
}
#[cfg(test)]
mod tests {
use super::*;
struct TestRegistry {
bundles: HashMap<String, RegistryBundle>,
}
impl TestRegistry {
fn new() -> Self {
Self {
bundles: HashMap::new(),
}
}
fn add_spec_bundle(&mut self, identifier: &str, lemma_source: &str) {
self.bundles.insert(
identifier.to_string(),
RegistryBundle {
lemma_source: lemma_source.to_string(),
source_type: crate::parsing::source::SourceType::Registry(Arc::new(
LemmaRepository::new(Some(identifier.to_string())),
)),
},
);
}
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
impl Registry for TestRegistry {
async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
self.bundles
.get(name)
.cloned()
.ok_or_else(|| RegistryError {
message: format!("'{}' not found in test registry", name),
kind: RegistryErrorKind::NotFound,
})
}
fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
if self.bundles.contains_key(name) {
Some(match effective {
None => format!("https://test.registry/{}", name),
Some(d) => format!("https://test.registry/{}?effective={}", name, d),
})
} else {
None
}
}
}
#[tokio::test]
async fn resolve_with_no_registry_references_returns_local_specs_unchanged() {
let source = r#"spec example
data price: 100"#;
let local_specs = crate::parse(
source,
crate::parsing::source::SourceType::Volatile,
&ResourceLimits::default(),
)
.unwrap()
.into_flattened_specs();
let mut store = Context::new();
let local_repository = store.workspace();
for spec in &local_specs {
store
.insert_spec(Arc::clone(&local_repository), Arc::new(spec.clone()))
.unwrap();
}
let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
sources.insert(
crate::parsing::source::SourceType::Volatile,
source.to_string(),
);
let registry = TestRegistry::new();
resolve_registry_references(
&mut store,
&mut sources,
®istry,
&ResourceLimits::default(),
)
.await
.unwrap();
assert_eq!(store.len(), 1);
let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
assert_eq!(names, ["example"]);
}
#[tokio::test]
async fn resolve_fetches_single_spec_from_registry() {
let local_source = r#"spec main_spec
uses external: @org/project helper
rule value: external.quantity"#;
let local_specs = crate::parse(
local_source,
crate::parsing::source::SourceType::Volatile,
&ResourceLimits::default(),
)
.unwrap()
.into_flattened_specs();
let mut store = Context::new();
let local_repository = store.workspace();
for spec in local_specs {
store
.insert_spec(Arc::clone(&local_repository), Arc::new(spec))
.unwrap();
}
let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
sources.insert(
crate::parsing::source::SourceType::Volatile,
local_source.to_string(),
);
let mut registry = TestRegistry::new();
registry.add_spec_bundle(
"@org/project",
r#"repo @org/project
spec helper
data quantity: 42"#,
);
resolve_registry_references(
&mut store,
&mut sources,
®istry,
&ResourceLimits::default(),
)
.await
.unwrap();
assert_eq!(store.len(), 2);
let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
assert!(names.iter().any(|n| n == "main_spec"));
assert!(names.iter().any(|n| n == "helper"));
}
#[tokio::test]
async fn resolve_registry_bundle_without_repo_decl_uses_reference_repository_name() {
let local_source = r#"spec main_spec
uses external: @org/project helper
rule value: external.quantity"#;
let local_specs = crate::parse(
local_source,
crate::parsing::source::SourceType::Volatile,
&ResourceLimits::default(),
)
.unwrap()
.into_flattened_specs();
let mut store = Context::new();
let local_repository = store.workspace();
for spec in local_specs {
store
.insert_spec(Arc::clone(&local_repository), Arc::new(spec))
.unwrap();
}
let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
sources.insert(
crate::parsing::source::SourceType::Volatile,
local_source.to_string(),
);
let mut registry = TestRegistry::new();
registry.add_spec_bundle(
"@org/project",
r#"spec helper
data quantity: 42"#,
);
resolve_registry_references(
&mut store,
&mut sources,
®istry,
&ResourceLimits::default(),
)
.await
.unwrap();
let ext_repo = store
.find_repository("@org/project")
.expect("registry bundle must land under fetched @ id");
let spec_names: Vec<String> = store
.repositories()
.get(&ext_repo)
.expect("spec sets for @org/project")
.keys()
.cloned()
.collect();
assert!(
spec_names.iter().any(|n| n == "helper"),
"helper spec should live under @org/project, got {:?}",
spec_names
);
}
#[tokio::test]
async fn get_returns_all_zones_and_url_for_id_supports_effective() {
let effective = DateTimeValue {
year: 2026,
month: 1,
day: 15,
hour: 0,
minute: 0,
second: 0,
microsecond: 0,
timezone: None,
};
let mut registry = TestRegistry::new();
registry.add_spec_bundle(
"@org/spec",
"spec org/spec 2025-01-01\ndata x: 1\n\nspec org/spec 2026-01-15\ndata x: 2",
);
let bundle = registry.get("@org/spec").await.unwrap();
assert!(bundle.lemma_source.contains("data x: 1"));
assert!(bundle.lemma_source.contains("data x: 2"));
assert_eq!(
registry.url_for_id("@org/spec", None),
Some("https://test.registry/@org/spec".to_string())
);
assert_eq!(
registry.url_for_id("@org/spec", Some(&effective)),
Some("https://test.registry/@org/spec?effective=2026-01-15".to_string())
);
}
#[tokio::test]
async fn resolve_fetches_transitive_dependencies() {
let local_source = r#"spec main_spec
uses a: @org/project spec_a"#;
let local_specs = crate::parse(
local_source,
crate::parsing::source::SourceType::Volatile,
&ResourceLimits::default(),
)
.unwrap()
.into_flattened_specs();
let mut store = Context::new();
let local_repository = store.workspace();
for spec in local_specs {
store
.insert_spec(Arc::clone(&local_repository), Arc::new(spec))
.unwrap();
}
let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
sources.insert(
crate::parsing::source::SourceType::Volatile,
local_source.to_string(),
);
let mut registry = TestRegistry::new();
registry.add_spec_bundle(
"@org/project",
r#"repo @org/project
spec spec_a
uses b: @org/sub spec_b"#,
);
registry.add_spec_bundle(
"@org/sub",
r#"repo @org/sub
spec spec_b
data value: 99"#,
);
resolve_registry_references(
&mut store,
&mut sources,
®istry,
&ResourceLimits::default(),
)
.await
.unwrap();
assert_eq!(store.len(), 3);
let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
assert!(names.iter().any(|n| n == "main_spec"));
assert!(names.iter().any(|n| n == "spec_a"));
assert!(names.iter().any(|n| n == "spec_b"));
}
#[tokio::test]
async fn resolve_handles_bundle_with_multiple_specs() {
let local_source = r#"spec main_spec
uses a: @org/project spec_a"#;
let local_specs = crate::parse(
local_source,
crate::parsing::source::SourceType::Volatile,
&ResourceLimits::default(),
)
.unwrap()
.into_flattened_specs();
let mut store = Context::new();
let local_repository = store.workspace();
for spec in local_specs {
store
.insert_spec(Arc::clone(&local_repository), Arc::new(spec))
.unwrap();
}
let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
sources.insert(
crate::parsing::source::SourceType::Volatile,
local_source.to_string(),
);
let mut registry = TestRegistry::new();
registry.add_spec_bundle(
"@org/project",
r#"repo @org/project
spec spec_a
uses b: spec_b
spec spec_b
data value: 99"#,
);
resolve_registry_references(
&mut store,
&mut sources,
®istry,
&ResourceLimits::default(),
)
.await
.unwrap();
assert_eq!(store.len(), 3);
let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
assert!(names.iter().any(|n| n == "main_spec"));
assert!(names.iter().any(|n| n == "spec_a"));
assert!(names.iter().any(|n| n == "spec_b"));
}
#[tokio::test]
async fn resolve_returns_registry_error_when_registry_fails() {
let local_source = r#"spec main_spec
uses external: @org/project missing"#;
let local_specs = crate::parse(
local_source,
crate::parsing::source::SourceType::Volatile,
&ResourceLimits::default(),
)
.unwrap()
.into_flattened_specs();
let mut store = Context::new();
let local_repository = store.workspace();
for spec in local_specs {
store
.insert_spec(Arc::clone(&local_repository), Arc::new(spec))
.unwrap();
}
let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
sources.insert(
crate::parsing::source::SourceType::Volatile,
local_source.to_string(),
);
let registry = TestRegistry::new();
let result = resolve_registry_references(
&mut store,
&mut sources,
®istry,
&ResourceLimits::default(),
)
.await;
assert!(result.is_err(), "Should fail when Registry cannot resolve");
let errs = result.unwrap_err();
let registry_err = errs
.iter()
.find(|e| matches!(e, Error::Registry { .. }))
.expect("expected at least one Registry error");
match registry_err {
Error::Registry {
identifier,
kind,
details,
} => {
assert_eq!(identifier, "@org/project");
assert_eq!(*kind, RegistryErrorKind::NotFound);
assert!(
details.suggestion.is_some(),
"NotFound errors should include a suggestion"
);
}
_ => unreachable!(),
}
let error_message = errs
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(" ");
assert!(
error_message.contains("@org/project"),
"Error should mention the identifier: {}",
error_message
);
}
#[tokio::test]
async fn resolve_returns_all_registry_errors_when_multiple_repositorys_fail() {
let local_source = r#"spec main_spec
uses @org/example helper
data money: money from @lemma/std finance"#;
let local_specs = crate::parse(
local_source,
crate::parsing::source::SourceType::Volatile,
&ResourceLimits::default(),
)
.unwrap()
.into_flattened_specs();
let mut store = Context::new();
let local_repository = store.workspace();
for spec in local_specs {
store
.insert_spec(Arc::clone(&local_repository), Arc::new(spec))
.unwrap();
}
let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
sources.insert(
crate::parsing::source::SourceType::Volatile,
local_source.to_string(),
);
let registry = TestRegistry::new();
let result = resolve_registry_references(
&mut store,
&mut sources,
®istry,
&ResourceLimits::default(),
)
.await;
assert!(result.is_err(), "Should fail when Registry cannot resolve");
let errors = result.unwrap_err();
let identifiers: Vec<&str> = errors
.iter()
.filter_map(|e| {
if let Error::Registry { identifier, .. } = e {
Some(identifier.as_str())
} else {
None
}
})
.collect();
assert!(
identifiers.contains(&"@org/example"),
"Should include repository error: {:?}",
identifiers
);
assert!(
identifiers.contains(&"@lemma/std"),
"Should include data import repository error: {:?}",
identifiers
);
}
#[tokio::test]
async fn resolve_does_not_request_same_repository_twice() {
let local_source = r#"spec spec_one
uses a: @org/shared shared
spec spec_two
uses b: @org/shared shared"#;
let local_specs = crate::parse(
local_source,
crate::parsing::source::SourceType::Volatile,
&ResourceLimits::default(),
)
.unwrap()
.into_flattened_specs();
let mut store = Context::new();
let local_repository = store.workspace();
for spec in local_specs {
store
.insert_spec(Arc::clone(&local_repository), Arc::new(spec))
.unwrap();
}
let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
sources.insert(
crate::parsing::source::SourceType::Volatile,
local_source.to_string(),
);
let mut registry = TestRegistry::new();
registry.add_spec_bundle(
"@org/shared",
r#"repo @org/shared
spec shared
data value: 1"#,
);
resolve_registry_references(
&mut store,
&mut sources,
®istry,
&ResourceLimits::default(),
)
.await
.unwrap();
assert_eq!(store.len(), 3);
let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
assert!(names.iter().any(|n| n == "shared"));
}
#[tokio::test]
async fn resolve_handles_data_import_from_registry() {
let local_source = r#"spec main_spec
data money: money from @lemma/std finance
data price: money"#;
let local_specs = crate::parse(
local_source,
crate::parsing::source::SourceType::Volatile,
&ResourceLimits::default(),
)
.unwrap()
.into_flattened_specs();
let mut store = Context::new();
let local_repository = store.workspace();
for spec in local_specs {
store
.insert_spec(Arc::clone(&local_repository), Arc::new(spec))
.unwrap();
}
let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
sources.insert(
crate::parsing::source::SourceType::Volatile,
local_source.to_string(),
);
let mut registry = TestRegistry::new();
registry.add_spec_bundle(
"@lemma/std",
r#"repo @lemma/std
spec finance
data money: scale
-> unit eur 1.00
-> unit usd 1.10
-> decimals 2"#,
);
resolve_registry_references(
&mut store,
&mut sources,
®istry,
&ResourceLimits::default(),
)
.await
.unwrap();
assert_eq!(store.len(), 2);
let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
assert!(names.iter().any(|n| n == "main_spec"));
assert!(names.iter().any(|n| n == "finance"));
}
#[cfg(feature = "registry")]
mod lemmabase_tests {
use super::super::*;
use std::sync::{Arc, Mutex};
type HttpFetchHandler = Box<dyn Fn(&str) -> Result<String, HttpFetchError> + Send + Sync>;
struct MockHttpFetcher {
handler: HttpFetchHandler,
}
impl MockHttpFetcher {
fn with_handler(
handler: impl Fn(&str) -> Result<String, HttpFetchError> + Send + Sync + 'static,
) -> Self {
Self {
handler: Box::new(handler),
}
}
fn always_returning(body: &str) -> Self {
let body = body.to_string();
Self::with_handler(move |_| Ok(body.clone()))
}
fn always_failing_with_status(code: u16) -> Self {
Self::with_handler(move |_| {
Err(HttpFetchError {
status_code: Some(code),
message: format!("HTTP {}", code),
})
})
}
fn always_failing_with_network_error(msg: &str) -> Self {
let msg = msg.to_string();
Self::with_handler(move |_| {
Err(HttpFetchError {
status_code: None,
message: msg.clone(),
})
})
}
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
impl HttpFetcher for MockHttpFetcher {
async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
(self.handler)(url)
}
}
#[test]
fn source_url_without_effective() {
let registry = LemmaBase::new();
let url = registry.source_url("@user/workspace/somespec", None);
assert_eq!(
url,
format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
);
}
#[test]
fn source_url_with_effective() {
let registry = LemmaBase::new();
let effective = DateTimeValue {
year: 2026,
month: 1,
day: 15,
hour: 0,
minute: 0,
second: 0,
microsecond: 0,
timezone: None,
};
let url = registry.source_url("@user/workspace/somespec", Some(&effective));
assert_eq!(
url,
format!(
"{}/@user/workspace/somespec.lemma?effective=2026-01-15",
LemmaBase::BASE_URL
)
);
}
#[test]
fn source_url_for_deeply_nested_identifier() {
let registry = LemmaBase::new();
let url = registry.source_url("@org/team/project/subdir/spec", None);
assert_eq!(
url,
format!(
"{}/@org/team/project/subdir/spec.lemma",
LemmaBase::BASE_URL
)
);
}
#[test]
fn navigation_url_without_effective() {
let registry = LemmaBase::new();
let url = registry.navigation_url("@user/workspace/somespec", None);
assert_eq!(
url,
format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL)
);
}
#[test]
fn navigation_url_with_effective() {
let registry = LemmaBase::new();
let effective = DateTimeValue {
year: 2026,
month: 1,
day: 15,
hour: 0,
minute: 0,
second: 0,
microsecond: 0,
timezone: None,
};
let url = registry.navigation_url("@user/workspace/somespec", Some(&effective));
assert_eq!(
url,
format!(
"{}/@user/workspace/somespec?effective=2026-01-15",
LemmaBase::BASE_URL
)
);
}
#[test]
fn url_for_id_returns_navigation_url() {
let registry = LemmaBase::new();
let url = registry.url_for_id("@user/workspace/somespec", None);
assert_eq!(
url,
Some(format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL))
);
}
#[test]
fn url_for_id_with_effective() {
let registry = LemmaBase::new();
let effective = DateTimeValue {
year: 2026,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
microsecond: 0,
timezone: None,
};
let url = registry.url_for_id("@owner/repo/spec", Some(&effective));
assert_eq!(
url,
Some(format!(
"{}/@owner/repo/spec?effective=2026-01-01",
LemmaBase::BASE_URL
))
);
}
#[test]
fn url_for_id_returns_navigation_url_for_nested_path() {
let registry = LemmaBase::new();
let url = registry.url_for_id("@lemma/std/finance", None);
assert_eq!(
url,
Some(format!("{}/@lemma/std/finance", LemmaBase::BASE_URL))
);
}
#[tokio::test]
async fn fetch_source_returns_bundle_on_success() {
let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
"spec org/my_spec\ndata x: 1",
)));
let bundle = registry.fetch_source("@org/my_spec").await.unwrap();
assert_eq!(bundle.lemma_source, "spec org/my_spec\ndata x: 1");
assert_eq!(bundle.source_type.to_string(), "@org/my_spec");
}
#[tokio::test]
async fn fetch_source_passes_correct_url_to_fetcher() {
let captured_url = Arc::new(Mutex::new(String::new()));
let captured = captured_url.clone();
let mock = MockHttpFetcher::with_handler(move |url| {
*captured.lock().unwrap() = url.to_string();
Ok("spec test/spec\ndata x: 1".to_string())
});
let registry = LemmaBase::with_fetcher(Box::new(mock));
let _ = registry.fetch_source("@user/workspace/somespec").await;
assert_eq!(
*captured_url.lock().unwrap(),
format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
);
}
#[tokio::test]
async fn fetch_source_maps_http_404_to_not_found() {
let registry =
LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(404)));
let err = registry.fetch_source("@org/missing").await.unwrap_err();
assert_eq!(err.kind, RegistryErrorKind::NotFound);
assert!(
err.message.contains("HTTP 404"),
"Expected 'HTTP 404' in: {}",
err.message
);
assert!(
err.message.contains("@org/missing"),
"Expected '@org/missing' in: {}",
err.message
);
}
#[tokio::test]
async fn fetch_source_maps_http_500_to_server_error() {
let registry =
LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(500)));
let err = registry.fetch_source("@org/broken").await.unwrap_err();
assert_eq!(err.kind, RegistryErrorKind::ServerError);
assert!(
err.message.contains("HTTP 500"),
"Expected 'HTTP 500' in: {}",
err.message
);
}
#[tokio::test]
async fn fetch_source_maps_http_401_to_unauthorized() {
let registry =
LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(401)));
let err = registry.fetch_source("@org/secret").await.unwrap_err();
assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
assert!(err.message.contains("HTTP 401"));
}
#[tokio::test]
async fn fetch_source_maps_http_403_to_unauthorized() {
let registry =
LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(403)));
let err = registry.fetch_source("@org/private").await.unwrap_err();
assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
assert!(
err.message.contains("HTTP 403"),
"Expected 'HTTP 403' in: {}",
err.message
);
}
#[tokio::test]
async fn fetch_source_maps_unexpected_status_to_other() {
let registry =
LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(418)));
let err = registry.fetch_source("@org/teapot").await.unwrap_err();
assert_eq!(err.kind, RegistryErrorKind::Other);
assert!(err.message.contains("HTTP 418"));
}
#[tokio::test]
async fn fetch_source_maps_network_error_to_network_error_kind() {
let registry = LemmaBase::with_fetcher(Box::new(
MockHttpFetcher::always_failing_with_network_error("connection refused"),
));
let err = registry.fetch_source("@org/unreachable").await.unwrap_err();
assert_eq!(err.kind, RegistryErrorKind::NetworkError);
assert!(
err.message.contains("connection refused"),
"Expected 'connection refused' in: {}",
err.message
);
assert!(
err.message.contains("@org/unreachable"),
"Expected '@org/unreachable' in: {}",
err.message
);
}
#[tokio::test]
async fn fetch_source_maps_dns_error_to_network_error_kind() {
let registry = LemmaBase::with_fetcher(Box::new(
MockHttpFetcher::always_failing_with_network_error(
"dns error: failed to lookup address",
),
));
let err = registry.fetch_source("@org/spec").await.unwrap_err();
assert_eq!(err.kind, RegistryErrorKind::NetworkError);
assert!(
err.message.contains("dns error"),
"Expected 'dns error' in: {}",
err.message
);
assert!(
err.message.contains("Failed to reach LemmaBase"),
"Expected 'Failed to reach LemmaBase' in: {}",
err.message
);
}
#[tokio::test]
async fn get_delegates_to_fetch_source() {
let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
"spec org/resolved\ndata a: 1",
)));
let bundle = registry.get("@org/resolved").await.unwrap();
assert_eq!(bundle.lemma_source, "spec org/resolved\ndata a: 1");
assert_eq!(bundle.source_type.to_string(), "@org/resolved");
}
#[tokio::test]
async fn fetch_source_returns_empty_body_as_valid_bundle() {
let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning("")));
let bundle = registry.fetch_source("@org/empty").await.unwrap();
assert_eq!(bundle.lemma_source, "");
assert_eq!(bundle.source_type.to_string(), "@org/empty");
}
}
}