Skip to main content

lemma/
registry.rs

1//! Registry types and sans-IO resolution for external `@` repository references.
2//!
3//! The engine owns registry policy (URL construction, status mapping, transitive
4//! resolve). Hosts own the socket: they answer [`Fetch`] with [`HttpResponse`].
5
6use crate::engine::Context;
7use crate::error::{Error, RegistryErrorKind};
8use crate::limits::ResourceLimits;
9use crate::parsing::ast::{DateTimeValue, LemmaRepository, RepositoryQualifier};
10use crate::parsing::source::{Source, SourceType};
11use serde::Serialize;
12use std::collections::{HashMap, HashSet, VecDeque};
13use std::sync::Arc;
14
15// ---------------------------------------------------------------------------
16// Wire types
17// ---------------------------------------------------------------------------
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20pub struct Header {
21    pub name: String,
22    pub value: String,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
26pub struct Fetch {
27    pub repository: String,
28    pub url: String,
29    pub headers: Vec<Header>,
30}
31
32#[derive(Debug, Clone)]
33pub struct HttpResponse {
34    pub status: u16,
35    pub headers: Vec<Header>,
36    pub body: String,
37}
38
39#[derive(Debug, Clone)]
40pub struct TransportFailure {
41    pub message: String,
42}
43
44#[derive(Debug, Clone)]
45pub struct RegistryBundle {
46    pub repository: String,
47    pub source: String,
48}
49
50#[derive(Debug, Clone)]
51pub struct RegistryError {
52    pub kind: RegistryErrorKind,
53    pub message: String,
54}
55
56/// Result of [`Install`]: LemmaBase repository source text and normalized id.
57#[derive(Debug, Clone, Serialize)]
58pub struct RepositoryInstallResult {
59    pub source: String,
60    pub id: String,
61}
62
63/// Synchronous driver for Rust hosts. wasm drives the steps itself with async fetch.
64pub trait HttpTransport {
65    fn get(&self, fetch: &Fetch) -> Result<HttpResponse, TransportFailure>;
66}
67
68/// A registry is a request builder and response interpreter. It never performs I/O.
69pub trait Registry: Send + Sync {
70    /// Build the fetch request for an already-validated registry qualifier.
71    fn fetch_for(&self, qualifier: &RepositoryQualifier) -> Result<Fetch, Error>;
72    fn bundle_from(
73        &self,
74        name: &str,
75        response: Result<HttpResponse, TransportFailure>,
76    ) -> Result<RegistryBundle, RegistryError>;
77    fn navigation_url(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String>;
78}
79
80// ---------------------------------------------------------------------------
81// LemmaBase
82// ---------------------------------------------------------------------------
83
84/// LemmaBase registry: bound to `https://lemmabase.com`. Not configurable.
85pub struct LemmaBase;
86
87impl LemmaBase {
88    const BASE_URL: &'static str = "https://lemmabase.com";
89
90    fn display_id(name: &str, effective: Option<&DateTimeValue>) -> String {
91        match effective {
92            None => name.to_string(),
93            Some(d) => format!("{name} {d}"),
94        }
95    }
96
97    fn kind_from_status(status: u16) -> RegistryErrorKind {
98        match status {
99            404 => RegistryErrorKind::NotFound,
100            401 | 403 => RegistryErrorKind::Unauthorized,
101            500..=599 => RegistryErrorKind::ServerError,
102            _ => RegistryErrorKind::Other,
103        }
104    }
105}
106
107impl Registry for LemmaBase {
108    fn fetch_for(&self, qualifier: &RepositoryQualifier) -> Result<Fetch, Error> {
109        if !qualifier.is_registry() {
110            return Err(Error::registry(
111                format!(
112                    "Registry identifier must start with '@' (got '{}')",
113                    qualifier.name
114                ),
115                volatile_origin_source(),
116                qualifier.name.clone(),
117                RegistryErrorKind::Other,
118                Some("Use a LemmaBase repository id like @owner/name".to_string()),
119                None,
120                None,
121            ));
122        }
123        let id = qualifier.name.clone();
124        Ok(Fetch {
125            url: format!("{}/{}.lemma", Self::BASE_URL, id),
126            repository: id,
127            headers: Vec::new(),
128        })
129    }
130
131    fn bundle_from(
132        &self,
133        name: &str,
134        response: Result<HttpResponse, TransportFailure>,
135    ) -> Result<RegistryBundle, RegistryError> {
136        let display = Self::display_id(name, None);
137        match response {
138            Err(failure) => Err(RegistryError {
139                kind: RegistryErrorKind::NetworkError,
140                message: format!(
141                    "Failed to reach LemmaBase for '{display}': {}",
142                    failure.message
143                ),
144            }),
145            Ok(response) => {
146                if (200..300).contains(&response.status) {
147                    return Ok(RegistryBundle {
148                        repository: name.to_string(),
149                        source: response.body,
150                    });
151                }
152                let kind = Self::kind_from_status(response.status);
153                Err(RegistryError {
154                    kind,
155                    message: format!(
156                        "LemmaBase returned HTTP {} for '{}'",
157                        response.status, display
158                    ),
159                })
160            }
161        }
162    }
163
164    fn navigation_url(&self, name: &str, effective: Option<&DateTimeValue>) -> Option<String> {
165        let qualifier = crate::parsing::parse_repository_qualifier_str(name).ok()?;
166        if !qualifier.is_registry() {
167            return None;
168        }
169        let base = format!("{}/{}", Self::BASE_URL, qualifier.name);
170        Some(match effective {
171            None => base,
172            Some(d) => format!("{base}?effective={d}"),
173        })
174    }
175}
176
177// ---------------------------------------------------------------------------
178// Registries catalogue
179// ---------------------------------------------------------------------------
180
181/// The registries the host composes. [`Default`] is LemmaBase alone.
182///
183/// LemmaBase is a fixed member: adding registries never retargets it.
184pub struct Registries {
185    lemmabase: LemmaBase,
186}
187
188impl Default for Registries {
189    fn default() -> Self {
190        Self {
191            lemmabase: LemmaBase,
192        }
193    }
194}
195
196impl Registries {
197    pub fn registry_for(&self, qualifier: &RepositoryQualifier) -> &dyn Registry {
198        assert!(
199            qualifier.is_registry(),
200            "BUG: registry_for called with non-registry qualifier '{}'",
201            qualifier.name
202        );
203        &self.lemmabase
204    }
205}
206
207// ---------------------------------------------------------------------------
208// Suggestions / error mapping
209// ---------------------------------------------------------------------------
210
211fn install_failure_suggestion(kind: &RegistryErrorKind) -> Option<String> {
212    match kind {
213        RegistryErrorKind::NotFound => Some(
214            "Check that the repository qualifier is spelled correctly and that the repository exists on LemmaBase."
215                .to_string(),
216        ),
217        RegistryErrorKind::Unauthorized => Some(
218            "Check your authentication credentials or permissions for this repository.".to_string(),
219        ),
220        RegistryErrorKind::NetworkError => Some("Check your network connection.".to_string()),
221        RegistryErrorKind::ServerError => {
222            Some("LemmaBase returned an internal error. Try again later.".to_string())
223        }
224        RegistryErrorKind::Other => None,
225    }
226}
227
228fn resolve_failure_suggestion(kind: &RegistryErrorKind) -> Option<String> {
229    match kind {
230        RegistryErrorKind::NotFound => Some(
231            "Check that the repository qualifier is spelled correctly and that the repository exists on the registry."
232                .to_string(),
233        ),
234        RegistryErrorKind::Unauthorized => Some(
235            "Check your authentication credentials or permissions for this registry.".to_string(),
236        ),
237        RegistryErrorKind::NetworkError => Some("Check your network connection.".to_string()),
238        RegistryErrorKind::ServerError => {
239            Some("The registry server returned an internal error. Try again later.".to_string())
240        }
241        RegistryErrorKind::Other => None,
242    }
243}
244
245fn volatile_origin_source() -> Source {
246    Source::new(
247        SourceType::Volatile,
248        crate::parsing::ast::Span {
249            start: 0,
250            end: 0,
251            line: 1,
252            col: 1,
253        },
254    )
255}
256
257fn registry_error_as_install_error(error: RegistryError, name: &str) -> Error {
258    let suggestion = install_failure_suggestion(&error.kind);
259    Error::registry(
260        error.message,
261        volatile_origin_source(),
262        name.to_string(),
263        error.kind,
264        suggestion,
265        None,
266        None,
267    )
268}
269
270fn parse_validate_dependency(id: &str, source: &str, limits: &ResourceLimits) -> Result<(), Error> {
271    let parsed = crate::parsing::parse(source, SourceType::Dependency(id.to_string()), limits)?;
272    for (parsed_repo, _) in &parsed.repositories {
273        if let Some(declared) = parsed_repo.name.as_deref() {
274            if declared != id {
275                return Err(Error::registry(
276                    format!(
277                        "Registry bundle declares repo '{declared}' but '{id}' was requested"
278                    ),
279                    volatile_origin_source(),
280                    id.to_string(),
281                    RegistryErrorKind::Other,
282                    Some(
283                        "The `repo` declaration in the downloaded source must match the requested repository id"
284                            .to_string(),
285                    ),
286                    None,
287                    None,
288                ));
289            }
290        }
291    }
292    Ok(())
293}
294
295// ---------------------------------------------------------------------------
296// Install step machine
297// ---------------------------------------------------------------------------
298
299pub enum InstallStep {
300    Fetch(Fetch),
301    Finished(Result<RepositoryInstallResult, Error>),
302}
303
304enum InstallState {
305    Awaiting { repository: String },
306    Done,
307}
308
309pub struct Install<'r> {
310    registries: &'r Registries,
311    limits: ResourceLimits,
312    state: InstallState,
313}
314
315impl<'r> Install<'r> {
316    pub fn start(
317        registries: &'r Registries,
318        repository: &str,
319        limits: ResourceLimits,
320    ) -> (Self, InstallStep) {
321        let qualifier = match crate::parsing::parse_repository_qualifier_str(repository) {
322            Ok(q) => q,
323            Err(e) => {
324                return (
325                    Self {
326                        registries,
327                        limits,
328                        state: InstallState::Done,
329                    },
330                    InstallStep::Finished(Err(Error::registry(
331                        e.message().to_string(),
332                        volatile_origin_source(),
333                        repository.trim().to_string(),
334                        RegistryErrorKind::Other,
335                        Some("Use a LemmaBase repository id like @owner/name".to_string()),
336                        None,
337                        None,
338                    ))),
339                );
340            }
341        };
342        if !qualifier.is_registry() {
343            let id = qualifier.name.clone();
344            return (
345                Self {
346                    registries,
347                    limits,
348                    state: InstallState::Done,
349                },
350                InstallStep::Finished(Err(Error::registry(
351                    format!("Registry identifier must start with '@' (got '{id}')"),
352                    volatile_origin_source(),
353                    id,
354                    RegistryErrorKind::Other,
355                    Some("Use a LemmaBase repository id like @owner/name".to_string()),
356                    None,
357                    None,
358                ))),
359            );
360        }
361        let registry = registries.registry_for(&qualifier);
362        match registry.fetch_for(&qualifier) {
363            Ok(fetch) => {
364                let repository = fetch.repository.clone();
365                (
366                    Self {
367                        registries,
368                        limits,
369                        state: InstallState::Awaiting { repository },
370                    },
371                    InstallStep::Fetch(fetch),
372                )
373            }
374            Err(e) => (
375                Self {
376                    registries,
377                    limits,
378                    state: InstallState::Done,
379                },
380                InstallStep::Finished(Err(e)),
381            ),
382        }
383    }
384
385    pub fn respond(&mut self, response: Result<HttpResponse, TransportFailure>) -> InstallStep {
386        let repository = match &self.state {
387            InstallState::Awaiting { repository } => repository.clone(),
388            InstallState::Done => {
389                panic!("BUG: Install::respond called when not awaiting a response")
390            }
391        };
392        let qualifier = RepositoryQualifier::new(repository.clone());
393        let registry = self.registries.registry_for(&qualifier);
394        let step = match registry.bundle_from(&repository, response) {
395            Ok(bundle) => {
396                match parse_validate_dependency(&bundle.repository, &bundle.source, &self.limits) {
397                    Ok(()) => InstallStep::Finished(Ok(RepositoryInstallResult {
398                        source: bundle.source,
399                        id: bundle.repository,
400                    })),
401                    Err(e) => InstallStep::Finished(Err(e)),
402                }
403            }
404            Err(error) => {
405                InstallStep::Finished(Err(registry_error_as_install_error(error, &repository)))
406            }
407        };
408        self.state = InstallState::Done;
409        step
410    }
411
412    pub fn run<T: HttpTransport>(
413        registries: &Registries,
414        repository: &str,
415        transport: &T,
416        limits: ResourceLimits,
417    ) -> Result<RepositoryInstallResult, Error> {
418        let (mut install, step) = Install::start(registries, repository, limits);
419        match step {
420            InstallStep::Finished(result) => result,
421            InstallStep::Fetch(fetch) => {
422                let response = transport.get(&fetch);
423                match install.respond(response) {
424                    InstallStep::Finished(result) => result,
425                    InstallStep::Fetch(_) => {
426                        panic!("BUG: Install yielded a second Fetch")
427                    }
428                }
429            }
430        }
431    }
432}
433
434// ---------------------------------------------------------------------------
435// Resolve step machine
436// ---------------------------------------------------------------------------
437
438pub enum ResolveStep {
439    Fetch(Fetch),
440    Finished(Result<(), Vec<Error>>),
441}
442
443struct PendingReference {
444    repository: RepositoryQualifier,
445    source: Source,
446}
447
448pub struct Resolve<'a> {
449    registries: &'a Registries,
450    ctx: &'a mut Context,
451    sources: &'a mut HashMap<SourceType, String>,
452    limits: &'a ResourceLimits,
453    already_requested: HashSet<String>,
454    pending: VecDeque<PendingReference>,
455    awaiting: Option<PendingReference>,
456    round_errors: Vec<Error>,
457}
458
459impl<'a> Resolve<'a> {
460    pub fn start(
461        registries: &'a Registries,
462        ctx: &'a mut Context,
463        sources: &'a mut HashMap<SourceType, String>,
464        limits: &'a ResourceLimits,
465    ) -> (Self, ResolveStep) {
466        let mut resolve = Self {
467            registries,
468            ctx,
469            sources,
470            limits,
471            already_requested: HashSet::new(),
472            pending: VecDeque::new(),
473            awaiting: None,
474            round_errors: Vec::new(),
475        };
476        let step = resolve.begin_round_or_finish();
477        (resolve, step)
478    }
479
480    pub fn respond(&mut self, response: Result<HttpResponse, TransportFailure>) -> ResolveStep {
481        let reference = self
482            .awaiting
483            .take()
484            .expect("BUG: Resolve::respond called when not awaiting a response");
485
486        let registry = self.registries.registry_for(&reference.repository);
487        match registry.bundle_from(&reference.repository.name, response) {
488            Ok(bundle) => {
489                let source_type = SourceType::Dependency(reference.repository.name.clone());
490                self.sources
491                    .insert(source_type.clone(), bundle.source.clone());
492
493                match crate::parsing::parse(&bundle.source, source_type.clone(), self.limits) {
494                    Ok(parsed) => {
495                        for (parsed_repo, specs) in parsed.repositories {
496                            if let Some(declared) = parsed_repo.name.as_deref() {
497                                if declared != reference.repository.name.as_str() {
498                                    self.round_errors.push(Error::registry(
499                                        format!(
500                                            "Registry bundle declares repo '{declared}' but '{}' was requested",
501                                            reference.repository.name
502                                        ),
503                                        reference.source.clone(),
504                                        reference.repository.name.clone(),
505                                        RegistryErrorKind::Other,
506                                        Some(
507                                            "The `repo` declaration in the downloaded source must match the requested repository id"
508                                                .to_string(),
509                                        ),
510                                        None,
511                                        None,
512                                    ));
513                                    continue;
514                                }
515                            }
516                            let repo_name = parsed_repo
517                                .name
518                                .clone()
519                                .unwrap_or_else(|| reference.repository.name.clone());
520                            let dep_id = reference.repository.name.clone();
521                            let header = LemmaRepository::new(Some(repo_name))
522                                .with_dependency(dep_id)
523                                .with_start_line(parsed_repo.start_line)
524                                .with_source_type(source_type.clone());
525                            let repository_arc = Arc::new(header);
526                            for spec in specs {
527                                if let Err(es) =
528                                    self.ctx.insert_spec(Arc::clone(&repository_arc), spec)
529                                {
530                                    self.round_errors.extend(es);
531                                }
532                            }
533                        }
534                    }
535                    Err(e) => {
536                        self.round_errors.push(e);
537                        return ResolveStep::Finished(Err(std::mem::take(&mut self.round_errors)));
538                    }
539                }
540            }
541            Err(error) => {
542                let suggestion = resolve_failure_suggestion(&error.kind);
543                let spec_context = self
544                    .ctx
545                    .iter()
546                    .find(|s| s.source_type == Some(reference.source.source_type.clone()));
547                self.round_errors.push(Error::registry(
548                    error.message,
549                    reference.source.clone(),
550                    reference.repository.name.clone(),
551                    error.kind,
552                    suggestion,
553                    spec_context,
554                    None,
555                ));
556            }
557        }
558
559        self.next_fetch_or_advance()
560    }
561
562    pub fn run<T: HttpTransport>(
563        registries: &Registries,
564        ctx: &mut Context,
565        sources: &mut HashMap<SourceType, String>,
566        limits: &ResourceLimits,
567        transport: &T,
568    ) -> Result<(), Vec<Error>> {
569        let (mut resolve, mut step) = Resolve::start(registries, ctx, sources, limits);
570        loop {
571            match step {
572                ResolveStep::Finished(result) => return result,
573                ResolveStep::Fetch(fetch) => {
574                    let response = transport.get(&fetch);
575                    step = resolve.respond(response);
576                }
577            }
578        }
579    }
580
581    fn begin_round_or_finish(&mut self) -> ResolveStep {
582        let unresolved = find_missing_repositories(self.ctx, &self.already_requested);
583        if unresolved.is_empty() {
584            if self.round_errors.is_empty() {
585                return ResolveStep::Finished(Ok(()));
586            }
587            return ResolveStep::Finished(Err(std::mem::take(&mut self.round_errors)));
588        }
589        self.pending = unresolved.into();
590        self.next_fetch_or_advance()
591    }
592
593    fn next_fetch_or_advance(&mut self) -> ResolveStep {
594        while let Some(reference) = self.pending.pop_front() {
595            if self.already_requested.contains(&reference.repository.name) {
596                continue;
597            }
598            self.already_requested
599                .insert(reference.repository.name.clone());
600            let registry = self.registries.registry_for(&reference.repository);
601            match registry.fetch_for(&reference.repository) {
602                Ok(fetch) => {
603                    self.awaiting = Some(reference);
604                    return ResolveStep::Fetch(fetch);
605                }
606                Err(e) => {
607                    self.round_errors.push(e);
608                }
609            }
610        }
611
612        if !self.round_errors.is_empty() {
613            return ResolveStep::Finished(Err(std::mem::take(&mut self.round_errors)));
614        }
615        self.begin_round_or_finish()
616    }
617}
618
619fn collect_repository_qualifiers_from_spec_ref(
620    spec_ref: &crate::parsing::ast::SpecRef,
621    source: &Source,
622    ctx: &Context,
623    already_requested: &HashSet<String>,
624    seen_in_this_round: &mut HashSet<String>,
625    out: &mut Vec<PendingReference>,
626) {
627    let Some(qualifier) = spec_ref.repository.as_ref() else {
628        return;
629    };
630    if !qualifier.is_registry() {
631        return;
632    }
633    if ctx.find_repository(&qualifier.name).is_some() {
634        return;
635    }
636    if already_requested.contains(&qualifier.name) {
637        return;
638    }
639    if !seen_in_this_round.insert(qualifier.name.clone()) {
640        return;
641    }
642    out.push(PendingReference {
643        repository: qualifier.clone(),
644        source: source.clone(),
645    });
646}
647
648fn find_missing_repositories(
649    ctx: &Context,
650    already_requested: &HashSet<String>,
651) -> Vec<PendingReference> {
652    let mut unresolved: Vec<PendingReference> = Vec::new();
653    let mut seen_in_this_round: HashSet<String> = HashSet::new();
654
655    for spec in ctx.iter() {
656        for data in &spec.data {
657            if let crate::parsing::ast::DataValue::Import { spec_ref, .. } = &data.value {
658                collect_repository_qualifiers_from_spec_ref(
659                    spec_ref,
660                    &data.source_location,
661                    ctx,
662                    already_requested,
663                    &mut seen_in_this_round,
664                    &mut unresolved,
665                );
666            }
667        }
668    }
669
670    unresolved
671}
672
673// ---------------------------------------------------------------------------
674// Tests
675// ---------------------------------------------------------------------------
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use crate::engine::Context;
681    use crate::literals::DateGranularity;
682
683    struct MapTransport {
684        bodies: HashMap<String, String>,
685        last_url: std::cell::RefCell<Option<String>>,
686        request_count: std::cell::Cell<usize>,
687    }
688
689    impl MapTransport {
690        fn new(bodies: HashMap<String, String>) -> Self {
691            Self {
692                bodies,
693                last_url: std::cell::RefCell::new(None),
694                request_count: std::cell::Cell::new(0),
695            }
696        }
697    }
698
699    impl HttpTransport for MapTransport {
700        fn get(&self, fetch: &Fetch) -> Result<HttpResponse, TransportFailure> {
701            assert!(
702                fetch.url.starts_with("https://lemmabase.com/"),
703                "Fetch.url must target LemmaBase, got {}",
704                fetch.url
705            );
706            self.request_count.set(self.request_count.get() + 1);
707            *self.last_url.borrow_mut() = Some(fetch.url.clone());
708            match self.bodies.get(&fetch.repository) {
709                Some(body) => Ok(HttpResponse {
710                    status: 200,
711                    headers: Vec::new(),
712                    body: body.clone(),
713                }),
714                None => Ok(HttpResponse {
715                    status: 404,
716                    headers: Vec::new(),
717                    body: String::new(),
718                }),
719            }
720        }
721    }
722
723    fn context_with_embedded_stdlib() -> Context {
724        use crate::engine::EMBEDDED_STDLIB_REPOSITORY;
725        use crate::parsing::ast::LemmaRepository;
726        use crate::stdlib::UNITS_LEMMA;
727
728        let mut ctx = Context::new();
729        let source_type = SourceType::Dependency(EMBEDDED_STDLIB_REPOSITORY.to_string());
730        let parsed = crate::parse(UNITS_LEMMA, source_type, &ResourceLimits::default())
731            .expect("BUG: embedded stdlib must parse");
732        for (parsed_repo, specs) in &parsed.repositories {
733            let repository_arc = Arc::new(
734                LemmaRepository::new(
735                    parsed_repo
736                        .name
737                        .clone()
738                        .or_else(|| Some(EMBEDDED_STDLIB_REPOSITORY.to_string())),
739                )
740                .with_dependency(EMBEDDED_STDLIB_REPOSITORY)
741                .with_start_line(parsed_repo.start_line),
742            );
743            for spec in specs {
744                ctx.insert_spec(Arc::clone(&repository_arc), spec.clone())
745                    .expect("BUG: embedded stdlib must load");
746            }
747        }
748        ctx
749    }
750
751    // -----------------------------------------------------------------------
752    // LemmaBase fetch_for / navigation_url
753    // -----------------------------------------------------------------------
754
755    fn qualifier(raw: &str) -> RepositoryQualifier {
756        crate::parsing::parse_repository_qualifier_str(raw)
757            .unwrap_or_else(|e| panic!("BUG: test qualifier {raw:?} must parse: {}", e.message()))
758    }
759
760    #[test]
761    fn fetch_for_builds_lemmabase_url() {
762        let fetch = LemmaBase
763            .fetch_for(&qualifier("@org/project"))
764            .expect("fetch_for");
765        assert_eq!(fetch.repository, "@org/project");
766        assert_eq!(fetch.url, "https://lemmabase.com/@org/project.lemma");
767        assert!(fetch.headers.is_empty());
768    }
769
770    #[test]
771    fn fetch_for_accepts_parsed_whitespace_trimmed_id() {
772        let fetch = LemmaBase
773            .fetch_for(&qualifier("  @org/project  "))
774            .expect("fetch_for");
775        assert_eq!(fetch.repository, "@org/project");
776        assert_eq!(fetch.url, "https://lemmabase.com/@org/project.lemma");
777    }
778
779    #[test]
780    fn fetch_for_rejects_id_without_at() {
781        let err = LemmaBase
782            .fetch_for(&RepositoryQualifier::new("org/project"))
783            .expect_err("no @");
784        assert_eq!(err.kind(), crate::ErrorKind::Registry);
785    }
786
787    #[test]
788    fn navigation_url_without_effective() {
789        let url = LemmaBase.navigation_url("@org/spec", None);
790        assert_eq!(url, Some("https://lemmabase.com/@org/spec".to_string()));
791    }
792
793    #[test]
794    fn navigation_url_with_effective() {
795        let effective = DateTimeValue {
796            year: 2026,
797            month: 1,
798            day: 15,
799            hour: 0,
800            minute: 0,
801            second: 0,
802            microsecond: 0,
803            timezone: None,
804            granularity: DateGranularity::Full,
805        };
806        let url = LemmaBase.navigation_url("@org/spec", Some(&effective));
807        assert_eq!(
808            url,
809            Some("https://lemmabase.com/@org/spec?effective=2026-01-15".to_string())
810        );
811    }
812
813    #[test]
814    fn navigation_url_rejects_non_at_id() {
815        assert!(LemmaBase.navigation_url("iso/countries", None).is_none());
816    }
817
818    // -----------------------------------------------------------------------
819    // bundle_from status mapping
820    // -----------------------------------------------------------------------
821
822    #[test]
823    fn bundle_from_maps_404_to_not_found() {
824        let err = LemmaBase
825            .bundle_from(
826                "@missing/repo",
827                Ok(HttpResponse {
828                    status: 404,
829                    headers: Vec::new(),
830                    body: String::new(),
831                }),
832            )
833            .expect_err("404");
834        assert_eq!(err.kind, RegistryErrorKind::NotFound);
835    }
836
837    #[test]
838    fn bundle_from_maps_401_to_unauthorized() {
839        let err = LemmaBase
840            .bundle_from(
841                "@org/private",
842                Ok(HttpResponse {
843                    status: 401,
844                    headers: Vec::new(),
845                    body: String::new(),
846                }),
847            )
848            .expect_err("401");
849        assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
850    }
851
852    #[test]
853    fn bundle_from_maps_403_to_unauthorized() {
854        let err = LemmaBase
855            .bundle_from(
856                "@org/private",
857                Ok(HttpResponse {
858                    status: 403,
859                    headers: Vec::new(),
860                    body: String::new(),
861                }),
862            )
863            .expect_err("403");
864        assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
865    }
866
867    #[test]
868    fn bundle_from_maps_500_to_server_error() {
869        let err = LemmaBase
870            .bundle_from(
871                "@org/broken",
872                Ok(HttpResponse {
873                    status: 500,
874                    headers: Vec::new(),
875                    body: String::new(),
876                }),
877            )
878            .expect_err("500");
879        assert_eq!(err.kind, RegistryErrorKind::ServerError);
880    }
881
882    #[test]
883    fn bundle_from_maps_transport_failure_to_network_error() {
884        let err = LemmaBase
885            .bundle_from(
886                "@org/unreachable",
887                Err(TransportFailure {
888                    message: "connection refused".to_string(),
889                }),
890            )
891            .expect_err("transport");
892        assert_eq!(err.kind, RegistryErrorKind::NetworkError);
893    }
894
895    #[test]
896    fn bundle_from_maps_418_to_other() {
897        let err = LemmaBase
898            .bundle_from(
899                "@org/teapot",
900                Ok(HttpResponse {
901                    status: 418,
902                    headers: Vec::new(),
903                    body: String::new(),
904                }),
905            )
906            .expect_err("418");
907        assert_eq!(err.kind, RegistryErrorKind::Other);
908    }
909
910    // -----------------------------------------------------------------------
911    // Install
912    // -----------------------------------------------------------------------
913
914    #[test]
915    fn install_returns_bundle() {
916        let mut bodies = HashMap::new();
917        bodies.insert(
918            "@iso/countries".to_string(),
919            "repo @iso/countries\nspec alpha2\ndata code: text\n".to_string(),
920        );
921        let transport = MapTransport::new(bodies);
922        let registries = Registries::default();
923        let result = Install::run(
924            &registries,
925            "  @iso/countries  ",
926            &transport,
927            ResourceLimits::default(),
928        )
929        .expect("install");
930        assert_eq!(result.id, "@iso/countries");
931        assert!(result.source.contains("spec alpha2"));
932        assert_eq!(
933            transport.last_url.borrow().as_deref(),
934            Some("https://lemmabase.com/@iso/countries.lemma")
935        );
936    }
937
938    #[test]
939    fn install_rejects_empty_id() {
940        let transport = MapTransport::new(HashMap::new());
941        let registries = Registries::default();
942        let err = Install::run(&registries, "   ", &transport, ResourceLimits::default())
943            .expect_err("empty id");
944        assert_eq!(err.kind(), crate::ErrorKind::Registry);
945    }
946
947    #[test]
948    fn install_rejects_path_injection() {
949        let transport = MapTransport::new(HashMap::new());
950        let registries = Registries::default();
951        let err = Install::run(
952            &registries,
953            "@org/../secret",
954            &transport,
955            ResourceLimits::default(),
956        )
957        .expect_err("path injection");
958        assert_eq!(err.kind(), crate::ErrorKind::Registry);
959    }
960
961    #[test]
962    fn install_rejects_repo_declaration_mismatch() {
963        let mut bodies = HashMap::new();
964        bodies.insert(
965            "@org/requested".to_string(),
966            "repo @org/other\n\nspec s\ndata v: 1\nrule r: v\n".to_string(),
967        );
968        let transport = MapTransport::new(bodies);
969        let registries = Registries::default();
970        let err = Install::run(
971            &registries,
972            "@org/requested",
973            &transport,
974            ResourceLimits::default(),
975        )
976        .expect_err("repo mismatch");
977        assert_eq!(err.kind(), crate::ErrorKind::Registry);
978        assert!(
979            err.message().contains("@org/other") && err.message().contains("@org/requested"),
980            "got: {}",
981            err.message()
982        );
983    }
984
985    #[test]
986    fn install_maps_not_found() {
987        let transport = MapTransport::new(HashMap::new());
988        let registries = Registries::default();
989        let err = Install::run(
990            &registries,
991            "@missing/repo",
992            &transport,
993            ResourceLimits::default(),
994        )
995        .expect_err("missing");
996        assert_eq!(err.kind(), crate::ErrorKind::Registry);
997        assert_eq!(err.registry_kind(), Some(RegistryErrorKind::NotFound));
998    }
999
1000    // -----------------------------------------------------------------------
1001    // Resolve
1002    // -----------------------------------------------------------------------
1003
1004    #[test]
1005    fn resolve_with_no_registry_references_returns_local_specs_unchanged() {
1006        let source = r#"spec example
1007data price: 100"#;
1008        let local_specs = crate::parse(source, SourceType::Volatile, &ResourceLimits::default())
1009            .unwrap()
1010            .into_flattened_specs();
1011        let mut store = context_with_embedded_stdlib();
1012        let local_repository = store.workspace();
1013        for spec in &local_specs {
1014            store
1015                .insert_spec(Arc::clone(&local_repository), spec.clone())
1016                .unwrap();
1017        }
1018        let mut sources: HashMap<SourceType, String> = HashMap::new();
1019        sources.insert(SourceType::Volatile, source.to_string());
1020
1021        let registries = Registries::default();
1022        let transport = MapTransport::new(HashMap::new());
1023        Resolve::run(
1024            &registries,
1025            &mut store,
1026            &mut sources,
1027            &ResourceLimits::default(),
1028            &transport,
1029        )
1030        .unwrap();
1031
1032        assert_eq!(
1033            store.iter().count(),
1034            2,
1035            "embedded spec units plus workspace example"
1036        );
1037        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1038        assert!(names.iter().any(|n| n == "example"));
1039        assert!(names.iter().any(|n| n == "units"));
1040    }
1041
1042    #[test]
1043    fn resolve_does_not_fetch_non_at_qualified_repositories() {
1044        let local_source = r#"spec burn_baby_burn
1045uses lemma units
1046rule x: 1 hour"#;
1047        let local_specs = crate::parse(
1048            local_source,
1049            SourceType::Volatile,
1050            &ResourceLimits::default(),
1051        )
1052        .unwrap()
1053        .into_flattened_specs();
1054        let mut store = Context::new();
1055        let local_repository = store.workspace();
1056        for spec in local_specs {
1057            store
1058                .insert_spec(Arc::clone(&local_repository), spec)
1059                .unwrap();
1060        }
1061        let mut sources: HashMap<SourceType, String> = HashMap::new();
1062        sources.insert(SourceType::Volatile, local_source.to_string());
1063
1064        let registries = Registries::default();
1065        let transport = MapTransport::new(HashMap::new());
1066        let result = Resolve::run(
1067            &registries,
1068            &mut store,
1069            &mut sources,
1070            &ResourceLimits::default(),
1071            &transport,
1072        );
1073
1074        assert!(
1075            result.is_ok(),
1076            "non-@ repository qualifiers must not be sent to the registry, got: {:?}",
1077            result.err()
1078        );
1079        assert!(transport.last_url.borrow().is_none());
1080    }
1081
1082    #[test]
1083    fn resolve_fetches_single_spec_from_registry() {
1084        let local_source = r#"spec main_spec
1085uses external: @org/project helper
1086rule value: external.quantity"#;
1087        let local_specs = crate::parse(
1088            local_source,
1089            SourceType::Volatile,
1090            &ResourceLimits::default(),
1091        )
1092        .unwrap()
1093        .into_flattened_specs();
1094        let mut store = context_with_embedded_stdlib();
1095        let local_repository = store.workspace();
1096        for spec in local_specs {
1097            store
1098                .insert_spec(Arc::clone(&local_repository), spec)
1099                .unwrap();
1100        }
1101        let mut sources: HashMap<SourceType, String> = HashMap::new();
1102        sources.insert(SourceType::Volatile, local_source.to_string());
1103
1104        let mut bodies = HashMap::new();
1105        bodies.insert(
1106            "@org/project".to_string(),
1107            "repo @org/project\nspec helper\ndata quantity: 42".to_string(),
1108        );
1109        let transport = MapTransport::new(bodies);
1110        let registries = Registries::default();
1111
1112        Resolve::run(
1113            &registries,
1114            &mut store,
1115            &mut sources,
1116            &ResourceLimits::default(),
1117            &transport,
1118        )
1119        .unwrap();
1120
1121        assert_eq!(store.iter().count(), 3);
1122        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1123        assert!(names.iter().any(|n| n == "main_spec"));
1124        assert!(names.iter().any(|n| n == "helper"));
1125        assert!(names.iter().any(|n| n == "units"));
1126        assert_eq!(
1127            transport.last_url.borrow().as_deref(),
1128            Some("https://lemmabase.com/@org/project.lemma")
1129        );
1130    }
1131
1132    #[test]
1133    fn resolve_registry_bundle_without_repo_decl_uses_reference_repository_name() {
1134        let local_source = r#"spec main_spec
1135uses external: @org/project helper
1136rule value: external.quantity"#;
1137        let local_specs = crate::parse(
1138            local_source,
1139            SourceType::Volatile,
1140            &ResourceLimits::default(),
1141        )
1142        .unwrap()
1143        .into_flattened_specs();
1144        let mut store = context_with_embedded_stdlib();
1145        let local_repository = store.workspace();
1146        for spec in local_specs {
1147            store
1148                .insert_spec(Arc::clone(&local_repository), spec)
1149                .unwrap();
1150        }
1151        let mut sources: HashMap<SourceType, String> = HashMap::new();
1152        sources.insert(SourceType::Volatile, local_source.to_string());
1153
1154        let mut bodies = HashMap::new();
1155        bodies.insert(
1156            "@org/project".to_string(),
1157            "spec helper\ndata quantity: 42".to_string(),
1158        );
1159        let transport = MapTransport::new(bodies);
1160        let registries = Registries::default();
1161
1162        Resolve::run(
1163            &registries,
1164            &mut store,
1165            &mut sources,
1166            &ResourceLimits::default(),
1167            &transport,
1168        )
1169        .unwrap();
1170
1171        assert!(store.find_repository("@org/project").is_some());
1172    }
1173
1174    #[test]
1175    fn resolve_fetches_transitive_dependencies() {
1176        let local_source = r#"spec main_spec
1177uses a: @org/a helper_a
1178rule value: a.x"#;
1179        let local_specs = crate::parse(
1180            local_source,
1181            SourceType::Volatile,
1182            &ResourceLimits::default(),
1183        )
1184        .unwrap()
1185        .into_flattened_specs();
1186        let mut store = context_with_embedded_stdlib();
1187        let local_repository = store.workspace();
1188        for spec in local_specs {
1189            store
1190                .insert_spec(Arc::clone(&local_repository), spec)
1191                .unwrap();
1192        }
1193        let mut sources: HashMap<SourceType, String> = HashMap::new();
1194        sources.insert(SourceType::Volatile, local_source.to_string());
1195
1196        let mut bodies = HashMap::new();
1197        bodies.insert(
1198            "@org/a".to_string(),
1199            "repo @org/a\nspec helper_a\nuses b: @org/b helper_b\ndata x: b.y".to_string(),
1200        );
1201        bodies.insert(
1202            "@org/b".to_string(),
1203            "repo @org/b\nspec helper_b\ndata y: 7".to_string(),
1204        );
1205        let transport = MapTransport::new(bodies);
1206        let registries = Registries::default();
1207
1208        Resolve::run(
1209            &registries,
1210            &mut store,
1211            &mut sources,
1212            &ResourceLimits::default(),
1213            &transport,
1214        )
1215        .unwrap();
1216
1217        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1218        assert!(names.iter().any(|n| n == "helper_a"));
1219        assert!(names.iter().any(|n| n == "helper_b"));
1220    }
1221
1222    #[test]
1223    fn resolve_handles_bundle_with_multiple_specs() {
1224        let local_source = r#"spec main_spec
1225uses a: @org/multi first
1226rule value: a.x"#;
1227        let local_specs = crate::parse(
1228            local_source,
1229            SourceType::Volatile,
1230            &ResourceLimits::default(),
1231        )
1232        .unwrap()
1233        .into_flattened_specs();
1234        let mut store = context_with_embedded_stdlib();
1235        let local_repository = store.workspace();
1236        for spec in local_specs {
1237            store
1238                .insert_spec(Arc::clone(&local_repository), spec)
1239                .unwrap();
1240        }
1241        let mut sources: HashMap<SourceType, String> = HashMap::new();
1242        sources.insert(SourceType::Volatile, local_source.to_string());
1243
1244        let mut bodies = HashMap::new();
1245        bodies.insert(
1246            "@org/multi".to_string(),
1247            "repo @org/multi\nspec first\ndata x: 1\nspec second\ndata y: 2".to_string(),
1248        );
1249        let transport = MapTransport::new(bodies);
1250        let registries = Registries::default();
1251
1252        Resolve::run(
1253            &registries,
1254            &mut store,
1255            &mut sources,
1256            &ResourceLimits::default(),
1257            &transport,
1258        )
1259        .unwrap();
1260
1261        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1262        assert!(names.iter().any(|n| n == "first"));
1263        assert!(names.iter().any(|n| n == "second"));
1264    }
1265
1266    #[test]
1267    fn resolve_returns_registry_error_when_registry_fails() {
1268        let local_source = r#"spec main_spec
1269uses external: @org/missing helper
1270rule value: external.quantity"#;
1271        let local_specs = crate::parse(
1272            local_source,
1273            SourceType::Volatile,
1274            &ResourceLimits::default(),
1275        )
1276        .unwrap()
1277        .into_flattened_specs();
1278        let mut store = context_with_embedded_stdlib();
1279        let local_repository = store.workspace();
1280        for spec in local_specs {
1281            store
1282                .insert_spec(Arc::clone(&local_repository), spec)
1283                .unwrap();
1284        }
1285        let mut sources: HashMap<SourceType, String> = HashMap::new();
1286        sources.insert(SourceType::Volatile, local_source.to_string());
1287
1288        let registries = Registries::default();
1289        let transport = MapTransport::new(HashMap::new());
1290        let errs = Resolve::run(
1291            &registries,
1292            &mut store,
1293            &mut sources,
1294            &ResourceLimits::default(),
1295            &transport,
1296        )
1297        .expect_err("missing");
1298        assert!(!errs.is_empty());
1299        assert_eq!(errs[0].kind(), crate::ErrorKind::Registry);
1300        assert_eq!(errs[0].registry_kind(), Some(RegistryErrorKind::NotFound));
1301    }
1302
1303    #[test]
1304    fn resolve_returns_all_registry_errors_when_multiple_repositories_fail() {
1305        let local_source = r#"spec main_spec
1306uses @org/example helper
1307uses @iso/countries alpha2
1308data country: alpha2.code"#;
1309        let local_specs = crate::parse(
1310            local_source,
1311            SourceType::Volatile,
1312            &ResourceLimits::default(),
1313        )
1314        .unwrap()
1315        .into_flattened_specs();
1316        let mut store = context_with_embedded_stdlib();
1317        let local_repository = store.workspace();
1318        for spec in local_specs {
1319            store
1320                .insert_spec(Arc::clone(&local_repository), spec)
1321                .unwrap();
1322        }
1323        let mut sources: HashMap<SourceType, String> = HashMap::new();
1324        sources.insert(SourceType::Volatile, local_source.to_string());
1325
1326        let registries = Registries::default();
1327        let transport = MapTransport::new(HashMap::new());
1328        let errs = Resolve::run(
1329            &registries,
1330            &mut store,
1331            &mut sources,
1332            &ResourceLimits::default(),
1333            &transport,
1334        )
1335        .expect_err("both missing");
1336        let identifiers: Vec<&str> = errs.iter().filter_map(|e| e.repository()).collect();
1337        assert!(
1338            identifiers.contains(&"@org/example"),
1339            "Should include repository error: {:?}",
1340            identifiers
1341        );
1342        assert!(
1343            identifiers.contains(&"@iso/countries"),
1344            "Should include data import repository error: {:?}",
1345            identifiers
1346        );
1347    }
1348
1349    #[test]
1350    fn resolve_does_not_request_same_repository_twice() {
1351        let local_source = r#"spec spec_one
1352uses a: @org/shared shared
1353
1354spec spec_two
1355uses b: @org/shared shared"#;
1356        let local_specs = crate::parse(
1357            local_source,
1358            SourceType::Volatile,
1359            &ResourceLimits::default(),
1360        )
1361        .unwrap()
1362        .into_flattened_specs();
1363        let mut store = context_with_embedded_stdlib();
1364        let local_repository = store.workspace();
1365        for spec in local_specs {
1366            store
1367                .insert_spec(Arc::clone(&local_repository), spec)
1368                .unwrap();
1369        }
1370        let mut sources: HashMap<SourceType, String> = HashMap::new();
1371        sources.insert(SourceType::Volatile, local_source.to_string());
1372
1373        let mut bodies = HashMap::new();
1374        bodies.insert(
1375            "@org/shared".to_string(),
1376            "repo @org/shared\nspec shared\ndata value: 1".to_string(),
1377        );
1378        let transport = MapTransport::new(bodies);
1379        let registries = Registries::default();
1380        Resolve::run(
1381            &registries,
1382            &mut store,
1383            &mut sources,
1384            &ResourceLimits::default(),
1385            &transport,
1386        )
1387        .unwrap();
1388
1389        assert_eq!(transport.request_count.get(), 1);
1390        assert_eq!(store.iter().count(), 4);
1391        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1392        assert!(names.iter().any(|n| n == "shared"));
1393        assert!(names.iter().any(|n| n == "units"));
1394    }
1395
1396    #[test]
1397    fn resolve_handles_data_import_from_registry() {
1398        let local_source = r#"spec main_spec
1399uses @iso/countries alpha2
1400data country: alpha2.code
1401data home: country"#;
1402        let local_specs = crate::parse(
1403            local_source,
1404            SourceType::Volatile,
1405            &ResourceLimits::default(),
1406        )
1407        .unwrap()
1408        .into_flattened_specs();
1409        let mut store = context_with_embedded_stdlib();
1410        let local_repository = store.workspace();
1411        for spec in local_specs {
1412            store
1413                .insert_spec(Arc::clone(&local_repository), spec)
1414                .unwrap();
1415        }
1416        let mut sources: HashMap<SourceType, String> = HashMap::new();
1417        sources.insert(SourceType::Volatile, local_source.to_string());
1418
1419        let mut bodies = HashMap::new();
1420        bodies.insert(
1421            "@iso/countries".to_string(),
1422            "repo @iso/countries\nspec alpha2\ndata code: text\n -> option \"NL\"".to_string(),
1423        );
1424        let transport = MapTransport::new(bodies);
1425        let registries = Registries::default();
1426        Resolve::run(
1427            &registries,
1428            &mut store,
1429            &mut sources,
1430            &ResourceLimits::default(),
1431            &transport,
1432        )
1433        .unwrap();
1434
1435        assert_eq!(store.iter().count(), 3);
1436        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1437        assert!(names.iter().any(|n| n == "main_spec"));
1438        assert!(names.iter().any(|n| n == "alpha2"));
1439        assert!(names.iter().any(|n| n == "units"));
1440    }
1441}