Skip to main content

lemma/
registry.rs

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