Skip to main content

lemma/
registry.rs

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