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