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