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