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