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