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