1use crate::deps::{lemma_deps_dir, relative_dependency_cache_path};
6use crate::workspace::{self, WorkspaceDiskError};
7use lemma::Engine;
8use lemma::{Fetch, Header, HttpResponse, HttpTransport, Install, Registries, TransportFailure};
9use std::collections::HashSet;
10use std::fs;
11use std::io::{self, Write};
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, OnceLock};
14use walkdir::WalkDir;
15
16fn registry_runtime() -> &'static tokio::runtime::Runtime {
17 static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
18 RUNTIME.get_or_init(|| {
19 tokio::runtime::Runtime::new().expect("BUG: failed to create registry install runtime")
20 })
21}
22
23pub fn block_on_registry<F: std::future::Future>(future: F) -> F::Output {
25 registry_runtime().block_on(future)
26}
27
28pub struct ReqwestTransport {
33 http: reqwest::Client,
34}
35
36impl ReqwestTransport {
37 pub fn new() -> Self {
38 let http = reqwest::Client::builder()
39 .timeout(std::time::Duration::from_secs(30))
40 .build()
41 .expect("BUG: reqwest client builder must succeed with default TLS");
42 Self { http }
43 }
44}
45
46impl Default for ReqwestTransport {
47 fn default() -> Self {
48 Self::new()
49 }
50}
51
52impl HttpTransport for ReqwestTransport {
53 fn get(&self, fetch: &Fetch) -> Result<HttpResponse, TransportFailure> {
54 block_on_registry(async {
55 let mut request = self.http.get(&fetch.url);
56 for header in &fetch.headers {
57 request = request.header(&header.name, &header.value);
58 }
59 let response = request.send().await.map_err(|e| TransportFailure {
60 message: e.to_string(),
61 })?;
62 let status = response.status().as_u16();
63 let mut headers = Vec::new();
64 for (name, value) in response.headers() {
65 match value.to_str() {
66 Ok(value) => headers.push(Header {
67 name: name.as_str().to_string(),
68 value: value.to_string(),
69 }),
70 Err(_) => {
71 return Err(TransportFailure {
72 message: format!(
73 "response header '{}' is not valid UTF-8",
74 name.as_str()
75 ),
76 });
77 }
78 }
79 }
80 let body = response.text().await.map_err(|e| TransportFailure {
81 message: e.to_string(),
82 })?;
83 Ok(HttpResponse {
84 status,
85 headers,
86 body,
87 })
88 })
89 }
90}
91
92#[derive(Debug)]
98pub enum InstallOutcome {
99 Written {
100 relative_path: PathBuf,
101 source: String,
102 },
103 AlreadyUpToDate {
104 relative_path: PathBuf,
105 source: String,
106 },
107}
108
109pub enum InstallError {
111 Registry(lemma::Error),
112 UnparseableRegistry(lemma::Error),
113 Conflict {
114 spec_names: Vec<String>,
115 path: PathBuf,
116 },
117 Io(io::Error),
118 Workspace(WorkspaceDiskError),
119 Plan(lemma::Errors),
120}
121
122impl std::fmt::Debug for InstallError {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 write!(f, "{self}")
125 }
126}
127
128impl std::fmt::Display for InstallError {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 match self {
131 Self::Registry(error) => write!(f, "LemmaBase error: {error}"),
132 Self::UnparseableRegistry(error) => {
133 write!(f, "LemmaBase returned unparseable repository: {error}")
134 }
135 Self::Conflict { spec_names, path } => write!(
136 f,
137 "Repository containing spec(s) {} already exists in {}.\n\
138 Content has changed on LemmaBase. Re-run with --force to overwrite.",
139 spec_names.join(", "),
140 path.display()
141 ),
142 Self::Io(error) => write!(f, "{error}"),
143 Self::Workspace(error) => write!(f, "{error}"),
144 Self::Plan(errors) => write!(
145 f,
146 "Planning installed repository failed ({} error(s))",
147 errors.errors.len()
148 ),
149 }
150 }
151}
152
153impl From<io::Error> for InstallError {
154 fn from(error: io::Error) -> Self {
155 Self::Io(error)
156 }
157}
158
159impl From<WorkspaceDiskError> for InstallError {
160 fn from(error: WorkspaceDiskError) -> Self {
161 match error {
162 WorkspaceDiskError::EngineLoad(errors) => Self::Plan(errors),
163 other => Self::Workspace(other),
164 }
165 }
166}
167
168pub fn install_registry_dependency<T: HttpTransport>(
170 workdir: &Path,
171 id: &str,
172 force: bool,
173 registries: &Registries,
174 transport: &T,
175) -> Result<InstallOutcome, InstallError> {
176 let result = Install::run(registries, id, transport, lemma::ResourceLimits::default())
177 .map_err(|error| {
178 if error.kind() == lemma::ErrorKind::Parsing {
179 InstallError::UnparseableRegistry(error)
180 } else {
181 InstallError::Registry(error)
182 }
183 })?;
184 persist_registry_dependency(workdir, &result.id, &result.source, force)
185}
186
187fn spec_names_from_source(repository: &str, source: &str) -> Result<HashSet<String>, InstallError> {
188 let limits = lemma::ResourceLimits::default();
189 let specs = lemma::parse(
190 source,
191 lemma::SourceType::Dependency(repository.to_string()),
192 &limits,
193 )
194 .map_err(InstallError::UnparseableRegistry)?
195 .into_flattened_specs();
196 Ok(specs.into_iter().map(|spec| spec.name).collect())
197}
198
199pub fn persist_registry_dependency(
207 workdir: &Path,
208 id: &str,
209 source: &str,
210 force: bool,
211) -> Result<InstallOutcome, InstallError> {
212 let spec_names = spec_names_from_source(id, source)?;
213 let scan = scan_lemma_deps(workdir, id, &spec_names)?;
214
215 if scan.destination_absolute.is_file() {
216 let existing = fs::read_to_string(&scan.destination_absolute)?;
217 if existing == source {
218 return Ok(InstallOutcome::AlreadyUpToDate {
219 relative_path: scan.destination_relative,
220 source: source.to_string(),
221 });
222 }
223 }
224
225 if !scan.conflict_paths.is_empty() && !force {
226 let path = scan.conflict_paths[0].clone();
227 let existing_content = fs::read_to_string(&path)?;
228 let limits = lemma::ResourceLimits::default();
229 let existing_specs = lemma::parse(
230 &existing_content,
231 lemma::SourceType::Path(Arc::new(path.clone())),
232 &limits,
233 )
234 .map_err(|error| {
235 InstallError::Io(io::Error::new(
236 io::ErrorKind::InvalidData,
237 format!("unparseable lemma_deps file {}: {error}", path.display()),
238 ))
239 })?
240 .into_flattened_specs();
241 let conflict: Vec<String> = existing_specs
242 .iter()
243 .filter(|spec| spec_names.contains(&spec.name))
244 .map(|spec| spec.name.clone())
245 .collect();
246 return Err(InstallError::Conflict {
247 spec_names: conflict,
248 path,
249 });
250 }
251
252 let tmp = write_tmp(&scan.destination_absolute, source)?;
253
254 let mut exclude: Vec<&Path> = vec![scan.destination_absolute.as_path()];
255 if force {
256 for path in &scan.conflict_paths {
257 exclude.push(path.as_path());
258 }
259 }
260
261 let probe_result = (|| -> Result<(), InstallError> {
262 let mut probe = Engine::new();
263 workspace::load_workspace_excluding(&mut probe, workdir, &exclude)?;
264 probe
265 .load([(
266 lemma::SourceType::Dependency(id.to_string()),
267 source.to_string(),
268 )])
269 .map_err(InstallError::Plan)?;
270 Ok(())
271 })();
272
273 if let Err(error) = probe_result {
274 let _ = fs::remove_file(&tmp);
275 return Err(error);
276 }
277
278 if force {
279 for path in &scan.conflict_paths {
280 if path != &scan.destination_absolute {
281 fs::remove_file(path)?;
282 }
283 }
284 }
285
286 rename_tmp(&tmp, &scan.destination_absolute)?;
287
288 Ok(InstallOutcome::Written {
289 relative_path: scan.destination_relative,
290 source: source.to_string(),
291 })
292}
293
294pub struct LemmaDepsScan {
296 pub destination_absolute: PathBuf,
297 pub destination_relative: PathBuf,
298 pub conflict_paths: Vec<PathBuf>,
300}
301
302pub fn scan_lemma_deps(
305 workdir: &Path,
306 repository: &str,
307 new_spec_names: &HashSet<String>,
308) -> Result<LemmaDepsScan, InstallError> {
309 let deps_dir = lemma_deps_dir(workdir);
310 let destination_relative = relative_dependency_cache_path(repository);
311 let destination_absolute = deps_dir.join(&destination_relative);
312 let limits = lemma::ResourceLimits::default();
313
314 let mut conflict_paths = Vec::new();
315
316 if deps_dir.exists() {
317 for entry in WalkDir::new(&deps_dir) {
318 let entry = entry.map_err(|error| InstallError::Io(io::Error::other(error)))?;
319 if entry.path().extension().and_then(|s| s.to_str()) != Some("lemma") {
320 continue;
321 }
322 let path = entry.path();
323 let existing_content = fs::read_to_string(path)?;
324 let existing_specs = lemma::parse(
325 &existing_content,
326 lemma::SourceType::Path(Arc::new(path.to_path_buf())),
327 &limits,
328 )
329 .map_err(|error| {
330 InstallError::Io(io::Error::new(
331 io::ErrorKind::InvalidData,
332 format!("unparseable lemma_deps file {}: {error}", path.display()),
333 ))
334 })?
335 .into_flattened_specs();
336 let has_conflict = existing_specs
337 .iter()
338 .any(|spec| new_spec_names.contains(&spec.name));
339 if has_conflict {
340 conflict_paths.push(path.to_path_buf());
341 }
342 }
343 }
344
345 Ok(LemmaDepsScan {
346 destination_absolute,
347 destination_relative,
348 conflict_paths,
349 })
350}
351
352fn tmp_path_for(path: &Path) -> io::Result<PathBuf> {
354 let file_name = path.file_name().ok_or_else(|| {
355 io::Error::new(
356 io::ErrorKind::InvalidInput,
357 "persist path must include a file name",
358 )
359 })?;
360 let dir = match path.parent() {
361 Some(p) if !p.as_os_str().is_empty() => p,
362 _ => Path::new("."),
363 };
364 fs::create_dir_all(dir)?;
365 Ok(dir.join(format!(".{}.tmp", file_name.to_string_lossy())))
366}
367
368pub fn write_tmp(path: &Path, contents: &str) -> io::Result<PathBuf> {
370 let tmp = tmp_path_for(path)?;
371 match (|| -> io::Result<()> {
372 let mut f = fs::File::create(&tmp)?;
373 f.write_all(contents.as_bytes())?;
374 f.sync_all()?;
375 Ok(())
376 })() {
377 Ok(()) => Ok(tmp),
378 Err(e) => {
379 let _ = fs::remove_file(&tmp);
380 Err(e)
381 }
382 }
383}
384
385pub fn rename_tmp(tmp: &Path, path: &Path) -> io::Result<()> {
387 fs::rename(tmp, path)
388}
389
390pub fn atomic_write(path: &Path, contents: &str) -> io::Result<()> {
392 let tmp = write_tmp(path, contents)?;
393 match rename_tmp(&tmp, path) {
394 Ok(()) => Ok(()),
395 Err(e) => {
396 let _ = fs::remove_file(&tmp);
397 Err(e)
398 }
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use std::collections::HashSet;
406
407 fn test_transport() -> (Registries, lemma::__test_support::FixtureTransport) {
408 (
409 Registries::default(),
410 lemma::__test_support::FixtureTransport::bundled(),
411 )
412 }
413
414 #[test]
415 fn write_tmp_then_rename_tmp_round_trip() {
416 let dir = tempfile::tempdir().unwrap();
417 let path = dir.path().join("out.lemma");
418 let tmp = write_tmp(&path, "spec x\ndata a: 1\n").unwrap();
419 assert!(tmp.exists(), "tmp must exist before rename");
420 assert!(!path.exists(), "final path must not exist before rename");
421 rename_tmp(&tmp, &path).unwrap();
422 assert_eq!(fs::read_to_string(&path).unwrap(), "spec x\ndata a: 1\n");
423 assert!(!tmp.exists(), "tmp must be gone after rename");
424 }
425
426 #[test]
427 fn atomic_write_round_trip() {
428 let dir = tempfile::tempdir().unwrap();
429 let path = dir.path().join("out.lemma");
430 atomic_write(&path, "spec x\ndata a: 1\n").unwrap();
431 assert_eq!(fs::read_to_string(&path).unwrap(), "spec x\ndata a: 1\n");
432 assert!(
433 !dir.path().join(".out.lemma.tmp").exists(),
434 "temp file must be cleaned up"
435 );
436 }
437
438 #[test]
439 fn scan_lemma_deps_rejects_unparseable_cache_file() {
440 let dir = tempfile::tempdir().unwrap();
441 let junk = dir.path().join("lemma_deps").join("@junk");
442 fs::create_dir_all(&junk).unwrap();
443 fs::write(junk.join("corrupt.lemma"), "{{{not valid lemma").unwrap();
444
445 let mut names = HashSet::new();
446 names.insert("alpha2".to_string());
447
448 let result = scan_lemma_deps(dir.path(), "@iso/countries", &names);
449 assert!(
450 result.is_err(),
451 "unparseable lemma_deps .lemma must hard-error"
452 );
453 }
454
455 #[test]
456 fn install_writes_fixture_dependency() {
457 let dir = tempfile::tempdir().unwrap();
458 let (registries, transport) = test_transport();
459 let outcome = install_registry_dependency(
460 dir.path(),
461 "@iso/countries",
462 false,
463 ®istries,
464 &transport,
465 )
466 .unwrap();
467 match outcome {
468 InstallOutcome::Written { relative_path, .. } => {
469 assert_eq!(relative_path, PathBuf::from("@iso").join("countries.lemma"));
470 }
471 InstallOutcome::AlreadyUpToDate { .. } => panic!("expected Written"),
472 }
473 assert!(dir
474 .path()
475 .join("lemma_deps")
476 .join("@iso")
477 .join("countries.lemma")
478 .exists());
479 assert!(
480 !dir.path()
481 .join("lemma_deps")
482 .join("@iso")
483 .join(".countries.lemma.tmp")
484 .exists(),
485 "tmp must not remain after success"
486 );
487 }
488
489 #[test]
490 fn install_destination_match_is_up_to_date() {
491 let dir = tempfile::tempdir().unwrap();
492 let (registries, transport) = test_transport();
493 install_registry_dependency(dir.path(), "@iso/countries", false, ®istries, &transport)
494 .unwrap();
495 let second = install_registry_dependency(
496 dir.path(),
497 "@iso/countries",
498 false,
499 ®istries,
500 &transport,
501 )
502 .unwrap();
503 assert!(
504 matches!(second, InstallOutcome::AlreadyUpToDate { .. }),
505 "canonical destination match must be up to date"
506 );
507 }
508
509 #[test]
510 fn install_identical_content_elsewhere_conflicts_without_force() {
511 let dir = tempfile::tempdir().unwrap();
512 let (registries, transport) = test_transport();
513 install_registry_dependency(dir.path(), "@iso/countries", false, ®istries, &transport)
514 .unwrap();
515 let other = dir
516 .path()
517 .join("lemma_deps")
518 .join("@other")
519 .join("copy.lemma");
520 fs::create_dir_all(other.parent().unwrap()).unwrap();
521 let dest = dir
522 .path()
523 .join("lemma_deps")
524 .join("@iso")
525 .join("countries.lemma");
526 fs::rename(&dest, &other).unwrap();
527
528 let err = install_registry_dependency(
529 dir.path(),
530 "@iso/countries",
531 false,
532 ®istries,
533 &transport,
534 )
535 .expect_err("foreign copy with overlapping specs must conflict");
536 assert!(
537 matches!(err, InstallError::Conflict { .. }),
538 "expected Conflict, got: {err}"
539 );
540 assert!(!dest.exists(), "conflict must not write destination");
541 assert!(other.exists(), "foreign copy must remain without force");
542 }
543
544 #[test]
545 fn install_identical_content_elsewhere_force_writes_canonical() {
546 let dir = tempfile::tempdir().unwrap();
547 let (registries, transport) = test_transport();
548 let first = install_registry_dependency(
549 dir.path(),
550 "@iso/countries",
551 false,
552 ®istries,
553 &transport,
554 )
555 .unwrap();
556 let source = match first {
557 InstallOutcome::Written { source, .. } => source,
558 InstallOutcome::AlreadyUpToDate { source, .. } => source,
559 };
560 let other = dir
561 .path()
562 .join("lemma_deps")
563 .join("@other")
564 .join("copy.lemma");
565 fs::create_dir_all(other.parent().unwrap()).unwrap();
566 let dest = dir
567 .path()
568 .join("lemma_deps")
569 .join("@iso")
570 .join("countries.lemma");
571 fs::rename(&dest, &other).unwrap();
572
573 let second = install_registry_dependency(
574 dir.path(),
575 "@iso/countries",
576 true,
577 ®istries,
578 &transport,
579 )
580 .unwrap();
581 match second {
582 InstallOutcome::Written { relative_path, .. } => {
583 assert_eq!(relative_path, PathBuf::from("@iso").join("countries.lemma"));
584 }
585 InstallOutcome::AlreadyUpToDate { .. } => {
586 panic!("force install must write canonical destination")
587 }
588 }
589 assert!(dest.exists(), "canonical destination must be written");
590 assert_eq!(fs::read_to_string(&dest).unwrap(), source);
591 assert!(
592 !other.exists(),
593 "force must remove conflicting foreign copy"
594 );
595 assert!(
596 !dest
597 .parent()
598 .expect("parent")
599 .join(".countries.lemma.tmp")
600 .exists(),
601 "tmp must not remain after force success"
602 );
603 }
604
605 #[test]
606 fn install_workspace_spec_name_collision_is_plan_error() {
607 let dir = tempfile::tempdir().unwrap();
608 fs::write(
609 dir.path().join("local.lemma"),
610 "repo @iso/countries\nspec alpha2\ndata code: text\n",
611 )
612 .unwrap();
613 let (registries, transport) = test_transport();
614 let err = install_registry_dependency(
615 dir.path(),
616 "@iso/countries",
617 false,
618 ®istries,
619 &transport,
620 )
621 .expect_err("workspace name collision must fail plan probe");
622 assert!(
623 matches!(err, InstallError::Plan(_)),
624 "expected Plan, got: {err}"
625 );
626 assert!(
627 !dir.path()
628 .join("lemma_deps")
629 .join("@iso")
630 .join("countries.lemma")
631 .exists(),
632 "plan failure must not write destination"
633 );
634 assert!(
635 !dir.path()
636 .join("lemma_deps")
637 .join("@iso")
638 .join(".countries.lemma.tmp")
639 .exists(),
640 "plan failure must not leave tmp"
641 );
642 }
643
644 #[test]
645 fn install_force_plan_failure_leaves_conflict_paths_and_destination() {
646 let dir = tempfile::tempdir().unwrap();
647 let (registries, transport) = test_transport();
648 let first = install_registry_dependency(
649 dir.path(),
650 "@iso/countries",
651 false,
652 ®istries,
653 &transport,
654 )
655 .unwrap();
656 let source = match first {
657 InstallOutcome::Written { source, .. } => source,
658 InstallOutcome::AlreadyUpToDate { source, .. } => source,
659 };
660 let other = dir
661 .path()
662 .join("lemma_deps")
663 .join("@other")
664 .join("copy.lemma");
665 fs::create_dir_all(other.parent().unwrap()).unwrap();
666 let dest = dir
667 .path()
668 .join("lemma_deps")
669 .join("@iso")
670 .join("countries.lemma");
671 fs::rename(&dest, &other).unwrap();
672
673 fs::write(
674 dir.path().join("local.lemma"),
675 "repo @iso/countries\nspec alpha2\ndata code: text\n",
676 )
677 .unwrap();
678
679 let err = install_registry_dependency(
680 dir.path(),
681 "@iso/countries",
682 true,
683 ®istries,
684 &transport,
685 )
686 .expect_err("workspace collision must fail even with force");
687 assert!(
688 matches!(err, InstallError::Plan(_)),
689 "expected Plan, got: {err}"
690 );
691 assert!(
692 other.exists(),
693 "force plan failure must leave foreign conflict file"
694 );
695 assert_eq!(fs::read_to_string(&other).unwrap(), source);
696 assert!(!dest.exists(), "plan failure must not create destination");
697 assert!(
698 !dest
699 .parent()
700 .expect("parent")
701 .join(".countries.lemma.tmp")
702 .exists(),
703 "plan failure must not leave tmp"
704 );
705 }
706
707 #[test]
708 fn install_non_at_id_reaches_registry() {
709 let dir = tempfile::tempdir().unwrap();
710 let (registries, transport) = test_transport();
711 let err = install_registry_dependency(
712 dir.path(),
713 "not-a-registry-id",
714 false,
715 ®istries,
716 &transport,
717 )
718 .expect_err("non-@ id must fail via registry");
719 assert!(
720 matches!(err, InstallError::Registry(_)),
721 "error must be Registry, got: {err}"
722 );
723 }
724}