Skip to main content

lemma/
registry.rs

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