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