Skip to main content

lemma/
registry.rs

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