Skip to main content

lemma/
registry.rs

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