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
295fn respond_without_fetch_error() -> Error {
296    Error::request(
297        "Registry response supplied but no fetch is pending",
298        Some("Respond exactly once to each Fetch step; stop after Finished"),
299    )
300}
301
302// ---------------------------------------------------------------------------
303// Install step machine
304// ---------------------------------------------------------------------------
305
306pub enum InstallStep {
307    Fetch(Fetch),
308    Finished(Result<RepositoryInstallResult, Error>),
309}
310
311enum InstallState {
312    Awaiting { repository: String },
313    Done,
314}
315
316pub struct Install<'r> {
317    registries: &'r Registries,
318    limits: ResourceLimits,
319    state: InstallState,
320}
321
322impl<'r> Install<'r> {
323    pub fn start(
324        registries: &'r Registries,
325        repository: &str,
326        limits: ResourceLimits,
327    ) -> (Self, InstallStep) {
328        let qualifier = match crate::parsing::parse_repository_qualifier_str(repository) {
329            Ok(q) => q,
330            Err(e) => {
331                return (
332                    Self {
333                        registries,
334                        limits,
335                        state: InstallState::Done,
336                    },
337                    InstallStep::Finished(Err(Error::registry(
338                        e.message().to_string(),
339                        volatile_origin_source(),
340                        repository.trim().to_string(),
341                        RegistryErrorKind::Other,
342                        Some("Use a LemmaBase repository id like @owner/name".to_string()),
343                        None,
344                        None,
345                    ))),
346                );
347            }
348        };
349        if !qualifier.is_registry() {
350            let id = qualifier.name.clone();
351            return (
352                Self {
353                    registries,
354                    limits,
355                    state: InstallState::Done,
356                },
357                InstallStep::Finished(Err(Error::registry(
358                    format!("Registry identifier must start with '@' (got '{id}')"),
359                    volatile_origin_source(),
360                    id,
361                    RegistryErrorKind::Other,
362                    Some("Use a LemmaBase repository id like @owner/name".to_string()),
363                    None,
364                    None,
365                ))),
366            );
367        }
368        let registry = registries.registry_for(&qualifier);
369        match registry.fetch_for(&qualifier) {
370            Ok(fetch) => {
371                let repository = fetch.repository.clone();
372                (
373                    Self {
374                        registries,
375                        limits,
376                        state: InstallState::Awaiting { repository },
377                    },
378                    InstallStep::Fetch(fetch),
379                )
380            }
381            Err(e) => (
382                Self {
383                    registries,
384                    limits,
385                    state: InstallState::Done,
386                },
387                InstallStep::Finished(Err(e)),
388            ),
389        }
390    }
391
392    /// A response when no fetch is pending (the install already finished) yields
393    /// `Finished(Err)`: callers drive this machine from host code across SDK boundaries.
394    pub fn respond(&mut self, response: Result<HttpResponse, TransportFailure>) -> InstallStep {
395        let repository = match &self.state {
396            InstallState::Awaiting { repository } => repository.clone(),
397            InstallState::Done => return InstallStep::Finished(Err(respond_without_fetch_error())),
398        };
399        let qualifier = RepositoryQualifier::new(repository.clone());
400        let registry = self.registries.registry_for(&qualifier);
401        let step = match registry.bundle_from(&repository, response) {
402            Ok(bundle) => {
403                match parse_validate_dependency(&bundle.repository, &bundle.source, &self.limits) {
404                    Ok(()) => InstallStep::Finished(Ok(RepositoryInstallResult {
405                        source: bundle.source,
406                        id: bundle.repository,
407                    })),
408                    Err(e) => InstallStep::Finished(Err(e)),
409                }
410            }
411            Err(error) => {
412                InstallStep::Finished(Err(registry_error_as_install_error(error, &repository)))
413            }
414        };
415        self.state = InstallState::Done;
416        step
417    }
418
419    pub fn run<T: HttpTransport>(
420        registries: &Registries,
421        repository: &str,
422        transport: &T,
423        limits: ResourceLimits,
424    ) -> Result<RepositoryInstallResult, Error> {
425        let (mut install, step) = Install::start(registries, repository, limits);
426        match step {
427            InstallStep::Finished(result) => result,
428            InstallStep::Fetch(fetch) => {
429                let response = transport.get(&fetch);
430                match install.respond(response) {
431                    InstallStep::Finished(result) => result,
432                    InstallStep::Fetch(_) => {
433                        panic!("BUG: Install yielded a second Fetch")
434                    }
435                }
436            }
437        }
438    }
439}
440
441// ---------------------------------------------------------------------------
442// Resolve step machine
443// ---------------------------------------------------------------------------
444
445pub enum ResolveStep {
446    Fetch(Fetch),
447    Finished(Result<(), Vec<Error>>),
448}
449
450struct PendingReference {
451    repository: RepositoryQualifier,
452    source: Source,
453}
454
455pub struct Resolve<'a> {
456    registries: &'a Registries,
457    ctx: &'a mut Context,
458    sources: &'a mut HashMap<SourceType, String>,
459    limits: &'a ResourceLimits,
460    already_requested: HashSet<String>,
461    pending: VecDeque<PendingReference>,
462    awaiting: Option<PendingReference>,
463    round_errors: Vec<Error>,
464}
465
466impl<'a> Resolve<'a> {
467    pub fn start(
468        registries: &'a Registries,
469        ctx: &'a mut Context,
470        sources: &'a mut HashMap<SourceType, String>,
471        limits: &'a ResourceLimits,
472    ) -> (Self, ResolveStep) {
473        let mut resolve = Self {
474            registries,
475            ctx,
476            sources,
477            limits,
478            already_requested: HashSet::new(),
479            pending: VecDeque::new(),
480            awaiting: None,
481            round_errors: Vec::new(),
482        };
483        let step = resolve.next_fetch_or_advance();
484        (resolve, step)
485    }
486
487    /// A response when no fetch is pending (resolution already finished) yields
488    /// `Finished(Err)`: callers drive this machine from host code across SDK boundaries.
489    pub fn respond(&mut self, response: Result<HttpResponse, TransportFailure>) -> ResolveStep {
490        let Some(reference) = self.awaiting.take() else {
491            return ResolveStep::Finished(Err(vec![respond_without_fetch_error()]));
492        };
493
494        let registry = self.registries.registry_for(&reference.repository);
495        match registry.bundle_from(&reference.repository.name, response) {
496            Ok(bundle) => {
497                let source_type = SourceType::Dependency(reference.repository.name.clone());
498                self.sources
499                    .insert(source_type.clone(), bundle.source.clone());
500
501                match crate::parsing::parse(&bundle.source, source_type.clone(), self.limits) {
502                    Ok(parsed) => {
503                        for (parsed_repo, specs) in parsed.repositories {
504                            if let Some(declared) = parsed_repo.name.as_deref() {
505                                if declared != reference.repository.name.as_str() {
506                                    self.round_errors.push(Error::registry(
507                                        format!(
508                                            "Registry bundle declares repo '{declared}' but '{}' was requested",
509                                            reference.repository.name
510                                        ),
511                                        reference.source.clone(),
512                                        reference.repository.name.clone(),
513                                        RegistryErrorKind::Other,
514                                        Some(
515                                            "The `repo` declaration in the downloaded source must match the requested repository id"
516                                                .to_string(),
517                                        ),
518                                        None,
519                                        None,
520                                    ));
521                                    continue;
522                                }
523                            }
524                            let repo_name = parsed_repo
525                                .name
526                                .clone()
527                                .unwrap_or_else(|| reference.repository.name.clone());
528                            let dep_id = reference.repository.name.clone();
529                            let header = LemmaRepository::new(Some(repo_name))
530                                .with_dependency(dep_id)
531                                .with_start_line(parsed_repo.start_line)
532                                .with_source_type(source_type.clone());
533                            let repository_arc = Arc::new(header);
534                            for spec in specs {
535                                if let Err(es) =
536                                    self.ctx.insert_spec(Arc::clone(&repository_arc), spec)
537                                {
538                                    self.round_errors.extend(es);
539                                }
540                            }
541                        }
542                    }
543                    Err(e) => {
544                        self.round_errors.push(e);
545                        return ResolveStep::Finished(Err(std::mem::take(&mut self.round_errors)));
546                    }
547                }
548            }
549            Err(error) => {
550                let suggestion = resolve_failure_suggestion(&error.kind);
551                let spec_context = self
552                    .ctx
553                    .iter()
554                    .find(|s| s.source_type == Some(reference.source.source_type.clone()));
555                self.round_errors.push(Error::registry(
556                    error.message,
557                    reference.source.clone(),
558                    reference.repository.name.clone(),
559                    error.kind,
560                    suggestion,
561                    spec_context,
562                    None,
563                ));
564            }
565        }
566
567        self.next_fetch_or_advance()
568    }
569
570    pub fn run<T: HttpTransport>(
571        registries: &Registries,
572        ctx: &mut Context,
573        sources: &mut HashMap<SourceType, String>,
574        limits: &ResourceLimits,
575        transport: &T,
576    ) -> Result<(), Vec<Error>> {
577        let (mut resolve, mut step) = Resolve::start(registries, ctx, sources, limits);
578        loop {
579            match step {
580                ResolveStep::Finished(result) => return result,
581                ResolveStep::Fetch(fetch) => {
582                    let response = transport.get(&fetch);
583                    step = resolve.respond(response);
584                }
585            }
586        }
587    }
588
589    fn next_fetch_or_advance(&mut self) -> ResolveStep {
590        loop {
591            while let Some(reference) = self.pending.pop_front() {
592                if self.already_requested.contains(&reference.repository.name) {
593                    continue;
594                }
595                self.already_requested
596                    .insert(reference.repository.name.clone());
597                let registry = self.registries.registry_for(&reference.repository);
598                match registry.fetch_for(&reference.repository) {
599                    Ok(fetch) => {
600                        self.awaiting = Some(reference);
601                        return ResolveStep::Fetch(fetch);
602                    }
603                    Err(e) => {
604                        self.round_errors.push(e);
605                    }
606                }
607            }
608
609            if !self.round_errors.is_empty() {
610                return ResolveStep::Finished(Err(std::mem::take(&mut self.round_errors)));
611            }
612
613            let unresolved = find_missing_repositories(self.ctx, &self.already_requested);
614            if unresolved.is_empty() {
615                return ResolveStep::Finished(Ok(()));
616            }
617            self.pending = unresolved.into();
618        }
619    }
620}
621
622fn collect_repository_qualifiers_from_spec_ref(
623    spec_ref: &crate::parsing::ast::SpecRef,
624    source: &Source,
625    ctx: &Context,
626    already_requested: &HashSet<String>,
627    seen_in_this_round: &mut HashSet<String>,
628    out: &mut Vec<PendingReference>,
629) {
630    let Some(qualifier) = spec_ref.repository.as_ref() else {
631        return;
632    };
633    if !qualifier.is_registry() {
634        return;
635    }
636    if ctx.find_repository(&qualifier.name).is_some() {
637        return;
638    }
639    if already_requested.contains(&qualifier.name) {
640        return;
641    }
642    if !seen_in_this_round.insert(qualifier.name.clone()) {
643        return;
644    }
645    out.push(PendingReference {
646        repository: qualifier.clone(),
647        source: source.clone(),
648    });
649}
650
651fn find_missing_repositories(
652    ctx: &Context,
653    already_requested: &HashSet<String>,
654) -> Vec<PendingReference> {
655    let mut unresolved: Vec<PendingReference> = Vec::new();
656    let mut seen_in_this_round: HashSet<String> = HashSet::new();
657
658    for spec in ctx.iter() {
659        for data in &spec.data {
660            if let crate::parsing::ast::DataValue::Import { spec_ref, .. } = &data.value {
661                collect_repository_qualifiers_from_spec_ref(
662                    spec_ref,
663                    &data.source_location,
664                    ctx,
665                    already_requested,
666                    &mut seen_in_this_round,
667                    &mut unresolved,
668                );
669            }
670        }
671    }
672
673    unresolved
674}
675
676// ---------------------------------------------------------------------------
677// Tests
678// ---------------------------------------------------------------------------
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683    use crate::engine::Context;
684    use crate::literals::DateGranularity;
685
686    struct MapTransport {
687        bodies: HashMap<String, String>,
688        last_url: std::cell::RefCell<Option<String>>,
689        request_count: std::cell::Cell<usize>,
690    }
691
692    impl MapTransport {
693        fn new(bodies: HashMap<String, String>) -> Self {
694            Self {
695                bodies,
696                last_url: std::cell::RefCell::new(None),
697                request_count: std::cell::Cell::new(0),
698            }
699        }
700    }
701
702    impl HttpTransport for MapTransport {
703        fn get(&self, fetch: &Fetch) -> Result<HttpResponse, TransportFailure> {
704            assert!(
705                fetch.url.starts_with("https://lemmabase.com/"),
706                "Fetch.url must target LemmaBase, got {}",
707                fetch.url
708            );
709            self.request_count.set(self.request_count.get() + 1);
710            *self.last_url.borrow_mut() = Some(fetch.url.clone());
711            match self.bodies.get(&fetch.repository) {
712                Some(body) => Ok(HttpResponse {
713                    status: 200,
714                    headers: Vec::new(),
715                    body: body.clone(),
716                }),
717                None => Ok(HttpResponse {
718                    status: 404,
719                    headers: Vec::new(),
720                    body: String::new(),
721                }),
722            }
723        }
724    }
725
726    fn context_with_embedded_stdlib() -> Context {
727        use crate::engine::EMBEDDED_STDLIB_REPOSITORY;
728        use crate::parsing::ast::LemmaRepository;
729        use crate::stdlib::UNITS_LEMMA;
730
731        let mut ctx = Context::new();
732        let source_type = SourceType::Dependency(EMBEDDED_STDLIB_REPOSITORY.to_string());
733        let parsed = crate::parse(UNITS_LEMMA, source_type, &ResourceLimits::default())
734            .expect("BUG: embedded stdlib must parse");
735        for (parsed_repo, specs) in &parsed.repositories {
736            let repository_arc = Arc::new(
737                LemmaRepository::new(
738                    parsed_repo
739                        .name
740                        .clone()
741                        .or_else(|| Some(EMBEDDED_STDLIB_REPOSITORY.to_string())),
742                )
743                .with_dependency(EMBEDDED_STDLIB_REPOSITORY)
744                .with_start_line(parsed_repo.start_line),
745            );
746            for spec in specs {
747                ctx.insert_spec(Arc::clone(&repository_arc), spec.clone())
748                    .expect("BUG: embedded stdlib must load");
749            }
750        }
751        ctx
752    }
753
754    // -----------------------------------------------------------------------
755    // LemmaBase fetch_for / navigation_url
756    // -----------------------------------------------------------------------
757
758    fn qualifier(raw: &str) -> RepositoryQualifier {
759        crate::parsing::parse_repository_qualifier_str(raw)
760            .unwrap_or_else(|e| panic!("BUG: test qualifier {raw:?} must parse: {}", e.message()))
761    }
762
763    #[test]
764    fn fetch_for_builds_lemmabase_url() {
765        let fetch = LemmaBase
766            .fetch_for(&qualifier("@org/project"))
767            .expect("fetch_for");
768        assert_eq!(fetch.repository, "@org/project");
769        assert_eq!(fetch.url, "https://lemmabase.com/@org/project.lemma");
770        assert!(fetch.headers.is_empty());
771    }
772
773    #[test]
774    fn fetch_for_accepts_parsed_whitespace_trimmed_id() {
775        let fetch = LemmaBase
776            .fetch_for(&qualifier("  @org/project  "))
777            .expect("fetch_for");
778        assert_eq!(fetch.repository, "@org/project");
779        assert_eq!(fetch.url, "https://lemmabase.com/@org/project.lemma");
780    }
781
782    #[test]
783    fn fetch_for_rejects_id_without_at() {
784        let err = LemmaBase
785            .fetch_for(&RepositoryQualifier::new("org/project"))
786            .expect_err("no @");
787        assert_eq!(err.kind(), crate::ErrorKind::Registry);
788    }
789
790    #[test]
791    fn navigation_url_without_effective() {
792        let url = LemmaBase.navigation_url("@org/spec", None);
793        assert_eq!(url, Some("https://lemmabase.com/@org/spec".to_string()));
794    }
795
796    #[test]
797    fn navigation_url_with_effective() {
798        let effective = DateTimeValue {
799            year: 2026,
800            month: 1,
801            day: 15,
802            hour: 0,
803            minute: 0,
804            second: 0,
805            microsecond: 0,
806            timezone: None,
807            granularity: DateGranularity::Full,
808        };
809        let url = LemmaBase.navigation_url("@org/spec", Some(&effective));
810        assert_eq!(
811            url,
812            Some("https://lemmabase.com/@org/spec?effective=2026-01-15".to_string())
813        );
814    }
815
816    #[test]
817    fn navigation_url_rejects_non_at_id() {
818        assert!(LemmaBase.navigation_url("iso/countries", None).is_none());
819    }
820
821    // -----------------------------------------------------------------------
822    // bundle_from status mapping
823    // -----------------------------------------------------------------------
824
825    #[test]
826    fn bundle_from_maps_404_to_not_found() {
827        let err = LemmaBase
828            .bundle_from(
829                "@missing/repo",
830                Ok(HttpResponse {
831                    status: 404,
832                    headers: Vec::new(),
833                    body: String::new(),
834                }),
835            )
836            .expect_err("404");
837        assert_eq!(err.kind, RegistryErrorKind::NotFound);
838    }
839
840    #[test]
841    fn bundle_from_maps_401_to_unauthorized() {
842        let err = LemmaBase
843            .bundle_from(
844                "@org/private",
845                Ok(HttpResponse {
846                    status: 401,
847                    headers: Vec::new(),
848                    body: String::new(),
849                }),
850            )
851            .expect_err("401");
852        assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
853    }
854
855    #[test]
856    fn bundle_from_maps_403_to_unauthorized() {
857        let err = LemmaBase
858            .bundle_from(
859                "@org/private",
860                Ok(HttpResponse {
861                    status: 403,
862                    headers: Vec::new(),
863                    body: String::new(),
864                }),
865            )
866            .expect_err("403");
867        assert_eq!(err.kind, RegistryErrorKind::Unauthorized);
868    }
869
870    #[test]
871    fn bundle_from_maps_500_to_server_error() {
872        let err = LemmaBase
873            .bundle_from(
874                "@org/broken",
875                Ok(HttpResponse {
876                    status: 500,
877                    headers: Vec::new(),
878                    body: String::new(),
879                }),
880            )
881            .expect_err("500");
882        assert_eq!(err.kind, RegistryErrorKind::ServerError);
883    }
884
885    #[test]
886    fn bundle_from_maps_transport_failure_to_network_error() {
887        let err = LemmaBase
888            .bundle_from(
889                "@org/unreachable",
890                Err(TransportFailure {
891                    message: "connection refused".to_string(),
892                }),
893            )
894            .expect_err("transport");
895        assert_eq!(err.kind, RegistryErrorKind::NetworkError);
896    }
897
898    #[test]
899    fn bundle_from_maps_418_to_other() {
900        let err = LemmaBase
901            .bundle_from(
902                "@org/teapot",
903                Ok(HttpResponse {
904                    status: 418,
905                    headers: Vec::new(),
906                    body: String::new(),
907                }),
908            )
909            .expect_err("418");
910        assert_eq!(err.kind, RegistryErrorKind::Other);
911    }
912
913    // -----------------------------------------------------------------------
914    // Install
915    // -----------------------------------------------------------------------
916
917    #[test]
918    fn install_returns_bundle() {
919        let mut bodies = HashMap::new();
920        bodies.insert(
921            "@iso/countries".to_string(),
922            "repo @iso/countries\nspec alpha2\ndata code: text\n".to_string(),
923        );
924        let transport = MapTransport::new(bodies);
925        let registries = Registries::default();
926        let result = Install::run(
927            &registries,
928            "  @iso/countries  ",
929            &transport,
930            ResourceLimits::default(),
931        )
932        .expect("install");
933        assert_eq!(result.id, "@iso/countries");
934        assert!(result.source.contains("spec alpha2"));
935        assert_eq!(
936            transport.last_url.borrow().as_deref(),
937            Some("https://lemmabase.com/@iso/countries.lemma")
938        );
939    }
940
941    fn ok_response(body: &str) -> Result<HttpResponse, TransportFailure> {
942        Ok(HttpResponse {
943            status: 200,
944            headers: Vec::new(),
945            body: body.to_string(),
946        })
947    }
948
949    #[test]
950    fn install_respond_after_finished_is_error() {
951        let registries = Registries::default();
952        let (mut install, step) =
953            Install::start(&registries, "@iso/countries", ResourceLimits::default());
954        assert!(matches!(step, InstallStep::Fetch(_)));
955        let body = "repo @iso/countries\nspec alpha2\ndata code: text\n";
956        assert!(matches!(
957            install.respond(ok_response(body)),
958            InstallStep::Finished(Ok(_))
959        ));
960        match install.respond(ok_response(body)) {
961            InstallStep::Finished(Err(error)) => {
962                assert_eq!(error.kind(), crate::ErrorKind::Request);
963                assert!(error.message().contains("no fetch is pending"));
964            }
965            InstallStep::Finished(Ok(_)) | InstallStep::Fetch(_) => {
966                panic!("second respond must be an error")
967            }
968        }
969    }
970
971    #[test]
972    fn resolve_respond_without_pending_fetch_is_error() {
973        let mut store = context_with_embedded_stdlib();
974        let mut sources: HashMap<SourceType, String> = HashMap::new();
975        let registries = Registries::default();
976        let limits = ResourceLimits::default();
977        let (mut resolve, step) = Resolve::start(&registries, &mut store, &mut sources, &limits);
978        assert!(matches!(step, ResolveStep::Finished(Ok(()))));
979        match resolve.respond(ok_response("spec s\n")) {
980            ResolveStep::Finished(Err(errors)) => {
981                assert_eq!(errors.len(), 1);
982                assert_eq!(errors[0].kind(), crate::ErrorKind::Request);
983                assert!(errors[0].message().contains("no fetch is pending"));
984            }
985            ResolveStep::Finished(Ok(())) | ResolveStep::Fetch(_) => {
986                panic!("respond without a pending fetch must be an error")
987            }
988        }
989    }
990
991    #[test]
992    fn install_rejects_empty_id() {
993        let transport = MapTransport::new(HashMap::new());
994        let registries = Registries::default();
995        let err = Install::run(&registries, "   ", &transport, ResourceLimits::default())
996            .expect_err("empty id");
997        assert_eq!(err.kind(), crate::ErrorKind::Registry);
998    }
999
1000    #[test]
1001    fn install_rejects_path_injection() {
1002        let transport = MapTransport::new(HashMap::new());
1003        let registries = Registries::default();
1004        let err = Install::run(
1005            &registries,
1006            "@org/../secret",
1007            &transport,
1008            ResourceLimits::default(),
1009        )
1010        .expect_err("path injection");
1011        assert_eq!(err.kind(), crate::ErrorKind::Registry);
1012    }
1013
1014    #[test]
1015    fn install_rejects_repo_declaration_mismatch() {
1016        let mut bodies = HashMap::new();
1017        bodies.insert(
1018            "@org/requested".to_string(),
1019            "repo @org/other\n\nspec s\ndata v: 1\nrule r: v\n".to_string(),
1020        );
1021        let transport = MapTransport::new(bodies);
1022        let registries = Registries::default();
1023        let err = Install::run(
1024            &registries,
1025            "@org/requested",
1026            &transport,
1027            ResourceLimits::default(),
1028        )
1029        .expect_err("repo mismatch");
1030        assert_eq!(err.kind(), crate::ErrorKind::Registry);
1031        assert!(
1032            err.message().contains("@org/other") && err.message().contains("@org/requested"),
1033            "got: {}",
1034            err.message()
1035        );
1036    }
1037
1038    #[test]
1039    fn install_maps_not_found() {
1040        let transport = MapTransport::new(HashMap::new());
1041        let registries = Registries::default();
1042        let err = Install::run(
1043            &registries,
1044            "@missing/repo",
1045            &transport,
1046            ResourceLimits::default(),
1047        )
1048        .expect_err("missing");
1049        assert_eq!(err.kind(), crate::ErrorKind::Registry);
1050        assert_eq!(err.registry_kind(), Some(RegistryErrorKind::NotFound));
1051    }
1052
1053    // -----------------------------------------------------------------------
1054    // Resolve
1055    // -----------------------------------------------------------------------
1056
1057    #[test]
1058    fn resolve_with_no_registry_references_returns_local_specs_unchanged() {
1059        let source = r#"spec example
1060data price: 100"#;
1061        let local_specs = crate::parse(source, SourceType::Volatile, &ResourceLimits::default())
1062            .unwrap()
1063            .into_flattened_specs();
1064        let mut store = context_with_embedded_stdlib();
1065        let local_repository = store.workspace();
1066        for spec in &local_specs {
1067            store
1068                .insert_spec(Arc::clone(&local_repository), spec.clone())
1069                .unwrap();
1070        }
1071        let mut sources: HashMap<SourceType, String> = HashMap::new();
1072        sources.insert(SourceType::Volatile, source.to_string());
1073
1074        let registries = Registries::default();
1075        let transport = MapTransport::new(HashMap::new());
1076        Resolve::run(
1077            &registries,
1078            &mut store,
1079            &mut sources,
1080            &ResourceLimits::default(),
1081            &transport,
1082        )
1083        .unwrap();
1084
1085        assert_eq!(
1086            store.iter().count(),
1087            2,
1088            "embedded spec units plus workspace example"
1089        );
1090        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1091        assert!(names.iter().any(|n| n == "example"));
1092        assert!(names.iter().any(|n| n == "units"));
1093    }
1094
1095    #[test]
1096    fn resolve_does_not_fetch_non_at_qualified_repositories() {
1097        let local_source = r#"spec burn_baby_burn
1098uses lemma units
1099rule x: 1 hour"#;
1100        let local_specs = crate::parse(
1101            local_source,
1102            SourceType::Volatile,
1103            &ResourceLimits::default(),
1104        )
1105        .unwrap()
1106        .into_flattened_specs();
1107        let mut store = Context::new();
1108        let local_repository = store.workspace();
1109        for spec in local_specs {
1110            store
1111                .insert_spec(Arc::clone(&local_repository), spec)
1112                .unwrap();
1113        }
1114        let mut sources: HashMap<SourceType, String> = HashMap::new();
1115        sources.insert(SourceType::Volatile, local_source.to_string());
1116
1117        let registries = Registries::default();
1118        let transport = MapTransport::new(HashMap::new());
1119        let result = Resolve::run(
1120            &registries,
1121            &mut store,
1122            &mut sources,
1123            &ResourceLimits::default(),
1124            &transport,
1125        );
1126
1127        assert!(
1128            result.is_ok(),
1129            "non-@ repository qualifiers must not be sent to the registry, got: {:?}",
1130            result.err()
1131        );
1132        assert!(transport.last_url.borrow().is_none());
1133    }
1134
1135    #[test]
1136    fn resolve_fetches_single_spec_from_registry() {
1137        let local_source = r#"spec main_spec
1138uses external: @org/project helper
1139rule value: external.quantity"#;
1140        let local_specs = crate::parse(
1141            local_source,
1142            SourceType::Volatile,
1143            &ResourceLimits::default(),
1144        )
1145        .unwrap()
1146        .into_flattened_specs();
1147        let mut store = context_with_embedded_stdlib();
1148        let local_repository = store.workspace();
1149        for spec in local_specs {
1150            store
1151                .insert_spec(Arc::clone(&local_repository), spec)
1152                .unwrap();
1153        }
1154        let mut sources: HashMap<SourceType, String> = HashMap::new();
1155        sources.insert(SourceType::Volatile, local_source.to_string());
1156
1157        let mut bodies = HashMap::new();
1158        bodies.insert(
1159            "@org/project".to_string(),
1160            "repo @org/project\nspec helper\ndata quantity: 42".to_string(),
1161        );
1162        let transport = MapTransport::new(bodies);
1163        let registries = Registries::default();
1164
1165        Resolve::run(
1166            &registries,
1167            &mut store,
1168            &mut sources,
1169            &ResourceLimits::default(),
1170            &transport,
1171        )
1172        .unwrap();
1173
1174        assert_eq!(store.iter().count(), 3);
1175        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1176        assert!(names.iter().any(|n| n == "main_spec"));
1177        assert!(names.iter().any(|n| n == "helper"));
1178        assert!(names.iter().any(|n| n == "units"));
1179        assert_eq!(
1180            transport.last_url.borrow().as_deref(),
1181            Some("https://lemmabase.com/@org/project.lemma")
1182        );
1183    }
1184
1185    #[test]
1186    fn resolve_registry_bundle_without_repo_decl_uses_reference_repository_name() {
1187        let local_source = r#"spec main_spec
1188uses external: @org/project helper
1189rule value: external.quantity"#;
1190        let local_specs = crate::parse(
1191            local_source,
1192            SourceType::Volatile,
1193            &ResourceLimits::default(),
1194        )
1195        .unwrap()
1196        .into_flattened_specs();
1197        let mut store = context_with_embedded_stdlib();
1198        let local_repository = store.workspace();
1199        for spec in local_specs {
1200            store
1201                .insert_spec(Arc::clone(&local_repository), spec)
1202                .unwrap();
1203        }
1204        let mut sources: HashMap<SourceType, String> = HashMap::new();
1205        sources.insert(SourceType::Volatile, local_source.to_string());
1206
1207        let mut bodies = HashMap::new();
1208        bodies.insert(
1209            "@org/project".to_string(),
1210            "spec helper\ndata quantity: 42".to_string(),
1211        );
1212        let transport = MapTransport::new(bodies);
1213        let registries = Registries::default();
1214
1215        Resolve::run(
1216            &registries,
1217            &mut store,
1218            &mut sources,
1219            &ResourceLimits::default(),
1220            &transport,
1221        )
1222        .unwrap();
1223
1224        assert!(store.find_repository("@org/project").is_some());
1225    }
1226
1227    #[test]
1228    fn resolve_fetches_transitive_dependencies() {
1229        let local_source = r#"spec main_spec
1230uses a: @org/a helper_a
1231rule value: a.x"#;
1232        let local_specs = crate::parse(
1233            local_source,
1234            SourceType::Volatile,
1235            &ResourceLimits::default(),
1236        )
1237        .unwrap()
1238        .into_flattened_specs();
1239        let mut store = context_with_embedded_stdlib();
1240        let local_repository = store.workspace();
1241        for spec in local_specs {
1242            store
1243                .insert_spec(Arc::clone(&local_repository), spec)
1244                .unwrap();
1245        }
1246        let mut sources: HashMap<SourceType, String> = HashMap::new();
1247        sources.insert(SourceType::Volatile, local_source.to_string());
1248
1249        let mut bodies = HashMap::new();
1250        bodies.insert(
1251            "@org/a".to_string(),
1252            "repo @org/a\nspec helper_a\nuses b: @org/b helper_b\ndata x: b.y".to_string(),
1253        );
1254        bodies.insert(
1255            "@org/b".to_string(),
1256            "repo @org/b\nspec helper_b\ndata y: 7".to_string(),
1257        );
1258        let transport = MapTransport::new(bodies);
1259        let registries = Registries::default();
1260
1261        Resolve::run(
1262            &registries,
1263            &mut store,
1264            &mut sources,
1265            &ResourceLimits::default(),
1266            &transport,
1267        )
1268        .unwrap();
1269
1270        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1271        assert!(names.iter().any(|n| n == "helper_a"));
1272        assert!(names.iter().any(|n| n == "helper_b"));
1273    }
1274
1275    #[test]
1276    fn resolve_handles_bundle_with_multiple_specs() {
1277        let local_source = r#"spec main_spec
1278uses a: @org/multi alpha
1279rule value: a.x"#;
1280        let local_specs = crate::parse(
1281            local_source,
1282            SourceType::Volatile,
1283            &ResourceLimits::default(),
1284        )
1285        .unwrap()
1286        .into_flattened_specs();
1287        let mut store = context_with_embedded_stdlib();
1288        let local_repository = store.workspace();
1289        for spec in local_specs {
1290            store
1291                .insert_spec(Arc::clone(&local_repository), spec)
1292                .unwrap();
1293        }
1294        let mut sources: HashMap<SourceType, String> = HashMap::new();
1295        sources.insert(SourceType::Volatile, local_source.to_string());
1296
1297        let mut bodies = HashMap::new();
1298        bodies.insert(
1299            "@org/multi".to_string(),
1300            "repo @org/multi\nspec alpha\ndata x: 1\nspec second\ndata y: 2".to_string(),
1301        );
1302        let transport = MapTransport::new(bodies);
1303        let registries = Registries::default();
1304
1305        Resolve::run(
1306            &registries,
1307            &mut store,
1308            &mut sources,
1309            &ResourceLimits::default(),
1310            &transport,
1311        )
1312        .unwrap();
1313
1314        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1315        assert!(names.iter().any(|n| n == "alpha"));
1316        assert!(names.iter().any(|n| n == "second"));
1317    }
1318
1319    #[test]
1320    fn resolve_returns_registry_error_when_registry_fails() {
1321        let local_source = r#"spec main_spec
1322uses external: @org/missing helper
1323rule value: external.quantity"#;
1324        let local_specs = crate::parse(
1325            local_source,
1326            SourceType::Volatile,
1327            &ResourceLimits::default(),
1328        )
1329        .unwrap()
1330        .into_flattened_specs();
1331        let mut store = context_with_embedded_stdlib();
1332        let local_repository = store.workspace();
1333        for spec in local_specs {
1334            store
1335                .insert_spec(Arc::clone(&local_repository), spec)
1336                .unwrap();
1337        }
1338        let mut sources: HashMap<SourceType, String> = HashMap::new();
1339        sources.insert(SourceType::Volatile, local_source.to_string());
1340
1341        let registries = Registries::default();
1342        let transport = MapTransport::new(HashMap::new());
1343        let errs = Resolve::run(
1344            &registries,
1345            &mut store,
1346            &mut sources,
1347            &ResourceLimits::default(),
1348            &transport,
1349        )
1350        .expect_err("missing");
1351        assert!(!errs.is_empty());
1352        assert_eq!(errs[0].kind(), crate::ErrorKind::Registry);
1353        assert_eq!(errs[0].registry_kind(), Some(RegistryErrorKind::NotFound));
1354    }
1355
1356    #[test]
1357    fn resolve_returns_all_registry_errors_when_multiple_repositories_fail() {
1358        let local_source = r#"spec main_spec
1359uses @org/example helper
1360uses @iso/countries alpha2
1361data country: alpha2.code"#;
1362        let local_specs = crate::parse(
1363            local_source,
1364            SourceType::Volatile,
1365            &ResourceLimits::default(),
1366        )
1367        .unwrap()
1368        .into_flattened_specs();
1369        let mut store = context_with_embedded_stdlib();
1370        let local_repository = store.workspace();
1371        for spec in local_specs {
1372            store
1373                .insert_spec(Arc::clone(&local_repository), spec)
1374                .unwrap();
1375        }
1376        let mut sources: HashMap<SourceType, String> = HashMap::new();
1377        sources.insert(SourceType::Volatile, local_source.to_string());
1378
1379        let registries = Registries::default();
1380        let transport = MapTransport::new(HashMap::new());
1381        let errs = Resolve::run(
1382            &registries,
1383            &mut store,
1384            &mut sources,
1385            &ResourceLimits::default(),
1386            &transport,
1387        )
1388        .expect_err("both missing");
1389        let identifiers: Vec<&str> = errs.iter().filter_map(|e| e.repository()).collect();
1390        assert!(
1391            identifiers.contains(&"@org/example"),
1392            "Should include repository error: {:?}",
1393            identifiers
1394        );
1395        assert!(
1396            identifiers.contains(&"@iso/countries"),
1397            "Should include data import repository error: {:?}",
1398            identifiers
1399        );
1400    }
1401
1402    #[test]
1403    fn resolve_does_not_request_same_repository_twice() {
1404        let local_source = r#"spec spec_one
1405uses a: @org/shared shared
1406
1407spec spec_two
1408uses b: @org/shared shared"#;
1409        let local_specs = crate::parse(
1410            local_source,
1411            SourceType::Volatile,
1412            &ResourceLimits::default(),
1413        )
1414        .unwrap()
1415        .into_flattened_specs();
1416        let mut store = context_with_embedded_stdlib();
1417        let local_repository = store.workspace();
1418        for spec in local_specs {
1419            store
1420                .insert_spec(Arc::clone(&local_repository), spec)
1421                .unwrap();
1422        }
1423        let mut sources: HashMap<SourceType, String> = HashMap::new();
1424        sources.insert(SourceType::Volatile, local_source.to_string());
1425
1426        let mut bodies = HashMap::new();
1427        bodies.insert(
1428            "@org/shared".to_string(),
1429            "repo @org/shared\nspec shared\ndata value: 1".to_string(),
1430        );
1431        let transport = MapTransport::new(bodies);
1432        let registries = Registries::default();
1433        Resolve::run(
1434            &registries,
1435            &mut store,
1436            &mut sources,
1437            &ResourceLimits::default(),
1438            &transport,
1439        )
1440        .unwrap();
1441
1442        assert_eq!(transport.request_count.get(), 1);
1443        assert_eq!(store.iter().count(), 4);
1444        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1445        assert!(names.iter().any(|n| n == "shared"));
1446        assert!(names.iter().any(|n| n == "units"));
1447    }
1448
1449    #[test]
1450    fn resolve_handles_data_import_from_registry() {
1451        let local_source = r#"spec main_spec
1452uses @iso/countries alpha2
1453data country: alpha2.code
1454data home: country"#;
1455        let local_specs = crate::parse(
1456            local_source,
1457            SourceType::Volatile,
1458            &ResourceLimits::default(),
1459        )
1460        .unwrap()
1461        .into_flattened_specs();
1462        let mut store = context_with_embedded_stdlib();
1463        let local_repository = store.workspace();
1464        for spec in local_specs {
1465            store
1466                .insert_spec(Arc::clone(&local_repository), spec)
1467                .unwrap();
1468        }
1469        let mut sources: HashMap<SourceType, String> = HashMap::new();
1470        sources.insert(SourceType::Volatile, local_source.to_string());
1471
1472        let mut bodies = HashMap::new();
1473        bodies.insert(
1474            "@iso/countries".to_string(),
1475            "repo @iso/countries\nspec alpha2\ndata code: text\n -> option \"NL\"".to_string(),
1476        );
1477        let transport = MapTransport::new(bodies);
1478        let registries = Registries::default();
1479        Resolve::run(
1480            &registries,
1481            &mut store,
1482            &mut sources,
1483            &ResourceLimits::default(),
1484            &transport,
1485        )
1486        .unwrap();
1487
1488        assert_eq!(store.iter().count(), 3);
1489        let names: Vec<String> = store.iter().map(|a| a.name.clone()).collect();
1490        assert!(names.iter().any(|n| n == "main_spec"));
1491        assert!(names.iter().any(|n| n == "alpha2"));
1492        assert!(names.iter().any(|n| n == "units"));
1493    }
1494}