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