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 ctx.find_repository(&qualifier.name).is_some() {
472        return;
473    }
474    if already_requested.contains(&qualifier.name) {
475        return;
476    }
477    if !seen_in_this_round.insert(qualifier.name.clone()) {
478        return;
479    }
480    out.push(RegistryReference {
481        repository: qualifier.clone(),
482        source: source.clone(),
483    });
484}
485
486/// Collect every distinct registry repository qualifier referenced by specs in `ctx`.
487#[cfg(all(feature = "registry", not(target_arch = "wasm32")))]
488fn find_missing_repositories(
489    ctx: &Context,
490    already_requested: &HashSet<String>,
491) -> Vec<RegistryReference> {
492    let mut unresolved: Vec<RegistryReference> = Vec::new();
493    let mut seen_in_this_round: HashSet<String> = HashSet::new();
494
495    for spec in ctx.iter() {
496        let spec = spec.as_ref();
497
498        for data in &spec.data {
499            // `uses <repository> <spec>`
500            if let DataValue::Import(spec_ref) = &data.value {
501                collect_repository_qualifiers_from_spec_ref(
502                    spec_ref,
503                    &data.source_location,
504                    ctx,
505                    already_requested,
506                    &mut seen_in_this_round,
507                    &mut unresolved,
508                );
509            }
510        }
511    }
512
513    unresolved
514}
515
516// ---------------------------------------------------------------------------
517// Tests
518// ---------------------------------------------------------------------------
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use crate::engine::Engine;
524    use crate::literals::DateGranularity;
525
526    /// A test Registry that returns predefined bundles keyed by name.
527    struct TestRegistry {
528        bundles: HashMap<String, RegistryBundle>,
529    }
530
531    impl TestRegistry {
532        fn new() -> Self {
533            Self {
534                bundles: HashMap::new(),
535            }
536        }
537
538        /// Add a bundle containing all zones for this identifier (e.g. `"@org/repo"`).
539        fn add_spec_bundle(&mut self, identifier: &str, lemma_source: &str) {
540            self.bundles.insert(
541                identifier.to_string(),
542                RegistryBundle {
543                    lemma_source: lemma_source.to_string(),
544                    source_type: crate::parsing::source::SourceType::Registry(Arc::new(
545                        LemmaRepository::new(Some(identifier.to_string())),
546                    )),
547                },
548            );
549        }
550    }
551
552    #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
553    #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
554    impl Registry for TestRegistry {
555        async fn get(&self, name: &str) -> Result<RegistryBundle, RegistryError> {
556            self.bundles
557                .get(name)
558                .cloned()
559                .ok_or_else(|| RegistryError {
560                    message: format!("'{}' not found in test registry", name),
561                    kind: RegistryErrorKind::NotFound,
562                })
563        }
564
565        fn url_for_id(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
566            if self.bundles.contains_key(name) {
567                Some(match effective {
568                    None => format!("https://test.registry/{}", name),
569                    Some(d) => format!("https://test.registry/{}?effective={}", name, d),
570                })
571            } else {
572                None
573            }
574        }
575    }
576
577    #[tokio::test]
578    async fn resolve_with_no_registry_references_returns_local_specs_unchanged() {
579        let source = r#"spec example
580data price: 100"#;
581        let local_specs = crate::parse(
582            source,
583            crate::parsing::source::SourceType::Volatile,
584            &ResourceLimits::default(),
585        )
586        .unwrap()
587        .into_flattened_specs();
588        let mut engine = Engine::new();
589        let store = engine.specs_mut();
590        let local_repository = store.workspace();
591        for spec in &local_specs {
592            store
593                .insert_spec(Arc::clone(&local_repository), Arc::new(spec.clone()))
594                .unwrap();
595        }
596        let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
597        sources.insert(
598            crate::parsing::source::SourceType::Volatile,
599            source.to_string(),
600        );
601
602        let registry = TestRegistry::new();
603        resolve_registry_references(store, &mut sources, &registry, &ResourceLimits::default())
604            .await
605            .unwrap();
606
607        assert_eq!(store.len(), 2, "embedded spec units plus workspace example");
608        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
609        assert!(names.iter().any(|n| n == "example"));
610        assert!(names.iter().any(|n| n == "units"));
611    }
612
613    #[tokio::test]
614    async fn resolve_fetches_single_spec_from_registry() {
615        let local_source = r#"spec main_spec
616uses external: @org/project helper
617rule value: external.quantity"#;
618        let local_specs = crate::parse(
619            local_source,
620            crate::parsing::source::SourceType::Volatile,
621            &ResourceLimits::default(),
622        )
623        .unwrap()
624        .into_flattened_specs();
625        let mut engine = Engine::new();
626        let store = engine.specs_mut();
627        let local_repository = store.workspace();
628        for spec in local_specs {
629            store
630                .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
631                .unwrap();
632        }
633        let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
634        sources.insert(
635            crate::parsing::source::SourceType::Volatile,
636            local_source.to_string(),
637        );
638
639        let mut registry = TestRegistry::new();
640        registry.add_spec_bundle(
641            "@org/project",
642            r#"repo @org/project
643spec helper
644data quantity: 42"#,
645        );
646
647        resolve_registry_references(store, &mut sources, &registry, &ResourceLimits::default())
648            .await
649            .unwrap();
650
651        assert_eq!(store.len(), 3);
652        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
653        assert!(names.iter().any(|n| n == "main_spec"));
654        assert!(names.iter().any(|n| n == "helper"));
655        assert!(names.iter().any(|n| n == "units"));
656    }
657
658    #[tokio::test]
659    async fn resolve_registry_bundle_without_repo_decl_uses_reference_repository_name() {
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#"spec helper
688data quantity: 42"#,
689        );
690
691        resolve_registry_references(store, &mut sources, &registry, &ResourceLimits::default())
692            .await
693            .unwrap();
694
695        let ext_repo = store
696            .find_repository("@org/project")
697            .expect("registry bundle must land under fetched @ id");
698        let spec_names: Vec<String> = store
699            .repositories()
700            .get(&ext_repo)
701            .expect("spec sets for @org/project")
702            .keys()
703            .cloned()
704            .collect();
705        assert!(
706            spec_names.iter().any(|n| n == "helper"),
707            "helper spec should live under @org/project, got {:?}",
708            spec_names
709        );
710    }
711
712    #[tokio::test]
713    async fn get_returns_all_zones_and_url_for_id_supports_effective() {
714        let effective = DateTimeValue {
715            year: 2026,
716            month: 1,
717            day: 15,
718            hour: 0,
719            minute: 0,
720            second: 0,
721            microsecond: 0,
722            timezone: None,
723
724            granularity: DateGranularity::Full,
725        };
726        let mut registry = TestRegistry::new();
727        registry.add_spec_bundle(
728            "@org/spec",
729            "spec org/spec 2025-01-01\ndata x: 1\n\nspec org/spec 2026-01-15\ndata x: 2",
730        );
731
732        let bundle = registry.get("@org/spec").await.unwrap();
733        assert!(bundle.lemma_source.contains("data x: 1"));
734        assert!(bundle.lemma_source.contains("data x: 2"));
735
736        assert_eq!(
737            registry.url_for_id("@org/spec", None),
738            Some("https://test.registry/@org/spec".to_string())
739        );
740        assert_eq!(
741            registry.url_for_id("@org/spec", Some(&effective)),
742            Some("https://test.registry/@org/spec?effective=2026-01-15".to_string())
743        );
744    }
745
746    #[tokio::test]
747    async fn resolve_fetches_transitive_dependencies() {
748        let local_source = r#"spec main_spec
749uses a: @org/project spec_a"#;
750        let local_specs = crate::parse(
751            local_source,
752            crate::parsing::source::SourceType::Volatile,
753            &ResourceLimits::default(),
754        )
755        .unwrap()
756        .into_flattened_specs();
757        let mut engine = Engine::new();
758        let store = engine.specs_mut();
759        let local_repository = store.workspace();
760        for spec in local_specs {
761            store
762                .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
763                .unwrap();
764        }
765        let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
766        sources.insert(
767            crate::parsing::source::SourceType::Volatile,
768            local_source.to_string(),
769        );
770
771        let mut registry = TestRegistry::new();
772        registry.add_spec_bundle(
773            "@org/project",
774            r#"repo @org/project
775spec spec_a
776uses b: @org/sub spec_b"#,
777        );
778        registry.add_spec_bundle(
779            "@org/sub",
780            r#"repo @org/sub
781spec spec_b
782data value: 99"#,
783        );
784
785        resolve_registry_references(store, &mut sources, &registry, &ResourceLimits::default())
786            .await
787            .unwrap();
788
789        assert_eq!(store.len(), 4);
790        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
791        assert!(names.iter().any(|n| n == "main_spec"));
792        assert!(names.iter().any(|n| n == "spec_a"));
793        assert!(names.iter().any(|n| n == "spec_b"));
794        assert!(names.iter().any(|n| n == "units"));
795    }
796
797    #[tokio::test]
798    async fn resolve_handles_bundle_with_multiple_specs() {
799        let local_source = r#"spec main_spec
800uses a: @org/project spec_a"#;
801        let local_specs = crate::parse(
802            local_source,
803            crate::parsing::source::SourceType::Volatile,
804            &ResourceLimits::default(),
805        )
806        .unwrap()
807        .into_flattened_specs();
808        let mut engine = Engine::new();
809        let store = engine.specs_mut();
810        let local_repository = store.workspace();
811        for spec in local_specs {
812            store
813                .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
814                .unwrap();
815        }
816        let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
817        sources.insert(
818            crate::parsing::source::SourceType::Volatile,
819            local_source.to_string(),
820        );
821
822        let mut registry = TestRegistry::new();
823        registry.add_spec_bundle(
824            "@org/project",
825            r#"repo @org/project
826spec spec_a
827uses b: spec_b
828
829spec spec_b
830data value: 99"#,
831        );
832
833        resolve_registry_references(store, &mut sources, &registry, &ResourceLimits::default())
834            .await
835            .unwrap();
836
837        assert_eq!(store.len(), 4);
838        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
839        assert!(names.iter().any(|n| n == "main_spec"));
840        assert!(names.iter().any(|n| n == "spec_a"));
841        assert!(names.iter().any(|n| n == "spec_b"));
842        assert!(names.iter().any(|n| n == "units"));
843    }
844
845    #[tokio::test]
846    async fn resolve_returns_registry_error_when_registry_fails() {
847        let local_source = r#"spec main_spec
848uses external: @org/project missing"#;
849        let local_specs = crate::parse(
850            local_source,
851            crate::parsing::source::SourceType::Volatile,
852            &ResourceLimits::default(),
853        )
854        .unwrap()
855        .into_flattened_specs();
856        let mut engine = Engine::new();
857        let store = engine.specs_mut();
858        let local_repository = store.workspace();
859        for spec in local_specs {
860            store
861                .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
862                .unwrap();
863        }
864        let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
865        sources.insert(
866            crate::parsing::source::SourceType::Volatile,
867            local_source.to_string(),
868        );
869
870        let registry = TestRegistry::new(); // empty — no bundles
871
872        let result =
873            resolve_registry_references(store, &mut sources, &registry, &ResourceLimits::default())
874                .await;
875
876        assert!(result.is_err(), "Should fail when Registry cannot resolve");
877        let errs = result.unwrap_err();
878        let registry_err = errs
879            .iter()
880            .find(|e| matches!(e, Error::Registry { .. }))
881            .expect("expected at least one Registry error");
882        match registry_err {
883            Error::Registry {
884                identifier,
885                kind,
886                details,
887            } => {
888                assert_eq!(identifier, "@org/project");
889                assert_eq!(*kind, RegistryErrorKind::NotFound);
890                assert!(
891                    details.suggestion.is_some(),
892                    "NotFound errors should include a suggestion"
893                );
894            }
895            _ => unreachable!(),
896        }
897
898        let error_message = errs
899            .iter()
900            .map(|e| e.to_string())
901            .collect::<Vec<_>>()
902            .join(" ");
903        assert!(
904            error_message.contains("@org/project"),
905            "Error should mention the identifier: {}",
906            error_message
907        );
908    }
909
910    #[tokio::test]
911    async fn resolve_returns_all_registry_errors_when_multiple_repositorys_fail() {
912        let local_source = r#"spec main_spec
913uses @org/example helper
914uses @lemma/std finance
915data money: finance.money"#;
916        let local_specs = crate::parse(
917            local_source,
918            crate::parsing::source::SourceType::Volatile,
919            &ResourceLimits::default(),
920        )
921        .unwrap()
922        .into_flattened_specs();
923        let mut engine = Engine::new();
924        let store = engine.specs_mut();
925        let local_repository = store.workspace();
926        for spec in local_specs {
927            store
928                .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
929                .unwrap();
930        }
931        let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
932        sources.insert(
933            crate::parsing::source::SourceType::Volatile,
934            local_source.to_string(),
935        );
936
937        let registry = TestRegistry::new(); // empty — no bundles
938
939        let result =
940            resolve_registry_references(store, &mut sources, &registry, &ResourceLimits::default())
941                .await;
942
943        assert!(result.is_err(), "Should fail when Registry cannot resolve");
944        let errors = result.unwrap_err();
945        let identifiers: Vec<&str> = errors
946            .iter()
947            .filter_map(|e| {
948                if let Error::Registry { identifier, .. } = e {
949                    Some(identifier.as_str())
950                } else {
951                    None
952                }
953            })
954            .collect();
955        assert!(
956            identifiers.contains(&"@org/example"),
957            "Should include repository error: {:?}",
958            identifiers
959        );
960        assert!(
961            identifiers.contains(&"@lemma/std"),
962            "Should include data import repository error: {:?}",
963            identifiers
964        );
965    }
966
967    #[tokio::test]
968    async fn resolve_does_not_request_same_repository_twice() {
969        let local_source = r#"spec spec_one
970uses a: @org/shared shared
971
972spec spec_two
973uses b: @org/shared shared"#;
974        let local_specs = crate::parse(
975            local_source,
976            crate::parsing::source::SourceType::Volatile,
977            &ResourceLimits::default(),
978        )
979        .unwrap()
980        .into_flattened_specs();
981        let mut engine = Engine::new();
982        let store = engine.specs_mut();
983        let local_repository = store.workspace();
984        for spec in local_specs {
985            store
986                .insert_spec(Arc::clone(&local_repository), Arc::new(spec))
987                .unwrap();
988        }
989        let mut sources: HashMap<crate::parsing::source::SourceType, String> = HashMap::new();
990        sources.insert(
991            crate::parsing::source::SourceType::Volatile,
992            local_source.to_string(),
993        );
994
995        let mut registry = TestRegistry::new();
996        registry.add_spec_bundle(
997            "@org/shared",
998            r#"repo @org/shared
999spec shared
1000data value: 1"#,
1001        );
1002
1003        resolve_registry_references(store, &mut sources, &registry, &ResourceLimits::default())
1004            .await
1005            .unwrap();
1006
1007        assert_eq!(store.len(), 4);
1008        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1009        assert!(names.iter().any(|n| n == "shared"));
1010        assert!(names.iter().any(|n| n == "units"));
1011    }
1012
1013    #[tokio::test]
1014    async fn resolve_handles_data_import_from_registry() {
1015        let local_source = r#"spec main_spec
1016uses @lemma/std finance
1017data money: finance.money
1018data price: money"#;
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            "@lemma/std",
1043            r#"repo @lemma/std
1044spec finance
1045data money: quantity
1046 -> unit eur 1.00
1047 -> unit usd 0.91
1048 -> decimals 2"#,
1049        );
1050
1051        resolve_registry_references(store, &mut sources, &registry, &ResourceLimits::default())
1052            .await
1053            .unwrap();
1054
1055        assert_eq!(store.len(), 3);
1056        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1057        assert!(names.iter().any(|n| n == "main_spec"));
1058        assert!(names.iter().any(|n| n == "finance"));
1059        assert!(names.iter().any(|n| n == "units"));
1060    }
1061
1062    // -----------------------------------------------------------------------
1063    // LemmaBase tests (feature-gated)
1064    // -----------------------------------------------------------------------
1065
1066    #[cfg(feature = "registry")]
1067    mod lemmabase_tests {
1068        use super::super::*;
1069        use crate::literals::DateGranularity;
1070        use std::sync::{Arc, Mutex};
1071
1072        // -------------------------------------------------------------------
1073        // MockHttpFetcher — drives LemmaBase without touching the network
1074        // -------------------------------------------------------------------
1075
1076        type HttpFetchHandler = Box<dyn Fn(&str) -> Result<String, HttpFetchError> + Send + Sync>;
1077
1078        struct MockHttpFetcher {
1079            handler: HttpFetchHandler,
1080        }
1081
1082        impl MockHttpFetcher {
1083            /// Create a mock that delegates every `.get(url)` call to `handler`.
1084            fn with_handler(
1085                handler: impl Fn(&str) -> Result<String, HttpFetchError> + Send + Sync + 'static,
1086            ) -> Self {
1087                Self {
1088                    handler: Box::new(handler),
1089                }
1090            }
1091
1092            /// Create a mock that always returns the given body for every URL.
1093            fn always_returning(body: &str) -> Self {
1094                let body = body.to_string();
1095                Self::with_handler(move |_| Ok(body.clone()))
1096            }
1097
1098            /// Create a mock that always fails with the given HTTP status code.
1099            fn always_failing_with_status(code: u16) -> Self {
1100                Self::with_handler(move |_| {
1101                    Err(HttpFetchError {
1102                        status_code: Some(code),
1103                        message: format!("HTTP {}", code),
1104                    })
1105                })
1106            }
1107
1108            /// Create a mock that always fails with a transport / network error.
1109            fn always_failing_with_network_error(msg: &str) -> Self {
1110                let msg = msg.to_string();
1111                Self::with_handler(move |_| {
1112                    Err(HttpFetchError {
1113                        status_code: None,
1114                        message: msg.clone(),
1115                    })
1116                })
1117            }
1118        }
1119
1120        #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
1121        #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
1122        impl HttpFetcher for MockHttpFetcher {
1123            async fn get(&self, url: &str) -> Result<String, HttpFetchError> {
1124                (self.handler)(url)
1125            }
1126        }
1127
1128        // -------------------------------------------------------------------
1129        // URL construction tests
1130        // -------------------------------------------------------------------
1131
1132        #[test]
1133        fn source_url_without_effective() {
1134            let registry = LemmaBase::new();
1135            let url = registry.source_url("@user/workspace/somespec", None);
1136            assert_eq!(
1137                url,
1138                format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1139            );
1140        }
1141
1142        #[test]
1143        fn source_url_with_effective() {
1144            let registry = LemmaBase::new();
1145            let effective = DateTimeValue {
1146                year: 2026,
1147                month: 1,
1148                day: 15,
1149                hour: 0,
1150                minute: 0,
1151                second: 0,
1152                microsecond: 0,
1153                timezone: None,
1154
1155                granularity: DateGranularity::Full,
1156            };
1157            let url = registry.source_url("@user/workspace/somespec", Some(&effective));
1158            assert_eq!(
1159                url,
1160                format!(
1161                    "{}/@user/workspace/somespec.lemma?effective=2026-01-15",
1162                    LemmaBase::BASE_URL
1163                )
1164            );
1165        }
1166
1167        #[test]
1168        fn source_url_for_deeply_nested_identifier() {
1169            let registry = LemmaBase::new();
1170            let url = registry.source_url("@org/team/project/subdir/spec", None);
1171            assert_eq!(
1172                url,
1173                format!(
1174                    "{}/@org/team/project/subdir/spec.lemma",
1175                    LemmaBase::BASE_URL
1176                )
1177            );
1178        }
1179
1180        #[test]
1181        fn navigation_url_without_effective() {
1182            let registry = LemmaBase::new();
1183            let url = registry.navigation_url("@user/workspace/somespec", None);
1184            assert_eq!(
1185                url,
1186                format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL)
1187            );
1188        }
1189
1190        #[test]
1191        fn navigation_url_with_effective() {
1192            let registry = LemmaBase::new();
1193            let effective = DateTimeValue {
1194                year: 2026,
1195                month: 1,
1196                day: 15,
1197                hour: 0,
1198                minute: 0,
1199                second: 0,
1200                microsecond: 0,
1201                timezone: None,
1202
1203                granularity: DateGranularity::Full,
1204            };
1205            let url = registry.navigation_url("@user/workspace/somespec", Some(&effective));
1206            assert_eq!(
1207                url,
1208                format!(
1209                    "{}/@user/workspace/somespec?effective=2026-01-15",
1210                    LemmaBase::BASE_URL
1211                )
1212            );
1213        }
1214
1215        #[test]
1216        fn url_for_id_returns_navigation_url() {
1217            let registry = LemmaBase::new();
1218            let url = registry.url_for_id("@user/workspace/somespec", None);
1219            assert_eq!(
1220                url,
1221                Some(format!("{}/@user/workspace/somespec", LemmaBase::BASE_URL))
1222            );
1223        }
1224
1225        #[test]
1226        fn url_for_id_with_effective() {
1227            let registry = LemmaBase::new();
1228            let effective = DateTimeValue {
1229                year: 2026,
1230                month: 1,
1231                day: 1,
1232                hour: 0,
1233                minute: 0,
1234                second: 0,
1235                microsecond: 0,
1236                timezone: None,
1237
1238                granularity: DateGranularity::Full,
1239            };
1240            let url = registry.url_for_id("@owner/repo/spec", Some(&effective));
1241            assert_eq!(
1242                url,
1243                Some(format!(
1244                    "{}/@owner/repo/spec?effective=2026-01-01",
1245                    LemmaBase::BASE_URL
1246                ))
1247            );
1248        }
1249
1250        #[test]
1251        fn url_for_id_returns_navigation_url_for_nested_path() {
1252            let registry = LemmaBase::new();
1253            let url = registry.url_for_id("@lemma/std/finance", None);
1254            assert_eq!(
1255                url,
1256                Some(format!("{}/@lemma/std/finance", LemmaBase::BASE_URL))
1257            );
1258        }
1259
1260        // -------------------------------------------------------------------
1261        // fetch_source tests (mock-based, no real HTTP calls)
1262        // -------------------------------------------------------------------
1263
1264        #[tokio::test]
1265        async fn fetch_source_returns_bundle_on_success() {
1266            let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1267                "spec org/my_spec\ndata x: 1",
1268            )));
1269
1270            let bundle = registry.fetch_source("@org/my_spec").await.unwrap();
1271
1272            assert_eq!(bundle.lemma_source, "spec org/my_spec\ndata x: 1");
1273            assert_eq!(bundle.source_type.to_string(), "@org/my_spec");
1274        }
1275
1276        #[tokio::test]
1277        async fn fetch_source_passes_correct_url_to_fetcher() {
1278            let captured_url = Arc::new(Mutex::new(String::new()));
1279            let captured = captured_url.clone();
1280            let mock = MockHttpFetcher::with_handler(move |url| {
1281                *captured.lock().unwrap() = url.to_string();
1282                Ok("spec test/spec\ndata x: 1".to_string())
1283            });
1284            let registry = LemmaBase::with_fetcher(Box::new(mock));
1285
1286            let _ = registry.fetch_source("@user/workspace/somespec").await;
1287
1288            assert_eq!(
1289                *captured_url.lock().unwrap(),
1290                format!("{}/@user/workspace/somespec.lemma", LemmaBase::BASE_URL)
1291            );
1292        }
1293
1294        #[tokio::test]
1295        async fn fetch_source_maps_http_404_to_not_found() {
1296            let registry =
1297                LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(404)));
1298
1299            let err = registry.fetch_source("@org/missing").await.unwrap_err();
1300
1301            assert_eq!(err.kind, RegistryErrorKind::NotFound);
1302            assert!(
1303                err.message.contains("HTTP 404"),
1304                "Expected 'HTTP 404' in: {}",
1305                err.message
1306            );
1307            assert!(
1308                err.message.contains("@org/missing"),
1309                "Expected '@org/missing' in: {}",
1310                err.message
1311            );
1312        }
1313
1314        #[tokio::test]
1315        async fn fetch_source_maps_http_500_to_server_error() {
1316            let registry =
1317                LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(500)));
1318
1319            let err = registry.fetch_source("@org/broken").await.unwrap_err();
1320
1321            assert_eq!(err.kind, RegistryErrorKind::ServerError);
1322            assert!(
1323                err.message.contains("HTTP 500"),
1324                "Expected 'HTTP 500' in: {}",
1325                err.message
1326            );
1327        }
1328
1329        #[tokio::test]
1330        async fn fetch_source_maps_http_401_to_unauthorized() {
1331            let registry =
1332                LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(401)));
1333
1334            let err = registry.fetch_source("@org/secret").await.unwrap_err();
1335
1336            assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1337            assert!(err.message.contains("HTTP 401"));
1338        }
1339
1340        #[tokio::test]
1341        async fn fetch_source_maps_http_403_to_unauthorized() {
1342            let registry =
1343                LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(403)));
1344
1345            let err = registry.fetch_source("@org/private").await.unwrap_err();
1346
1347            assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
1348            assert!(
1349                err.message.contains("HTTP 403"),
1350                "Expected 'HTTP 403' in: {}",
1351                err.message
1352            );
1353        }
1354
1355        #[tokio::test]
1356        async fn fetch_source_maps_unexpected_status_to_other() {
1357            let registry =
1358                LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_failing_with_status(418)));
1359
1360            let err = registry.fetch_source("@org/teapot").await.unwrap_err();
1361
1362            assert_eq!(err.kind, RegistryErrorKind::Other);
1363            assert!(err.message.contains("HTTP 418"));
1364        }
1365
1366        #[tokio::test]
1367        async fn fetch_source_maps_network_error_to_network_error_kind() {
1368            let registry = LemmaBase::with_fetcher(Box::new(
1369                MockHttpFetcher::always_failing_with_network_error("connection refused"),
1370            ));
1371
1372            let err = registry.fetch_source("@org/unreachable").await.unwrap_err();
1373
1374            assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1375            assert!(
1376                err.message.contains("connection refused"),
1377                "Expected 'connection refused' in: {}",
1378                err.message
1379            );
1380            assert!(
1381                err.message.contains("@org/unreachable"),
1382                "Expected '@org/unreachable' in: {}",
1383                err.message
1384            );
1385        }
1386
1387        #[tokio::test]
1388        async fn fetch_source_maps_dns_error_to_network_error_kind() {
1389            let registry = LemmaBase::with_fetcher(Box::new(
1390                MockHttpFetcher::always_failing_with_network_error(
1391                    "dns error: failed to lookup address",
1392                ),
1393            ));
1394
1395            let err = registry.fetch_source("@org/spec").await.unwrap_err();
1396
1397            assert_eq!(err.kind, RegistryErrorKind::NetworkError);
1398            assert!(
1399                err.message.contains("dns error"),
1400                "Expected 'dns error' in: {}",
1401                err.message
1402            );
1403            assert!(
1404                err.message.contains("Failed to reach LemmaBase"),
1405                "Expected 'Failed to reach LemmaBase' in: {}",
1406                err.message
1407            );
1408        }
1409
1410        // -------------------------------------------------------------------
1411        // Registry trait delegation tests (mock-based)
1412        // -------------------------------------------------------------------
1413
1414        #[tokio::test]
1415        async fn get_delegates_to_fetch_source() {
1416            let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning(
1417                "spec org/resolved\ndata a: 1",
1418            )));
1419
1420            let bundle = registry.get("@org/resolved").await.unwrap();
1421
1422            assert_eq!(bundle.lemma_source, "spec org/resolved\ndata a: 1");
1423            assert_eq!(bundle.source_type.to_string(), "@org/resolved");
1424        }
1425
1426        #[tokio::test]
1427        async fn fetch_source_returns_empty_body_as_valid_bundle() {
1428            let registry = LemmaBase::with_fetcher(Box::new(MockHttpFetcher::always_returning("")));
1429
1430            let bundle = registry.fetch_source("@org/empty").await.unwrap();
1431
1432            assert_eq!(bundle.lemma_source, "");
1433            assert_eq!(bundle.source_type.to_string(), "@org/empty");
1434        }
1435    }
1436}