Skip to main content

lemma/
registry.rs

1//! Registry trait, types, and resolution logic for external repository references.
2//!
3//! A Registry maps repository identifiers to Lemma source text (for resolution)
4//! and to human-facing addresses (for editor navigation).
5//!
6//! The engine calls `resolve_registry_references` during the resolution step
7//! (after parsing local files, before planning) to fetch external specs.
8//! The Language Server calls `url_for_id` to produce clickable links.
9//!
10//! Input to all methods is the full repository name as it appears in source
11//! (e.g. `"@org/project"` including the `@` prefix).
12
13#[cfg(feature = "registry")]
14use crate::parsing::ast::DateTimeValue;
15#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
16use crate::parsing::ast::LemmaRepository;
17use std::fmt;
18#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
19use std::sync::Arc;
20
21#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
22use std::path::{Path, PathBuf};
23
24#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
25use {
26    crate::engine::Context,
27    crate::error::Error,
28    crate::limits::ResourceLimits,
29    crate::parsing::ast::{DataValue, RepositoryQualifier, SpecRef},
30    crate::parsing::source::Source,
31    std::collections::{HashMap, HashSet},
32};
33
34// ---------------------------------------------------------------------------
35// Trait and types
36// ---------------------------------------------------------------------------
37
38/// A bundle of Lemma source text returned by the Registry.
39///
40/// Contains one or more `spec ...` blocks as raw Lemma source code.
41#[cfg(feature = "registry")]
42#[derive(Debug, Clone)]
43pub struct RegistryBundle {
44    pub repository: String,
45    pub source: String,
46}
47
48/// The kind of failure that occurred during a Registry operation.
49///
50/// Registry implementations classify their errors into these kinds so that
51/// the engine (and ultimately the user) can distinguish between a missing
52/// spec, an authorization failure, a network outage, etc.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
54#[serde(rename_all = "snake_case")]
55pub enum RegistryErrorKind {
56    /// The requested spec or type was not found (e.g. HTTP 404).
57    NotFound,
58    /// The request was unauthorized or forbidden (e.g. HTTP 401, 403).
59    Unauthorized,
60    /// A network or transport error occurred (DNS failure, timeout, connection refused).
61    NetworkError,
62    /// The registry server returned an internal error (e.g. HTTP 5xx).
63    ServerError,
64    /// An error that does not fit the other categories.
65    Other,
66}
67
68impl fmt::Display for RegistryErrorKind {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            Self::NotFound => write!(f, "not found"),
72            Self::Unauthorized => write!(f, "unauthorized"),
73            Self::NetworkError => write!(f, "network error"),
74            Self::ServerError => write!(f, "server error"),
75            Self::Other => write!(f, "error"),
76        }
77    }
78}
79
80/// An error returned by a Registry implementation.
81#[cfg(feature = "registry")]
82#[derive(Debug, Clone)]
83pub struct RegistryError {
84    pub message: String,
85    pub kind: RegistryErrorKind,
86}
87
88#[cfg(feature = "registry")]
89impl fmt::Display for RegistryError {
90    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
91        write!(formatter, "{}", self.message)
92    }
93}
94
95#[cfg(feature = "registry")]
96impl std::error::Error for RegistryError {}
97
98/// Trait for resolving external repository references.
99///
100/// Implementations must be `Send + Sync` so they can be shared across threads.
101/// Resolution is async so that WASM can use `fetch()` and native can use async HTTP.
102///
103/// `get` returns a bundle containing ALL temporal versions for the requested
104/// identifier. The engine handles temporal resolution locally using
105/// `effective_from` on the parsed specs. Registry-qualified `uses`
106/// references and `uses`-backed type parents from specs share this resolution path.
107///
108/// `name` is the full repository name as it appears in source (e.g. `"@org/project"`).
109#[cfg(feature = "registry")]
110#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
111#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
112pub trait Registry: Send + Sync {
113    /// Fetch all temporal versions for a repository identifier.
114    ///
115    /// `name` is the full repository name (e.g. `"@org/project"`).
116    /// Returns a bundle whose `source` contains all temporal versions.
117    async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError>;
118
119    /// Map a repository identifier to a human-facing address for navigation.
120    ///
121    /// `name` is the full repository name (e.g. `"@org/project"`).
122    /// `effective` is an optional datetime for linking directly to a specific
123    /// temporal version in the registry UI.
124    fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String>;
125}
126
127// ---------------------------------------------------------------------------
128// LemmaBase: the default Registry implementation (feature-gated)
129// ---------------------------------------------------------------------------
130
131// Internal HTTP abstraction — async so we can use fetch() in WASM and reqwest on native.
132
133/// Error returned by the internal HTTP fetcher layer.
134///
135/// Separates HTTP status errors (4xx, 5xx) from transport / parsing errors
136/// so that `LemmaBase::fetch_source` can produce distinct error messages.
137#[cfg(feature = "registry")]
138struct HttpFetchError {
139    /// If the failure was an HTTP status code (4xx, 5xx), it is stored here.
140    status_code: Option<u16>,
141    /// Human-readable error description.
142    message: String,
143}
144
145/// Internal trait for performing async HTTP GET requests.
146///
147/// Native uses [`ReqwestHttpFetcher`]; WASM uses [`WasmHttpFetcher`]; tests inject a mock.
148#[cfg(feature = "registry")]
149#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
150#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
151trait HttpFetcher: Send + Sync {
152    async fn get(&self, url: &str) -> Result<String, HttpFetchError>;
153}
154
155/// Production HTTP fetcher for native (reqwest).
156#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
157struct ReqwestHttpFetcher;
158
159#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
160#[async_trait::async_trait]
161impl HttpFetcher for ReqwestHttpFetcher {
162    async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
163        let response = reqwest::get(url).await.map_err(|e| HttpFetchError {
164            status_code: e.status().map(|s| s.as_u16()),
165            message: e.to_string(),
166        })?;
167        let status = response.status();
168        let body = response.text().await.map_err(|e| HttpFetchError {
169            status_code: None,
170            message: e.to_string(),
171        })?;
172        if !status.is_success() {
173            return Err(HttpFetchError {
174                status_code: Some(status.as_u16()),
175                message: format!("HTTP {}", status),
176            });
177        }
178        Ok(body)
179    }
180}
181
182/// Production HTTP fetcher for WASM (gloo_net / fetch).
183#[cfg(all(feature = "registry", target_arch = "wasm32"))]
184struct WasmHttpFetcher;
185
186#[cfg(all(feature = "registry", target_arch = "wasm32"))]
187#[async_trait::async_trait(?Send)]
188impl HttpFetcher for WasmHttpFetcher {
189    async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
190        let response = gloo_net::http::Request::get(url)
191            .send()
192            .await
193            .map_err(|e| HttpFetchError {
194                status_code: None,
195                message: e.to_string(),
196            })?;
197        let status = response.status();
198        let ok = response.ok();
199        if !ok {
200            return Err(HttpFetchError {
201                status_code: Some(status),
202                message: format!("HTTP {}", status),
203            });
204        }
205        let text = response.text().await.map_err(|e| HttpFetchError {
206            status_code: None,
207            message: e.to_string(),
208        })?;
209        Ok(text)
210    }
211}
212
213// ---------------------------------------------------------------------------
214
215/// Parse `{base}/{identifier}.lemma` URLs into registry identifiers (e.g. `@iso/countries`).
216#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
217fn registry_identifier_from_source_url(url: &str) -> Option<String> {
218    let without_suffix = url.strip_suffix(".lemma")?;
219    let path = without_suffix
220        .split_once("://")
221        .map_or(without_suffix, |(_, rest)| {
222            rest.split_once('/').map_or(rest, |(_, p)| p)
223        });
224    if path.is_empty() {
225        None
226    } else {
227        Some(path.to_string())
228    }
229}
230
231/// Serves registry bundles from a `lemma_deps/`-shaped fixture directory (no network).
232#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
233struct FixtureDirFetcher {
234    fixtures: std::collections::HashMap<String, String>,
235}
236
237#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
238impl FixtureDirFetcher {
239    fn from_dir(dir: &Path) -> Self {
240        let mut fixtures = std::collections::HashMap::new();
241        collect_fixture_files(dir, dir, &mut fixtures);
242        Self { fixtures }
243    }
244}
245
246#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
247fn collect_fixture_files(
248    dir: &Path,
249    base: &Path,
250    fixtures: &mut std::collections::HashMap<String, String>,
251) {
252    let entries = std::fs::read_dir(dir)
253        .unwrap_or_else(|e| panic!("BUG: read fixture dir {}: {e}", dir.display()));
254    for entry in entries {
255        let entry =
256            entry.unwrap_or_else(|e| panic!("BUG: fixture dir entry in {}: {e}", dir.display()));
257        let path = entry.path();
258        if path.is_dir() {
259            collect_fixture_files(&path, base, fixtures);
260            continue;
261        }
262        if path.extension().is_none_or(|e| e != "lemma") {
263            continue;
264        }
265        let relative = path
266            .strip_prefix(base)
267            .unwrap_or_else(|_| panic!("BUG: fixture path not under base: {}", path.display()));
268        let identifier = relative
269            .with_extension("")
270            .to_string_lossy()
271            .replace('\\', "/");
272        let content = std::fs::read_to_string(&path)
273            .unwrap_or_else(|e| panic!("BUG: read fixture {}: {e}", path.display()));
274        fixtures.insert(identifier, content);
275    }
276}
277
278#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
279#[async_trait::async_trait]
280impl HttpFetcher for FixtureDirFetcher {
281    async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
282        let identifier =
283            registry_identifier_from_source_url(url).ok_or_else(|| HttpFetchError {
284                status_code: None,
285                message: format!("fixture URL must end with .lemma: {url}"),
286            })?;
287        self.fixtures
288            .get(&identifier)
289            .cloned()
290            .ok_or_else(|| HttpFetchError {
291                status_code: Some(404),
292                message: format!("no fixture for \"{identifier}\" (url {url})"),
293            })
294    }
295}
296
297// ---------------------------------------------------------------------------
298
299/// The LemmaBase registry fetches Lemma source text from LemmaBase.
300///
301/// This is the default registry for the Lemma engine. It resolves `@...` identifiers
302/// via `GET {base}/{name}.lemma` (`name` includes the leading `@`). The base depends on compile profile:
303/// [`LemmaBase::BASE_URL`] (`http://localhost:4222` in debug builds,
304/// `https://lemmabase.com` in release builds).
305///
306/// LemmaBase.com returns the requested spec with all of its dependencies inlined,
307/// so the resolution loop typically completes in a single iteration.
308///
309/// This struct is only available when the `registry` feature is enabled (which it is
310/// by default). Users who require strict sandboxing (no network access) can compile
311/// without this feature.
312#[cfg(feature = "registry")]
313pub struct LemmaBase {
314    fetcher: Box<dyn HttpFetcher>,
315}
316
317#[cfg(feature = "registry")]
318impl LemmaBase {
319    /// LemmaBase registry root: `http://localhost:4222` when `debug_assertions` are on
320    /// (normal `cargo build` / `cargo run`), `https://lemmabase.com` in `--release`.
321    ///
322    /// Same rule for any crate embedding this one (CLI, LSP, WASM) at that profile.
323    #[cfg(debug_assertions)]
324    pub const BASE_URL: &'static str = "http://localhost:4222";
325    #[cfg(not(debug_assertions))]
326    pub const BASE_URL: &'static str = "https://lemmabase.com";
327
328    /// Create a new LemmaBase registry backed by the real HTTP client (reqwest on native, fetch on WASM).
329    pub fn new() -> Self {
330        Self {
331            #[cfg(not(target_arch = "wasm32"))]
332            fetcher: Box::new(ReqwestHttpFetcher),
333            #[cfg(target_arch = "wasm32")]
334            fetcher: Box::new(WasmHttpFetcher),
335        }
336    }
337
338    /// Offline registry backed by [`Self::test_fixtures_dir`] (no network).
339    ///
340    /// Integration tests and local runs use bundled fixtures under
341    /// `engine/tests/registry_fixtures/` (`@iso/countries`, …).
342    #[cfg(not(target_arch = "wasm32"))]
343    pub fn test() -> Self {
344        Self::with_fixture_dir(Self::test_fixtures_dir())
345    }
346
347    /// Directory of bundled registry fixtures shipped with `lemma-engine`.
348    #[cfg(not(target_arch = "wasm32"))]
349    pub fn test_fixtures_dir() -> PathBuf {
350        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/registry_fixtures")
351    }
352
353    /// Offline registry reading `lemma_deps/`-shaped `.lemma` files from `dir`.
354    #[cfg(not(target_arch = "wasm32"))]
355    pub fn with_fixture_dir(dir: impl AsRef<Path>) -> Self {
356        Self {
357            fetcher: Box::new(FixtureDirFetcher::from_dir(dir.as_ref())),
358        }
359    }
360
361    /// Base URL for the spec; when effective is set, appends ?effective=... for temporal resolution.
362    fn source_url(
363        &self,
364        name: &str,
365        effective: Option<&DateTimeValue>,
366    ) -> Result<String, RegistryError> {
367        if !name.starts_with('@') {
368            return Err(RegistryError {
369                message: format!("Registry identifier must start with '@' (got '{name}')"),
370                kind: RegistryErrorKind::Other,
371            });
372        }
373        let base = format!("{}/{}.lemma", Self::BASE_URL, name);
374        Ok(match effective {
375            None => base,
376            Some(d) => format!("{}?effective={}", base, d),
377        })
378    }
379
380    /// Human-facing URL for navigation; when effective is set, appends ?effective=... for linking to a specific temporal version.
381    fn navigation_url(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
382        if !name.starts_with('@') {
383            return None;
384        }
385        let base = format!("{}/{}", Self::BASE_URL, name);
386        Some(match effective {
387            None => base,
388            Some(d) => format!("{}?effective={}", base, d),
389        })
390    }
391
392    fn display_id(name: &str, effective: Option<&DateTimeValue>) -> String {
393        match effective {
394            None => name.to_string(),
395            Some(d) => format!("{name} {d}"),
396        }
397    }
398
399    /// Fetch all zones for the given identifier (no temporal filtering).
400    async fn fetch_source(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
401        let url = self.source_url(name, None)?;
402        let display = Self::display_id(name, None);
403
404        let source = self.fetcher.get(&url).await.map_err(|error| {
405            if let Some(code) = error.status_code {
406                let kind = match code {
407                    404 => RegistryErrorKind::NotFound,
408                    401 | 403 => RegistryErrorKind::Unauthorized,
409                    500..=599 => RegistryErrorKind::ServerError,
410                    _ => RegistryErrorKind::Other,
411                };
412                RegistryError {
413                    message: format!("LemmaBase returned HTTP {} {} for '{}'", code, url, display),
414                    kind,
415                }
416            } else {
417                RegistryError {
418                    message: format!(
419                        "Failed to reach LemmaBase for '{}': {}",
420                        display, error.message
421                    ),
422                    kind: RegistryErrorKind::NetworkError,
423                }
424            }
425        })?;
426
427        Ok(RegistryBundle {
428            repository: name.to_string(),
429            source,
430        })
431    }
432}
433
434#[cfg(feature = "registry")]
435impl Default for LemmaBase {
436    fn default() -> Self {
437        Self::new()
438    }
439}
440
441#[cfg(feature = "registry")]
442#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
443#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
444impl Registry for LemmaBase {
445    async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
446        self.fetch_source(name).await
447    }
448
449    fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
450        self.navigation_url(name, effective)
451    }
452}
453
454// ---------------------------------------------------------------------------
455// Resolution: fetching external `@...` specs from a Registry
456// ---------------------------------------------------------------------------
457
458/// Resolve every `uses` reference that carries a registry repository qualifier in the loaded specs.
459///
460/// Starting from the already-parsed local specs, this function:
461/// 1. Collects every distinct registry repository qualifier referenced by the specs.
462/// 2. For each repository qualifier not already loaded into `ctx`, calls the Registry.
463/// 3. Parses the returned source text and inserts every spec from the bundle
464///    under the registry [`LemmaRepository`] for that fetch (using each reference's
465///    [`crate::parsing::ast::SpecRef::repository`] qualifier when present).
466/// 4. Recurses: the newly inserted specs may themselves reference further
467///    registry repositories.
468/// 5. Repeats until no unresolved repository qualifiers remain.
469///
470/// Errors are fatal: any registry failure or any unresolved qualifier produces
471/// errors that are returned to the caller without partial loads being silently
472/// retained.
473#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
474pub async fn resolve_registry_references(
475    ctx: &mut Context,
476    sources: &mut HashMap<crate::parsing::source::SourceType, String>,
477    registry: &dyn Registry,
478    limits: &ResourceLimits,
479) -> Result<(), Vec<Error>> {
480    let mut already_requested: HashSet<String> = HashSet::new();
481
482    loop {
483        let unresolved = find_missing_repositories(ctx, &already_requested);
484
485        if unresolved.is_empty() {
486            break;
487        }
488
489        let mut round_errors: Vec<Error> = Vec::new();
490        for reference in &unresolved {
491            if already_requested.contains(&reference.repository.name) {
492                continue;
493            }
494            already_requested.insert(reference.repository.name.clone());
495
496            let bundle_result = registry.get(&reference.repository.name).await;
497
498            let dependency = match bundle_result {
499                Ok(d) => d,
500                Err(registry_error) => {
501                    let suggestion = match &registry_error.kind {
502                        RegistryErrorKind::NotFound => Some(
503                            "Check that the repository qualifier is spelled correctly and that the repository exists on the registry.".to_string(),
504                        ),
505                        RegistryErrorKind::Unauthorized => Some(
506                            "Check your authentication credentials or permissions for this registry.".to_string(),
507                        ),
508                        RegistryErrorKind::NetworkError => Some(
509                            "Check your network connection. To compile without registry access, disable the 'registry' feature.".to_string(),
510                        ),
511                        RegistryErrorKind::ServerError => Some(
512                            "The registry server returned an internal error. Try again later.".to_string(),
513                        ),
514                        RegistryErrorKind::Other => None,
515                    };
516                    let spec_context = ctx
517                        .iter()
518                        .find(|s| s.source_type == Some(reference.source.source_type.clone()));
519                    round_errors.push(Error::registry(
520                        registry_error.message,
521                        reference.source.clone(),
522                        reference.repository.name.clone(),
523                        registry_error.kind,
524                        suggestion,
525                        spec_context,
526                        None,
527                    ));
528                    continue;
529                }
530            };
531
532            let source_type =
533                crate::parsing::source::SourceType::Dependency(dependency.repository.clone());
534            sources.insert(source_type.clone(), dependency.source.clone());
535
536            let parsed =
537                match crate::parsing::parse(&dependency.source, source_type.clone(), limits) {
538                    Ok(result) => result,
539                    Err(e) => {
540                        round_errors.push(e);
541                        return Err(round_errors);
542                    }
543                };
544
545            for (parsed_repo, specs) in parsed.repositories {
546                let repo_name = parsed_repo
547                    .name
548                    .clone()
549                    .unwrap_or_else(|| reference.repository.name.clone());
550                let dep_id = reference.repository.name.clone();
551                let header = LemmaRepository::new(Some(repo_name))
552                    .with_dependency(dep_id.clone())
553                    .with_start_line(parsed_repo.start_line)
554                    .with_source_type(source_type.clone());
555                let repository_arc = Arc::new(header);
556
557                for spec in specs {
558                    if let Err(e) = ctx.insert_spec(Arc::clone(&repository_arc), spec) {
559                        round_errors.push(e);
560                    }
561                }
562            }
563        }
564
565        if !round_errors.is_empty() {
566            return Err(round_errors);
567        }
568    }
569
570    Ok(())
571}
572
573/// A collected registry repository reference needing fetch.
574#[derive(Debug, Clone)]
575#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
576struct RegistryReference {
577    repository: RepositoryQualifier,
578    source: Source,
579}
580
581#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
582fn collect_repository_qualifiers_from_spec_ref(
583    spec_ref: &SpecRef,
584    source: &Source,
585    ctx: &Context,
586    already_requested: &HashSet<String>,
587    seen_in_this_round: &mut HashSet<String>,
588    out: &mut Vec<RegistryReference>,
589) {
590    let Some(qualifier) = spec_ref.repository.as_ref() else {
591        return;
592    };
593    if !qualifier.is_registry() {
594        return;
595    }
596    if ctx.find_repository(&qualifier.name).is_some() {
597        return;
598    }
599    if already_requested.contains(&qualifier.name) {
600        return;
601    }
602    if !seen_in_this_round.insert(qualifier.name.clone()) {
603        return;
604    }
605    out.push(RegistryReference {
606        repository: qualifier.clone(),
607        source: source.clone(),
608    });
609}
610
611/// Collect every distinct registry repository qualifier referenced by specs in `ctx`.
612#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
613fn find_missing_repositories(
614    ctx: &Context,
615    already_requested: &HashSet<String>,
616) -> Vec<RegistryReference> {
617    let mut unresolved: Vec<RegistryReference> = Vec::new();
618    let mut seen_in_this_round: HashSet<String> = HashSet::new();
619
620    for spec in ctx.iter() {
621        for data in &spec.data {
622            // `uses <repository> <spec>`
623            if let DataValue::Import(spec_ref) = &data.value {
624                collect_repository_qualifiers_from_spec_ref(
625                    spec_ref,
626                    &data.source_location,
627                    ctx,
628                    already_requested,
629                    &mut seen_in_this_round,
630                    &mut unresolved,
631                );
632            }
633        }
634    }
635
636    unresolved
637}
638
639// ---------------------------------------------------------------------------
640// Tests
641// ---------------------------------------------------------------------------
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use crate::engine::Context;
647    use crate::literals::DateGranularity;
648
649    /// A test Registry that returns predefined bundles keyed by name.
650    struct TestRegistry {
651        bundles: HashMap<String, RegistryBundle>,
652    }
653
654    impl TestRegistry {
655        fn new() -> Self {
656            Self {
657                bundles: HashMap::new(),
658            }
659        }
660
661        /// Add a bundle containing all zones for this identifier (e.g. `"@org/repo"`).
662        fn add_spec_bundle(&mut self, identifier: &str, source: &str) {
663            self.bundles.insert(
664                identifier.to_string(),
665                RegistryBundle {
666                    repository: identifier.to_string(),
667                    source: source.to_string(),
668                },
669            );
670        }
671    }
672
673    #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
674    #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
675    impl Registry for TestRegistry {
676        async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
677            self.bundles
678                .get(name)
679                .cloned()
680                .ok_or_else(|| RegistryError {
681                    message: format!("'{}' not found in test registry", name),
682                    kind: RegistryErrorKind::NotFound,
683                })
684        }
685
686        fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
687            if self.bundles.contains_key(name) {
688                Some(match effective {
689                    None => format!("https://test.registry/{}", name),
690                    Some(d) => format!("https://test.registry/{}?effective={}", name, d),
691                })
692            } else {
693                None
694            }
695        }
696    }
697
698    fn context_with_embedded_stdlib() -> Context {
699        use crate::engine::EMBEDDED_STDLIB_REPOSITORY;
700        use crate::parsing::ast::LemmaRepository;
701        use crate::parsing::source::SourceType;
702        use crate::stdlib::UNITS_LEMMA;
703
704        let mut ctx = Context::new();
705        let source_type = SourceType::Dependency(EMBEDDED_STDLIB_REPOSITORY.to_string());
706        let parsed = crate::parse(UNITS_LEMMA, source_type, &ResourceLimits::default())
707            .expect("BUG: embedded stdlib must parse");
708        for (parsed_repo, specs) in &parsed.repositories {
709            let repository_arc = Arc::new(
710                LemmaRepository::new(
711                    parsed_repo
712                        .name
713                        .clone()
714                        .or_else(|| Some(EMBEDDED_STDLIB_REPOSITORY.to_string())),
715                )
716                .with_dependency(EMBEDDED_STDLIB_REPOSITORY)
717                .with_start_line(parsed_repo.start_line),
718            );
719            for spec in specs {
720                ctx.insert_spec(Arc::clone(&repository_arc), spec.clone())
721                    .expect("BUG: embedded stdlib must load");
722            }
723        }
724        ctx
725    }
726
727    #[tokio::test(flavor = "current_thread")]
728    async fn resolve_with_no_registry_references_returns_local_specs_unchanged() {
729        let source = r#"spec example
730data price: 100"#;
731        let local_specs = crate::parse(
732            source,
733            crate::parsing::source::SourceType::Volatile,
734            &ResourceLimits::default(),
735        )
736        .unwrap()
737        .into_flattened_specs();
738        let mut store = context_with_embedded_stdlib();
739        let local_repository = store.workspace();
740        for spec in &local_specs {
741            store
742                .insert_spec(Arc::clone(&local_repository), spec.clone())
743                .unwrap();
744        }
745        let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
746        sources.insert(
747            crate::parsing::source::SourceType::Volatile,
748            source.to_string(),
749        );
750
751        let registry = TestRegistry::new();
752        resolve_registry_references(
753            &mut store,
754            &mut sources,
755            &registry,
756            &ResourceLimits::default(),
757        )
758        .await
759        .unwrap();
760
761        assert_eq!(
762            store.iter().count(),
763            2,
764            "embedded spec units plus workspace example"
765        );
766        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
767        assert!(names.iter().any(|n| n == "example"));
768        assert!(names.iter().any(|n| n == "units"));
769    }
770
771    /// Mirrors `lemma install --all`: bare `Context::new()` without embedded stdlib.
772    #[tokio::test(flavor = "current_thread")]
773    async fn resolve_does_not_fetch_non_at_qualified_repositories() {
774        let local_source = r#"spec burn_baby_burn
775uses lemma units
776rule x: 1 hour"#;
777        let local_specs = crate::parse(
778            local_source,
779            crate::parsing::source::SourceType::Volatile,
780            &ResourceLimits::default(),
781        )
782        .unwrap()
783        .into_flattened_specs();
784        let mut store = Context::new();
785        let local_repository = store.workspace();
786        for spec in local_specs {
787            store
788                .insert_spec(Arc::clone(&local_repository), spec)
789                .unwrap();
790        }
791        let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
792        sources.insert(
793            crate::parsing::source::SourceType::Volatile,
794            local_source.to_string(),
795        );
796
797        let registry = TestRegistry::new();
798        let result = resolve_registry_references(
799            &mut store,
800            &mut sources,
801            &registry,
802            &ResourceLimits::default(),
803        )
804        .await;
805
806        assert!(
807            result.is_ok(),
808            "non-@ repository qualifiers must not be sent to the registry, got: {:?}",
809            result.err()
810        );
811    }
812
813    #[tokio::test(flavor = "current_thread")]
814    async fn resolve_fetches_single_spec_from_registry() {
815        let local_source = r#"spec main_spec
816uses external: @org/project helper
817rule value: external.quantity"#;
818        let local_specs = crate::parse(
819            local_source,
820            crate::parsing::source::SourceType::Volatile,
821            &ResourceLimits::default(),
822        )
823        .unwrap()
824        .into_flattened_specs();
825        let mut store = context_with_embedded_stdlib();
826        let local_repository = store.workspace();
827        for spec in local_specs {
828            store
829                .insert_spec(Arc::clone(&local_repository), spec)
830                .unwrap();
831        }
832        let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
833        sources.insert(
834            crate::parsing::source::SourceType::Volatile,
835            local_source.to_string(),
836        );
837
838        let mut registry = TestRegistry::new();
839        registry.add_spec_bundle(
840            "@org/project",
841            r#"repo @org/project
842spec helper
843data quantity: 42"#,
844        );
845
846        resolve_registry_references(
847            &mut store,
848            &mut sources,
849            &registry,
850            &ResourceLimits::default(),
851        )
852        .await
853        .unwrap();
854
855        assert_eq!(store.iter().count(), 3);
856        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
857        assert!(names.iter().any(|n| n == "main_spec"));
858        assert!(names.iter().any(|n| n == "helper"));
859        assert!(names.iter().any(|n| n == "units"));
860    }
861
862    #[tokio::test(flavor = "current_thread")]
863    async fn resolve_registry_bundle_without_repo_decl_uses_reference_repository_name() {
864        let local_source = r#"spec main_spec
865uses external: @org/project helper
866rule value: external.quantity"#;
867        let local_specs = crate::parse(
868            local_source,
869            crate::parsing::source::SourceType::Volatile,
870            &ResourceLimits::default(),
871        )
872        .unwrap()
873        .into_flattened_specs();
874        let mut store = context_with_embedded_stdlib();
875        let local_repository = store.workspace();
876        for spec in local_specs {
877            store
878                .insert_spec(Arc::clone(&local_repository), spec)
879                .unwrap();
880        }
881        let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
882        sources.insert(
883            crate::parsing::source::SourceType::Volatile,
884            local_source.to_string(),
885        );
886
887        let mut registry = TestRegistry::new();
888        registry.add_spec_bundle(
889            "@org/project",
890            r#"spec helper
891data quantity: 42"#,
892        );
893
894        resolve_registry_references(
895            &mut store,
896            &mut sources,
897            &registry,
898            &ResourceLimits::default(),
899        )
900        .await
901        .unwrap();
902
903        let ext_repo = store
904            .find_repository("@org/project")
905            .expect("registry bundle must land under fetched @ id");
906        let spec_names: Vec<String> = store
907            .repositories()
908            .get(&ext_repo)
909            .expect("spec sets for @org/project")
910            .keys()
911            .cloned()
912            .collect();
913        assert!(
914            spec_names.iter().any(|n| n == "helper"),
915            "helper spec should live under @org/project, got {:?}",
916            spec_names
917        );
918    }
919
920    #[tokio::test(flavor = "current_thread")]
921    async fn get_returns_all_zones_and_url_for_id_supports_effective() {
922        let effective = DateTimeValue {
923            year: 2026,
924            month: 1,
925            day: 15,
926            hour: 0,
927            minute: 0,
928            second: 0,
929            microsecond: 0,
930            timezone: None,
931
932            granularity: DateGranularity::Full,
933        };
934        let mut registry = TestRegistry::new();
935        registry.add_spec_bundle(
936            "@org/spec",
937            "spec org/spec 2025-01-01\ndata x: 1\n\nspec org/spec 2026-01-15\ndata x: 2",
938        );
939
940        let bundle = registry.get("@org/spec").await.unwrap();
941        assert!(bundle.source.contains("data x: 1"));
942        assert!(bundle.source.contains("data x: 2"));
943
944        assert_eq!(
945            registry.url_for_id("@org/spec", None),
946            Some("https://test.registry/@org/spec".to_string())
947        );
948        assert_eq!(
949            registry.url_for_id("@org/spec", Some(&effective)),
950            Some("https://test.registry/@org/spec?effective=2026-01-15".to_string())
951        );
952    }
953
954    #[tokio::test(flavor = "current_thread")]
955    async fn resolve_fetches_transitive_dependencies() {
956        let local_source = r#"spec main_spec
957uses a: @org/project spec_a"#;
958        let local_specs = crate::parse(
959            local_source,
960            crate::parsing::source::SourceType::Volatile,
961            &ResourceLimits::default(),
962        )
963        .unwrap()
964        .into_flattened_specs();
965        let mut store = context_with_embedded_stdlib();
966        let local_repository = store.workspace();
967        for spec in local_specs {
968            store
969                .insert_spec(Arc::clone(&local_repository), spec)
970                .unwrap();
971        }
972        let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
973        sources.insert(
974            crate::parsing::source::SourceType::Volatile,
975            local_source.to_string(),
976        );
977
978        let mut registry = TestRegistry::new();
979        registry.add_spec_bundle(
980            "@org/project",
981            r#"repo @org/project
982spec spec_a
983uses b: @org/sub spec_b"#,
984        );
985        registry.add_spec_bundle(
986            "@org/sub",
987            r#"repo @org/sub
988spec spec_b
989data value: 99"#,
990        );
991
992        resolve_registry_references(
993            &mut store,
994            &mut sources,
995            &registry,
996            &ResourceLimits::default(),
997        )
998        .await
999        .unwrap();
1000
1001        assert_eq!(store.iter().count(), 4);
1002        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1003        assert!(names.iter().any(|n| n == "main_spec"));
1004        assert!(names.iter().any(|n| n == "spec_a"));
1005        assert!(names.iter().any(|n| n == "spec_b"));
1006        assert!(names.iter().any(|n| n == "units"));
1007    }
1008
1009    #[tokio::test(flavor = "current_thread")]
1010    async fn resolve_handles_bundle_with_multiple_specs() {
1011        let local_source = r#"spec main_spec
1012uses a: @org/project spec_a"#;
1013        let local_specs = crate::parse(
1014            local_source,
1015            crate::parsing::source::SourceType::Volatile,
1016            &ResourceLimits::default(),
1017        )
1018        .unwrap()
1019        .into_flattened_specs();
1020        let mut store = context_with_embedded_stdlib();
1021        let local_repository = store.workspace();
1022        for spec in local_specs {
1023            store
1024                .insert_spec(Arc::clone(&local_repository), spec)
1025                .unwrap();
1026        }
1027        let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
1028        sources.insert(
1029            crate::parsing::source::SourceType::Volatile,
1030            local_source.to_string(),
1031        );
1032
1033        let mut registry = TestRegistry::new();
1034        registry.add_spec_bundle(
1035            "@org/project",
1036            r#"repo @org/project
1037spec spec_a
1038uses b: spec_b
1039
1040spec spec_b
1041data value: 99"#,
1042        );
1043
1044        resolve_registry_references(
1045            &mut store,
1046            &mut sources,
1047            &registry,
1048            &ResourceLimits::default(),
1049        )
1050        .await
1051        .unwrap();
1052
1053        assert_eq!(store.iter().count(), 4);
1054        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1055        assert!(names.iter().any(|n| n == "main_spec"));
1056        assert!(names.iter().any(|n| n == "spec_a"));
1057        assert!(names.iter().any(|n| n == "spec_b"));
1058        assert!(names.iter().any(|n| n == "units"));
1059    }
1060
1061    #[tokio::test(flavor = "current_thread")]
1062    async fn resolve_returns_registry_error_when_registry_fails() {
1063        let local_source = r#"spec main_spec
1064uses external: @org/project missing"#;
1065        let local_specs = crate::parse(
1066            local_source,
1067            crate::parsing::source::SourceType::Volatile,
1068            &ResourceLimits::default(),
1069        )
1070        .unwrap()
1071        .into_flattened_specs();
1072        let mut store = context_with_embedded_stdlib();
1073        let local_repository = store.workspace();
1074        for spec in local_specs {
1075            store
1076                .insert_spec(Arc::clone(&local_repository), 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 registry = TestRegistry::new(); // empty — no bundles
1086
1087        let result = resolve_registry_references(
1088            &mut store,
1089            &mut sources,
1090            &registry,
1091            &ResourceLimits::default(),
1092        )
1093        .await;
1094
1095        assert!(result.is_err(), "Should fail when Registry cannot resolve");
1096        let errs = result.unwrap_err();
1097        let registry_err = errs
1098            .iter()
1099            .find(|e| matches!(e, Error::Registry { .. }))
1100            .expect("expected at least one Registry error");
1101        match registry_err {
1102            Error::Registry {
1103                identifier,
1104                kind,
1105                details,
1106            } => {
1107                assert_eq!(identifier, "@org/project");
1108                assert_eq!(*kind, RegistryErrorKind::NotFound);
1109                assert!(
1110                    details.suggestion.is_some(),
1111                    "NotFound errors should include a suggestion"
1112                );
1113            }
1114            _ => unreachable!(),
1115        }
1116
1117        let error_message = errs
1118            .iter()
1119            .map(|e| e.to_string())
1120            .collect::<Vec<_>>()
1121            .join(" ");
1122        assert!(
1123            error_message.contains("@org/project"),
1124            "Error should mention the identifier: {}",
1125            error_message
1126        );
1127    }
1128
1129    #[tokio::test(flavor = "current_thread")]
1130    async fn resolve_returns_all_registry_errors_when_multiple_repositorys_fail() {
1131        let local_source = r#"spec main_spec
1132uses @org/example helper
1133uses @iso/countries alpha2
1134data country: alpha2.code"#;
1135        let local_specs = crate::parse(
1136            local_source,
1137            crate::parsing::source::SourceType::Volatile,
1138            &ResourceLimits::default(),
1139        )
1140        .unwrap()
1141        .into_flattened_specs();
1142        let mut store = context_with_embedded_stdlib();
1143        let local_repository = store.workspace();
1144        for spec in local_specs {
1145            store
1146                .insert_spec(Arc::clone(&local_repository), spec)
1147                .unwrap();
1148        }
1149        let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
1150        sources.insert(
1151            crate::parsing::source::SourceType::Volatile,
1152            local_source.to_string(),
1153        );
1154
1155        let registry = TestRegistry::new(); // empty — no bundles
1156
1157        let result = resolve_registry_references(
1158            &mut store,
1159            &mut sources,
1160            &registry,
1161            &ResourceLimits::default(),
1162        )
1163        .await;
1164
1165        assert!(result.is_err(), "Should fail when Registry cannot resolve");
1166        let errors = result.unwrap_err();
1167        let identifiers: Vec<&str> = errors
1168            .iter()
1169            .filter_map(|e| {
1170                if let Error::Registry { identifier, .. } = e {
1171                    Some(identifier.as_str())
1172                } else {
1173                    None
1174                }
1175            })
1176            .collect();
1177        assert!(
1178            identifiers.contains(&"@org/example"),
1179            "Should include repository error: {:?}",
1180            identifiers
1181        );
1182        assert!(
1183            identifiers.contains(&"@iso/countries"),
1184            "Should include data import repository error: {:?}",
1185            identifiers
1186        );
1187    }
1188
1189    #[tokio::test(flavor = "current_thread")]
1190    async fn resolve_does_not_request_same_repository_twice() {
1191        let local_source = r#"spec spec_one
1192uses a: @org/shared shared
1193
1194spec spec_two
1195uses b: @org/shared shared"#;
1196        let local_specs = crate::parse(
1197            local_source,
1198            crate::parsing::source::SourceType::Volatile,
1199            &ResourceLimits::default(),
1200        )
1201        .unwrap()
1202        .into_flattened_specs();
1203        let mut store = context_with_embedded_stdlib();
1204        let local_repository = store.workspace();
1205        for spec in local_specs {
1206            store
1207                .insert_spec(Arc::clone(&local_repository), spec)
1208                .unwrap();
1209        }
1210        let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
1211        sources.insert(
1212            crate::parsing::source::SourceType::Volatile,
1213            local_source.to_string(),
1214        );
1215
1216        let mut registry = TestRegistry::new();
1217        registry.add_spec_bundle(
1218            "@org/shared",
1219            r#"repo @org/shared
1220spec shared
1221data value: 1"#,
1222        );
1223
1224        resolve_registry_references(
1225            &mut store,
1226            &mut sources,
1227            &registry,
1228            &ResourceLimits::default(),
1229        )
1230        .await
1231        .unwrap();
1232
1233        assert_eq!(store.iter().count(), 4);
1234        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1235        assert!(names.iter().any(|n| n == "shared"));
1236        assert!(names.iter().any(|n| n == "units"));
1237    }
1238
1239    #[tokio::test(flavor = "current_thread")]
1240    async fn resolve_handles_data_import_from_registry() {
1241        let local_source = r#"spec main_spec
1242uses @iso/countries alpha2
1243data country: alpha2.code
1244data home: country"#;
1245        let local_specs = crate::parse(
1246            local_source,
1247            crate::parsing::source::SourceType::Volatile,
1248            &ResourceLimits::default(),
1249        )
1250        .unwrap()
1251        .into_flattened_specs();
1252        let mut store = context_with_embedded_stdlib();
1253        let local_repository = store.workspace();
1254        for spec in local_specs {
1255            store
1256                .insert_spec(Arc::clone(&local_repository), spec)
1257                .unwrap();
1258        }
1259        let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
1260        sources.insert(
1261            crate::parsing::source::SourceType::Volatile,
1262            local_source.to_string(),
1263        );
1264
1265        let mut registry = TestRegistry::new();
1266        registry.add_spec_bundle(
1267            "@iso/countries",
1268            r#"repo @iso/countries
1269spec alpha2
1270data code: text
1271 -> option "NL""#,
1272        );
1273
1274        resolve_registry_references(
1275            &mut store,
1276            &mut sources,
1277            &registry,
1278            &ResourceLimits::default(),
1279        )
1280        .await
1281        .unwrap();
1282
1283        assert_eq!(store.iter().count(), 3);
1284        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1285        assert!(names.iter().any(|n| n == "main_spec"));
1286        assert!(names.iter().any(|n| n == "alpha2"));
1287        assert!(names.iter().any(|n| n == "units"));
1288    }
1289
1290    // -----------------------------------------------------------------------
1291    // LemmaBase tests (feature-gated)
1292    // -----------------------------------------------------------------------
1293
1294    #[cfg(feature = "registry")]
1295    mod lemmabase_tests {
1296        fn lemma_base_with_fetcher(fetcher: Box<dyn HttpFetcher>) -> LemmaBase {
1297            LemmaBase { fetcher }
1298        }
1299
1300        use super::super::*;
1301        use crate::literals::DateGranularity;
1302        use std::sync::{Arc, Mutex};
1303
1304        // -------------------------------------------------------------------
1305        // MockHttpFetcher — drives LemmaBase without touching the network
1306        // -------------------------------------------------------------------
1307
1308        type HttpFetchHandler = Box<dyn Fn(&str) -> Result<String, HttpFetchError> + Send + Sync>;
1309
1310        struct MockHttpFetcher {
1311            handler: HttpFetchHandler,
1312        }
1313
1314        impl MockHttpFetcher {
1315            /// Create a mock that delegates every `.get(url)` call to `handler`.
1316            fn with_handler(
1317                handler: impl Fn(&str) -> Result<String, HttpFetchError> + Send + Sync + 'static,
1318            ) -> Self {
1319                Self {
1320                    handler: Box::new(handler),
1321                }
1322            }
1323
1324            /// Create a mock that always returns the given body for every URL.
1325            fn always_returning(body: &str) -> Self {
1326                let body = body.to_string();
1327                Self::with_handler(move |_| Ok(body.clone()))
1328            }
1329
1330            /// Create a mock that always fails with the given HTTP status code.
1331            fn always_failing_with_status(code: u16) -> Self {
1332                Self::with_handler(move |_| {
1333                    Err(HttpFetchError {
1334                        status_code: Some(code),
1335                        message: format!("HTTP {}", code),
1336                    })
1337                })
1338            }
1339
1340            /// Create a mock that always fails with a transport / network error.
1341            fn always_failing_with_network_error(msg: &str) -> Self {
1342                let msg = msg.to_string();
1343                Self::with_handler(move |_| {
1344                    Err(HttpFetchError {
1345                        status_code: None,
1346                        message: msg.clone(),
1347                    })
1348                })
1349            }
1350        }
1351
1352        #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
1353        #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
1354        impl HttpFetcher for MockHttpFetcher {
1355            async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
1356                (self.handler)(url)
1357            }
1358        }
1359
1360        // -------------------------------------------------------------------
1361        // URL construction tests
1362        // -------------------------------------------------------------------
1363
1364        #[test]
1365        fn source_url_without_effective() {
1366            let registry = LemmaBase::new();
1367            let url = registry
1368                .source_url("@user/workspace/somespec", None)
1369                .unwrap();
1370            assert_eq!(
1371                url,
1372                format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1373            );
1374        }
1375
1376        #[test]
1377        fn source_url_with_effective() {
1378            let registry = LemmaBase::new();
1379            let effective = DateTimeValue {
1380                year: 2026,
1381                month: 1,
1382                day: 15,
1383                hour: 0,
1384                minute: 0,
1385                second: 0,
1386                microsecond: 0,
1387                timezone: None,
1388
1389                granularity: DateGranularity::Full,
1390            };
1391            let url = registry
1392                .source_url("@user/workspace/somespec", Some(&effective))
1393                .unwrap();
1394            assert_eq!(
1395                url,
1396                format!(
1397                    "{}/@user/workspace/somespec.lemma?effective=2026-01-15",
1398                    LemmaBase::BASE_URL
1399                )
1400            );
1401        }
1402
1403        #[test]
1404        fn source_url_for_deeply_nested_identifier() {
1405            let registry = LemmaBase::new();
1406            let url = registry
1407                .source_url("@org/team/project/subdir/spec", None)
1408                .unwrap();
1409            assert_eq!(
1410                url,
1411                format!(
1412                    "{}/@org/team/project/subdir/spec.lemma",
1413                    LemmaBase::BASE_URL
1414                )
1415            );
1416        }
1417
1418        #[test]
1419        fn source_url_rejects_id_without_at() {
1420            let registry = LemmaBase::new();
1421            let err = registry
1422                .source_url("not-a-registry-id", None)
1423                .expect_err("non-@ id must be a registry error");
1424            assert_eq!(err.kind, RegistryErrorKind::Other);
1425            assert!(
1426                err.message.contains("must start with '@'"),
1427                "got: {}",
1428                err.message
1429            );
1430            assert!(
1431                err.message.contains("not-a-registry-id"),
1432                "got: {}",
1433                err.message
1434            );
1435        }
1436
1437        #[test]
1438        fn navigation_url_without_effective() {
1439            let registry = LemmaBase::new();
1440            let url = registry
1441                .navigation_url("@user/workspace/somespec", None)
1442                .unwrap();
1443            assert_eq!(
1444                url,
1445                format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL)
1446            );
1447        }
1448
1449        #[test]
1450        fn navigation_url_with_effective() {
1451            let registry = LemmaBase::new();
1452            let effective = DateTimeValue {
1453                year: 2026,
1454                month: 1,
1455                day: 15,
1456                hour: 0,
1457                minute: 0,
1458                second: 0,
1459                microsecond: 0,
1460                timezone: None,
1461
1462                granularity: DateGranularity::Full,
1463            };
1464            let url = registry
1465                .navigation_url("@user/workspace/somespec", Some(&effective))
1466                .unwrap();
1467            assert_eq!(
1468                url,
1469                format!(
1470                    "{}/@user/workspace/somespec?effective=2026-01-15",
1471                    LemmaBase::BASE_URL
1472                )
1473            );
1474        }
1475
1476        #[test]
1477        fn navigation_url_rejects_id_without_at() {
1478            let registry = LemmaBase::new();
1479            assert!(registry.navigation_url("iso/countries", None).is_none());
1480        }
1481
1482        #[test]
1483        fn url_for_id_returns_navigation_url() {
1484            let registry = LemmaBase::new();
1485            let url = registry.url_for_id("@user/workspace/somespec", None);
1486            assert_eq!(
1487                url,
1488                Some(format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL))
1489            );
1490        }
1491
1492        #[test]
1493        fn url_for_id_with_effective() {
1494            let registry = LemmaBase::new();
1495            let effective = DateTimeValue {
1496                year: 2026,
1497                month: 1,
1498                day: 1,
1499                hour: 0,
1500                minute: 0,
1501                second: 0,
1502                microsecond: 0,
1503                timezone: None,
1504
1505                granularity: DateGranularity::Full,
1506            };
1507            let url = registry.url_for_id("@owner/repo/spec", Some(&effective));
1508            assert_eq!(
1509                url,
1510                Some(format!(
1511                    "{}/@owner/repo/spec?effective=2026-01-01",
1512                    LemmaBase::BASE_URL
1513                ))
1514            );
1515        }
1516
1517        #[test]
1518        fn url_for_id_returns_navigation_url_for_nested_path() {
1519            let registry = LemmaBase::new();
1520            let url = registry.url_for_id("@iso/countries/alpha2", None);
1521            assert_eq!(
1522                url,
1523                Some(format!("{}/@iso/countries/alpha2", LemmaBase::BASE_URL))
1524            );
1525        }
1526
1527        // -------------------------------------------------------------------
1528        // fetch_source tests (mock-based, no real HTTP calls)
1529        // -------------------------------------------------------------------
1530
1531        #[tokio::test(flavor = "current_thread")]
1532        async fn test_mode_serves_bundled_fixtures() {
1533            let registry = LemmaBase::test();
1534            let iso = registry.get("@iso/countries").await.unwrap();
1535            assert!(iso.source.contains("spec alpha2"));
1536        }
1537
1538        #[tokio::test(flavor = "current_thread")]
1539        async fn fetch_source_rejects_id_without_at() {
1540            let registry =
1541                lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_returning("unused")));
1542            let err = registry
1543                .fetch_source("not-a-registry-id")
1544                .await
1545                .expect_err("non-@ id must not hit the network");
1546            assert_eq!(err.kind, RegistryErrorKind::Other);
1547            assert!(
1548                err.message.contains("must start with '@'"),
1549                "got: {}",
1550                err.message
1551            );
1552        }
1553
1554        #[tokio::test(flavor = "current_thread")]
1555        async fn fetch_source_returns_bundle_on_success() {
1556            let registry = lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_returning(
1557                "spec org/my_spec\ndata x: 1",
1558            )));
1559
1560            let bundle = registry.fetch_source("@org/my_spec").await.unwrap();
1561
1562            assert_eq!(bundle.source, "spec org/my_spec\ndata x: 1");
1563            assert_eq!(bundle.repository, "@org/my_spec");
1564        }
1565
1566        #[tokio::test(flavor = "current_thread")]
1567        async fn fetch_source_passes_correct_url_to_fetcher() {
1568            let captured_url = Arc::new(Mutex::new(String::new()));
1569            let captured = captured_url.clone();
1570            let mock = MockHttpFetcher::with_handler(move |url| {
1571                *captured.lock().unwrap() = url.to_string();
1572                Ok("spec test/spec\ndata x: 1".to_string())
1573            });
1574            let registry = lemma_base_with_fetcher(Box::new(mock));
1575
1576            let _ = registry.fetch_source("@user/workspace/somespec").await;
1577
1578            assert_eq!(
1579                *captured_url.lock().unwrap(),
1580                format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1581            );
1582        }
1583
1584        #[tokio::test(flavor = "current_thread")]
1585        async fn fetch_source_maps_http_404_to_not_found() {
1586            let registry =
1587                lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(404)));
1588
1589            let err = registry.fetch_source("@org/missing").await.unwrap_err();
1590
1591            assert_eq!(err.kind, RegistryErrorKind::NotFound);
1592            assert!(
1593                err.message.contains("HTTP 404"),
1594                "Expected 'HTTP 404' in: {}",
1595                err.message
1596            );
1597            assert!(
1598                err.message.contains("@org/missing"),
1599                "Expected '@org/missing' in: {}",
1600                err.message
1601            );
1602        }
1603
1604        #[tokio::test(flavor = "current_thread")]
1605        async fn fetch_source_maps_http_500_to_server_error() {
1606            let registry =
1607                lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(500)));
1608
1609            let err = registry.fetch_source("@org/broken").await.unwrap_err();
1610
1611            assert_eq!(err.kind, RegistryErrorKind::ServerError);
1612            assert!(
1613                err.message.contains("HTTP 500"),
1614                "Expected 'HTTP 500' in: {}",
1615                err.message
1616            );
1617        }
1618
1619        #[tokio::test(flavor = "current_thread")]
1620        async fn fetch_source_maps_http_401_to_unauthorized() {
1621            let registry =
1622                lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(401)));
1623
1624            let err = registry.fetch_source("@org/secret").await.unwrap_err();
1625
1626            assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1627            assert!(err.message.contains("HTTP 401"));
1628        }
1629
1630        #[tokio::test(flavor = "current_thread")]
1631        async fn fetch_source_maps_http_403_to_unauthorized() {
1632            let registry =
1633                lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(403)));
1634
1635            let err = registry.fetch_source("@org/private").await.unwrap_err();
1636
1637            assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1638            assert!(
1639                err.message.contains("HTTP 403"),
1640                "Expected 'HTTP 403' in: {}",
1641                err.message
1642            );
1643        }
1644
1645        #[tokio::test(flavor = "current_thread")]
1646        async fn fetch_source_maps_unexpected_status_to_other() {
1647            let registry =
1648                lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(418)));
1649
1650            let err = registry.fetch_source("@org/teapot").await.unwrap_err();
1651
1652            assert_eq!(err.kind, RegistryErrorKind::Other);
1653            assert!(err.message.contains("HTTP 418"));
1654        }
1655
1656        #[tokio::test(flavor = "current_thread")]
1657        async fn fetch_source_maps_network_error_to_network_error_kind() {
1658            let registry = lemma_base_with_fetcher(Box::new(
1659                MockHttpFetcher::always_failing_with_network_error("connection refused"),
1660            ));
1661
1662            let err = registry.fetch_source("@org/unreachable").await.unwrap_err();
1663
1664            assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1665            assert!(
1666                err.message.contains("connection refused"),
1667                "Expected 'connection refused' in: {}",
1668                err.message
1669            );
1670            assert!(
1671                err.message.contains("@org/unreachable"),
1672                "Expected '@org/unreachable' in: {}",
1673                err.message
1674            );
1675        }
1676
1677        #[tokio::test(flavor = "current_thread")]
1678        async fn fetch_source_maps_dns_error_to_network_error_kind() {
1679            let registry = lemma_base_with_fetcher(Box::new(
1680                MockHttpFetcher::always_failing_with_network_error(
1681                    "dns error: failed to lookup address",
1682                ),
1683            ));
1684
1685            let err = registry.fetch_source("@org/spec").await.unwrap_err();
1686
1687            assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1688            assert!(
1689                err.message.contains("dns error"),
1690                "Expected 'dns error' in: {}",
1691                err.message
1692            );
1693            assert!(
1694                err.message.contains("Failed to reach LemmaBase"),
1695                "Expected 'Failed to reach LemmaBase' in: {}",
1696                err.message
1697            );
1698        }
1699
1700        // -------------------------------------------------------------------
1701        // Registry trait delegation tests (mock-based)
1702        // -------------------------------------------------------------------
1703
1704        #[tokio::test(flavor = "current_thread")]
1705        async fn get_delegates_to_fetch_source() {
1706            let registry = lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_returning(
1707                "spec org/resolved\ndata a: 1",
1708            )));
1709
1710            let bundle = registry.get("@org/resolved").await.unwrap();
1711
1712            assert_eq!(bundle.source, "spec org/resolved\ndata a: 1");
1713            assert_eq!(bundle.repository, "@org/resolved");
1714        }
1715
1716        #[tokio::test(flavor = "current_thread")]
1717        async fn fetch_source_returns_empty_body_as_valid_bundle() {
1718            let registry = lemma_base_with_fetcher(Box::new(MockHttpFetcher::always_returning("")));
1719
1720            let bundle = registry.fetch_source("@org/empty").await.unwrap();
1721
1722            assert_eq!(bundle.source, "");
1723            assert_eq!(bundle.repository, "@org/empty");
1724        }
1725    }
1726}