Skip to main content

lemma/
registry.rs

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