1use 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#[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#[derive(Debug, Clone, Serialize)]
58pub struct RepositoryInstallResult {
59 pub source: String,
60 pub id: String,
61}
62
63pub trait HttpTransport {
65 fn get(&self, fetch: &Fetch) -> Result<HttpResponse, TransportFailure>;
66}
67
68pub trait Registry: Send + Sync {
70 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
80pub 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
177pub 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
207fn 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
302pub 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 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
441pub 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 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#[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 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 #[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 #[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 ®istries,
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(®istries, "@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(®istries, &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(®istries, " ", &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 ®istries,
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 ®istries,
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 ®istries,
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 #[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 ®istries,
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 ®istries,
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 ®istries,
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 ®istries,
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 ®istries,
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 ®istries,
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 ®istries,
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 ®istries,
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 ®istries,
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 ®istries,
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}