pop_common/sourcing/
mod.rs

1// SPDX-License-Identifier: GPL-3.0
2
3use crate::{api, git::GITHUB_API_CLIENT, Git, Status};
4pub use binary::*;
5use duct::cmd;
6use flate2::read::GzDecoder;
7use reqwest::StatusCode;
8use std::{
9	error::Error as _,
10	fs::{copy, metadata, read_dir, rename, File},
11	io::{BufRead, Seek, SeekFrom, Write},
12	os::unix::fs::PermissionsExt,
13	path::{Path, PathBuf},
14	time::Duration,
15};
16use tar::Archive;
17use tempfile::{tempdir, tempfile};
18use thiserror::Error;
19use url::Url;
20
21mod binary;
22
23/// An error relating to the sourcing of binaries.
24#[derive(Error, Debug)]
25pub enum Error {
26	/// An error occurred.
27	#[error("Anyhow error: {0}")]
28	AnyhowError(#[from] anyhow::Error),
29	/// An API error occurred.
30	#[error("API error: {0}")]
31	ApiError(#[from] api::Error),
32	/// An error occurred sourcing a binary from an archive.
33	#[error("Archive error: {0}")]
34	ArchiveError(String),
35	/// A HTTP error occurred.
36	#[error("HTTP error: {0} caused by {:?}", reqwest::Error::source(.0))]
37	HttpError(#[from] reqwest::Error),
38	/// An IO error occurred.
39	#[error("IO error: {0}")]
40	IO(#[from] std::io::Error),
41	/// A binary cannot be sourced.
42	#[error("Missing binary: {0}")]
43	MissingBinary(String),
44	/// An error occurred during parsing.
45	#[error("ParseError error: {0}")]
46	ParseError(#[from] url::ParseError),
47}
48
49/// The source of a binary.
50#[derive(Clone, Debug, PartialEq)]
51pub enum Source {
52	/// An archive for download.
53	#[allow(dead_code)]
54	Archive {
55		/// The url of the archive.
56		url: String,
57		/// The archive contents required, including the binary name.
58		contents: Vec<String>,
59	},
60	/// A git repository.
61	Git {
62		/// The url of the repository.
63		url: Url,
64		/// If applicable, the branch, tag or commit.
65		reference: Option<String>,
66		/// If applicable, a specification of the path to the manifest.
67		manifest: Option<PathBuf>,
68		/// The name of the package to be built.
69		package: String,
70		/// Any additional build artifacts which are required.
71		artifacts: Vec<String>,
72	},
73	/// A GitHub repository.
74	GitHub(GitHub),
75	/// A URL for download.
76	#[allow(dead_code)]
77	Url {
78		/// The URL for download.
79		url: String,
80		/// The name of the binary.
81		name: String,
82	},
83}
84
85impl Source {
86	/// Sources the binary.
87	///
88	/// # Arguments
89	///
90	/// * `cache` - the cache to be used.
91	/// * `release` - whether any binaries needing to be built should be done so using the release
92	///   profile.
93	/// * `status` - used to observe status updates.
94	/// * `verbose` - whether verbose output is required.
95	pub(super) async fn source(
96		&self,
97		cache: &Path,
98		release: bool,
99		status: &impl Status,
100		verbose: bool,
101	) -> Result<(), Error> {
102		use Source::*;
103		match self {
104			Archive { url, contents } => {
105				let contents: Vec<_> =
106					contents.iter().map(|name| (name.as_str(), cache.join(name))).collect();
107				from_archive(url, &contents, status).await
108			},
109			Git { url, reference, manifest, package, artifacts } => {
110				let artifacts: Vec<_> = artifacts
111					.iter()
112					.map(|name| match reference {
113						Some(version) => (name.as_str(), cache.join(format!("{name}-{version}"))),
114						None => (name.as_str(), cache.join(name)),
115					})
116					.collect();
117				from_git(
118					url.as_str(),
119					reference.as_deref(),
120					manifest.as_ref(),
121					package,
122					&artifacts,
123					release,
124					status,
125					verbose,
126				)
127				.await
128			},
129			GitHub(source) => source.source(cache, release, status, verbose).await,
130			Url { url, name } => from_url(url, &cache.join(name), status).await,
131		}
132	}
133}
134
135/// A binary sourced from GitHub.
136#[derive(Clone, Debug, PartialEq)]
137pub enum GitHub {
138	/// An archive for download from a GitHub release.
139	ReleaseArchive {
140		/// The owner of the repository - i.e. <https://github.com/{owner}/repository>.
141		owner: String,
142		/// The name of the repository - i.e. <https://github.com/owner/{repository}>.
143		repository: String,
144		/// The release tag to be used, where `None` is latest.
145		tag: Option<String>,
146		/// If applicable, any formatting for the release tag.
147		tag_format: Option<String>,
148		/// The name of the archive (asset) to download.
149		archive: String,
150		/// The archive contents required, including the binary name.
151		/// The second parameter can be used to specify another name for the binary once extracted.
152		contents: Vec<(&'static str, Option<String>)>,
153		/// If applicable, the latest release tag available.
154		latest: Option<String>,
155	},
156	/// A source code archive for download from GitHub.
157	SourceCodeArchive {
158		/// The owner of the repository - i.e. <https://github.com/{owner}/repository>.
159		owner: String,
160		/// The name of the repository - i.e. <https://github.com/owner/{repository}>.
161		repository: String,
162		/// If applicable, the branch, tag or commit.
163		reference: Option<String>,
164		/// If applicable, a specification of the path to the manifest.
165		manifest: Option<PathBuf>,
166		/// The name of the package to be built.
167		package: String,
168		/// Any additional artifacts which are required.
169		artifacts: Vec<String>,
170	},
171}
172
173impl GitHub {
174	/// Sources the binary.
175	///
176	/// # Arguments
177	///
178	/// * `cache` - the cache to be used.
179	/// * `release` - whether any binaries needing to be built should be done so using the release
180	///   profile.
181	/// * `status` - used to observe status updates.
182	/// * `verbose` - whether verbose output is required.
183	async fn source(
184		&self,
185		cache: &Path,
186		release: bool,
187		status: &impl Status,
188		verbose: bool,
189	) -> Result<(), Error> {
190		use GitHub::*;
191		match self {
192			ReleaseArchive { owner, repository, tag, tag_format, archive, contents, .. } => {
193				// Complete url and contents based on tag
194				let base_url = format!("https://github.com/{owner}/{repository}/releases");
195				let url = match tag.as_ref() {
196					Some(tag) => {
197						let tag = tag_format.as_ref().map_or_else(
198							|| tag.to_string(),
199							|tag_format| tag_format.replace("{tag}", tag),
200						);
201						format!("{base_url}/download/{tag}/{archive}")
202					},
203					None => format!("{base_url}/latest/download/{archive}"),
204				};
205				let contents: Vec<_> = contents
206					.iter()
207					.map(|(name, target)| match tag.as_ref() {
208						Some(tag) => (
209							*name,
210							cache.join(format!(
211								"{}-{tag}",
212								target.as_ref().map_or(*name, |t| t.as_str())
213							)),
214						),
215						None => (*name, cache.join(target.as_ref().map_or(*name, |t| t.as_str()))),
216					})
217					.collect();
218				from_archive(&url, &contents, status).await
219			},
220			SourceCodeArchive { owner, repository, reference, manifest, package, artifacts } => {
221				let artifacts: Vec<_> = artifacts
222					.iter()
223					.map(|name| match reference {
224						Some(reference) =>
225							(name.as_str(), cache.join(format!("{name}-{reference}"))),
226						None => (name.as_str(), cache.join(name)),
227					})
228					.collect();
229				from_github_archive(
230					owner,
231					repository,
232					reference.as_ref().map(|r| r.as_str()),
233					manifest.as_ref(),
234					package,
235					&artifacts,
236					release,
237					status,
238					verbose,
239				)
240				.await
241			},
242		}
243	}
244}
245
246/// Source binary by downloading and extracting from an archive.
247///
248/// # Arguments
249/// * `url` - The url of the archive.
250/// * `contents` - The contents within the archive which are required.
251/// * `status` - Used to observe status updates.
252async fn from_archive(
253	url: &str,
254	contents: &[(&str, PathBuf)],
255	status: &impl Status,
256) -> Result<(), Error> {
257	// Download archive
258	status.update(&format!("Downloading from {url}..."));
259	let response = reqwest::get(url).await?.error_for_status()?;
260	let mut file = tempfile()?;
261	file.write_all(&response.bytes().await?)?;
262	file.seek(SeekFrom::Start(0))?;
263	// Extract contents
264	status.update("Extracting from archive...");
265	let tar = GzDecoder::new(file);
266	let mut archive = Archive::new(tar);
267	let temp_dir = tempdir()?;
268	let working_dir = temp_dir.path();
269	archive.unpack(working_dir)?;
270	for (name, dest) in contents {
271		let src = working_dir.join(name);
272		if src.exists() {
273			if let Err(_e) = rename(&src, dest) {
274				// If rename fails (e.g., due to cross-device linking), fallback to copy and remove
275				copy(&src, dest)?;
276				std::fs::remove_file(&src)?;
277			}
278		} else {
279			return Err(Error::ArchiveError(format!(
280				"Expected file '{}' in archive, but it was not found.",
281				name
282			)));
283		}
284	}
285	status.update("Sourcing complete.");
286	Ok(())
287}
288
289/// Source binary by cloning a git repository and then building.
290///
291/// # Arguments
292/// * `url` - The url of the repository.
293/// * `reference` - If applicable, the branch, tag or commit.
294/// * `manifest` - If applicable, a specification of the path to the manifest.
295/// * `package` - The name of the package to be built.
296/// * `artifacts` - Any additional artifacts which are required.
297/// * `release` - Whether to build optimized artifacts using the release profile.
298/// * `status` - Used to observe status updates.
299/// * `verbose` - Whether verbose output is required.
300#[allow(clippy::too_many_arguments)]
301async fn from_git(
302	url: &str,
303	reference: Option<&str>,
304	manifest: Option<impl AsRef<Path>>,
305	package: &str,
306	artifacts: &[(&str, impl AsRef<Path>)],
307	release: bool,
308	status: &impl Status,
309	verbose: bool,
310) -> Result<(), Error> {
311	// Clone repository into working directory
312	let temp_dir = tempdir()?;
313	let working_dir = temp_dir.path();
314	status.update(&format!("Cloning {url}..."));
315	Git::clone(&Url::parse(url)?, working_dir, reference)?;
316	// Build binaries
317	status.update("Starting build of binary...");
318	let manifest = manifest
319		.as_ref()
320		.map_or_else(|| working_dir.join("Cargo.toml"), |m| working_dir.join(m));
321	build(manifest, package, artifacts, release, status, verbose).await?;
322	status.update("Sourcing complete.");
323	Ok(())
324}
325
326/// Source binary by downloading from a source code archive and then building.
327///
328/// # Arguments
329/// * `owner` - The owner of the repository.
330/// * `repository` - The name of the repository.
331/// * `reference` - If applicable, the branch, tag or commit.
332/// * `manifest` - If applicable, a specification of the path to the manifest.
333/// * `package` - The name of the package to be built.
334/// * `artifacts` - Any additional artifacts which are required.
335/// * `release` - Whether to build optimized artifacts using the release profile.
336/// * `status` - Used to observe status updates.
337/// * `verbose` - Whether verbose output is required.
338#[allow(clippy::too_many_arguments)]
339async fn from_github_archive(
340	owner: &str,
341	repository: &str,
342	reference: Option<&str>,
343	manifest: Option<impl AsRef<Path>>,
344	package: &str,
345	artifacts: &[(&str, impl AsRef<Path>)],
346	release: bool,
347	status: &impl Status,
348	verbose: bool,
349) -> Result<(), Error> {
350	// User agent required when using GitHub API
351	let response =
352		match reference {
353			Some(reference) => {
354				// Various potential urls to try based on not knowing the type of ref
355				let urls = [
356					format!("https://github.com/{owner}/{repository}/archive/refs/heads/{reference}.tar.gz"),
357					format!("https://github.com/{owner}/{repository}/archive/refs/tags/{reference}.tar.gz"),
358					format!("https://github.com/{owner}/{repository}/archive/{reference}.tar.gz"),
359				];
360				let mut response = None;
361				for url in urls {
362					status.update(&format!("Downloading from {url}..."));
363					response = Some(GITHUB_API_CLIENT.get(url).await);
364					if let Some(Err(api::Error::HttpError(e))) = &response {
365						if e.status() == Some(StatusCode::NOT_FOUND) {
366							tokio::time::sleep(Duration::from_secs(1)).await;
367							continue;
368						}
369					}
370					break;
371				}
372				response.expect("value set above")?
373			},
374			None => {
375				let url = format!("https://api.github.com/repos/{owner}/{repository}/tarball");
376				status.update(&format!("Downloading from {url}..."));
377				GITHUB_API_CLIENT.get(url).await?
378			},
379		};
380	let mut file = tempfile()?;
381	file.write_all(&response)?;
382	file.seek(SeekFrom::Start(0))?;
383	// Extract contents
384	status.update("Extracting from archive...");
385	let tar = GzDecoder::new(file);
386	let mut archive = Archive::new(tar);
387	let temp_dir = tempdir()?;
388	let mut working_dir = temp_dir.path().into();
389	archive.unpack(&working_dir)?;
390	// Prepare archive contents for build
391	let entries: Vec<_> = read_dir(&working_dir)?.take(2).filter_map(|x| x.ok()).collect();
392	match entries.len() {
393		0 =>
394			return Err(Error::ArchiveError(
395				"The downloaded archive does not contain any entries.".into(),
396			)),
397		1 => working_dir = entries[0].path(), // Automatically switch to top level directory
398		_ => {},                              /* Assume that downloaded archive does not have a
399		                                        * top level directory */
400	}
401	// Build binaries
402	status.update("Starting build of binary...");
403	let manifest = manifest
404		.as_ref()
405		.map_or_else(|| working_dir.join("Cargo.toml"), |m| working_dir.join(m));
406	build(&manifest, package, artifacts, release, status, verbose).await?;
407	status.update("Sourcing complete.");
408	Ok(())
409}
410
411/// Source binary by building a local package.
412///
413/// # Arguments
414/// * `manifest` - The path to the local package manifest.
415/// * `package` - The name of the package to be built.
416/// * `release` - Whether to build optimized artifacts using the release profile.
417/// * `status` - Used to observe status updates.
418/// * `verbose` - Whether verbose output is required.
419pub(crate) async fn from_local_package(
420	manifest: &Path,
421	package: &str,
422	release: bool,
423	status: &impl Status,
424	verbose: bool,
425) -> Result<(), Error> {
426	// Build binaries
427	status.update("Starting build of binary...");
428	const EMPTY: [(&str, PathBuf); 0] = [];
429	build(manifest, package, &EMPTY, release, status, verbose).await?;
430	status.update("Sourcing complete.");
431	Ok(())
432}
433
434/// Source binary by downloading from a url.
435///
436/// # Arguments
437/// * `url` - The url of the binary.
438/// * `path` - The (local) destination path.
439/// * `status` - Used to observe status updates.
440async fn from_url(url: &str, path: &Path, status: &impl Status) -> Result<(), Error> {
441	// Download required version of binaries
442	status.update(&format!("Downloading from {url}..."));
443	download(url, path).await?;
444	status.update("Sourcing complete.");
445	Ok(())
446}
447
448/// Builds a package.
449///
450/// # Arguments
451/// * `manifest` - The path to the manifest.
452/// * `package` - The name of the package to be built.
453/// * `artifacts` - Any additional artifacts which are required.
454/// * `release` - Whether to build optimized artifacts using the release profile.
455/// * `status` - Used to observe status updates.
456/// * `verbose` - Whether verbose output is required.
457async fn build(
458	manifest: impl AsRef<Path>,
459	package: &str,
460	artifacts: &[(&str, impl AsRef<Path>)],
461	release: bool,
462	status: &impl Status,
463	verbose: bool,
464) -> Result<(), Error> {
465	// Define arguments
466	let manifest_path = manifest.as_ref().to_str().expect("expected manifest path to be valid");
467	let mut args = vec!["build", "-p", package, "--manifest-path", manifest_path];
468	if release {
469		args.push("--release")
470	}
471	// Build binaries
472	let command = cmd("cargo", args);
473	match verbose {
474		false => {
475			let reader = command.stderr_to_stdout().reader()?;
476			let output = std::io::BufReader::new(reader).lines();
477			for line in output {
478				status.update(&line?);
479			}
480		},
481		true => {
482			command.run()?;
483		},
484	}
485	// Copy required artifacts to destination path
486	let target = manifest
487		.as_ref()
488		.parent()
489		.expect("")
490		.join(format!("target/{}", if release { "release" } else { "debug" }));
491	for (name, dest) in artifacts {
492		copy(target.join(name), dest)?;
493	}
494	Ok(())
495}
496
497/// Downloads a file from a URL.
498///
499/// # Arguments
500/// * `url` - The url of the file.
501/// * `path` - The (local) destination path.
502async fn download(url: &str, dest: &Path) -> Result<(), Error> {
503	// Download to destination path
504	let response = reqwest::get(url).await?.error_for_status()?;
505	let mut file = File::create(dest)?;
506	file.write_all(&response.bytes().await?)?;
507	// Make executable
508	set_executable_permission(dest)?;
509	Ok(())
510}
511
512/// Sets the executable permission for a given file.
513///
514/// # Arguments
515/// * `path` - The file path to which permissions should be granted.
516pub fn set_executable_permission<P: AsRef<Path>>(path: P) -> Result<(), Error> {
517	let mut perms = metadata(&path)?.permissions();
518	perms.set_mode(0o755);
519	std::fs::set_permissions(path, perms)?;
520	Ok(())
521}
522
523#[cfg(test)]
524pub(super) mod tests {
525	use super::{GitHub::*, Status, *};
526	use crate::target;
527	use tempfile::tempdir;
528
529	#[tokio::test]
530	async fn sourcing_from_archive_works() -> anyhow::Result<()> {
531		let url = "https://github.com/r0gue-io/polkadot/releases/latest/download/polkadot-aarch64-apple-darwin.tar.gz".to_string();
532		let name = "polkadot".to_string();
533		let contents =
534			vec![name.clone(), "polkadot-execute-worker".into(), "polkadot-prepare-worker".into()];
535		let temp_dir = tempdir()?;
536
537		Source::Archive { url, contents: contents.clone() }
538			.source(temp_dir.path(), true, &Output, true)
539			.await?;
540		for item in contents {
541			assert!(temp_dir.path().join(item).exists());
542		}
543		Ok(())
544	}
545
546	#[tokio::test]
547	async fn sourcing_from_git_works() -> anyhow::Result<()> {
548		let url = Url::parse("https://github.com/hpaluch/rust-hello-world")?;
549		let package = "hello_world".to_string();
550		let temp_dir = tempdir()?;
551
552		Source::Git {
553			url,
554			reference: None,
555			manifest: None,
556			package: package.clone(),
557			artifacts: vec![package.clone()],
558		}
559		.source(temp_dir.path(), true, &Output, true)
560		.await?;
561		assert!(temp_dir.path().join(package).exists());
562		Ok(())
563	}
564
565	#[tokio::test]
566	async fn sourcing_from_git_ref_works() -> anyhow::Result<()> {
567		let url = Url::parse("https://github.com/hpaluch/rust-hello-world")?;
568		let initial_commit = "436b7dbffdfaaf7ad90bf44ae8fdcb17eeee65a3".to_string();
569		let package = "hello_world".to_string();
570		let temp_dir = tempdir()?;
571
572		Source::Git {
573			url,
574			reference: Some(initial_commit.clone()),
575			manifest: None,
576			package: package.clone(),
577			artifacts: vec![package.clone()],
578		}
579		.source(temp_dir.path(), true, &Output, true)
580		.await?;
581		assert!(temp_dir.path().join(format!("{package}-{initial_commit}")).exists());
582		Ok(())
583	}
584
585	#[tokio::test]
586	async fn sourcing_from_github_release_archive_works() -> anyhow::Result<()> {
587		let owner = "r0gue-io".to_string();
588		let repository = "polkadot".to_string();
589		let tag = "v1.12.0";
590		let tag_format = Some("polkadot-{tag}".to_string());
591		let name = "polkadot".to_string();
592		let archive = format!("{name}-{}.tar.gz", target()?);
593		let contents = ["polkadot", "polkadot-execute-worker", "polkadot-prepare-worker"];
594		let temp_dir = tempdir()?;
595
596		Source::GitHub(ReleaseArchive {
597			owner,
598			repository,
599			tag: Some(tag.to_string()),
600			tag_format,
601			archive,
602			contents: contents.map(|n| (n, None)).to_vec(),
603			latest: None,
604		})
605		.source(temp_dir.path(), true, &Output, true)
606		.await?;
607		for item in contents {
608			assert!(temp_dir.path().join(format!("{item}-{tag}")).exists());
609		}
610		Ok(())
611	}
612
613	#[tokio::test]
614	async fn sourcing_from_github_release_archive_maps_contents() -> anyhow::Result<()> {
615		let owner = "r0gue-io".to_string();
616		let repository = "polkadot".to_string();
617		let tag = "v1.12.0";
618		let tag_format = Some("polkadot-{tag}".to_string());
619		let name = "polkadot".to_string();
620		let archive = format!("{name}-{}.tar.gz", target()?);
621		let contents = ["polkadot", "polkadot-execute-worker", "polkadot-prepare-worker"];
622		let temp_dir = tempdir()?;
623		let prefix = "test";
624
625		Source::GitHub(ReleaseArchive {
626			owner,
627			repository,
628			tag: Some(tag.to_string()),
629			tag_format,
630			archive,
631			contents: contents.map(|n| (n, Some(format!("{prefix}-{n}")))).to_vec(),
632			latest: None,
633		})
634		.source(temp_dir.path(), true, &Output, true)
635		.await?;
636		for item in contents {
637			assert!(temp_dir.path().join(format!("{prefix}-{item}-{tag}")).exists());
638		}
639		Ok(())
640	}
641
642	#[tokio::test]
643	async fn sourcing_from_latest_github_release_archive_works() -> anyhow::Result<()> {
644		let owner = "r0gue-io".to_string();
645		let repository = "polkadot".to_string();
646		let tag_format = Some("polkadot-{tag}".to_string());
647		let name = "polkadot".to_string();
648		let archive = format!("{name}-{}.tar.gz", target()?);
649		let contents = ["polkadot", "polkadot-execute-worker", "polkadot-prepare-worker"];
650		let temp_dir = tempdir()?;
651
652		Source::GitHub(ReleaseArchive {
653			owner,
654			repository,
655			tag: None,
656			tag_format,
657			archive,
658			contents: contents.map(|n| (n, None)).to_vec(),
659			latest: None,
660		})
661		.source(temp_dir.path(), true, &Output, true)
662		.await?;
663		for item in contents {
664			assert!(temp_dir.path().join(item).exists());
665		}
666		Ok(())
667	}
668
669	#[tokio::test]
670	async fn sourcing_from_github_source_code_archive_works() -> anyhow::Result<()> {
671		let owner = "paritytech".to_string();
672		let repository = "polkadot-sdk".to_string();
673		let package = "polkadot".to_string();
674		let temp_dir = tempdir()?;
675		let initial_commit = "72dba98250a6267c61772cd55f8caf193141050f";
676		let manifest = PathBuf::from("substrate/Cargo.toml");
677
678		Source::GitHub(SourceCodeArchive {
679			owner,
680			repository,
681			reference: Some(initial_commit.to_string()),
682			manifest: Some(manifest),
683			package: package.clone(),
684			artifacts: vec![package.clone()],
685		})
686		.source(temp_dir.path(), true, &Output, true)
687		.await?;
688		assert!(temp_dir.path().join(format!("{package}-{initial_commit}")).exists());
689		Ok(())
690	}
691
692	#[tokio::test]
693	async fn sourcing_from_latest_github_source_code_archive_works() -> anyhow::Result<()> {
694		let owner = "hpaluch".to_string();
695		let repository = "rust-hello-world".to_string();
696		let package = "hello_world".to_string();
697		let temp_dir = tempdir()?;
698
699		Source::GitHub(SourceCodeArchive {
700			owner,
701			repository,
702			reference: None,
703			manifest: None,
704			package: package.clone(),
705			artifacts: vec![package.clone()],
706		})
707		.source(temp_dir.path(), true, &Output, true)
708		.await?;
709		assert!(temp_dir.path().join(package).exists());
710		Ok(())
711	}
712
713	#[tokio::test]
714	async fn sourcing_from_url_works() -> anyhow::Result<()> {
715		let url =
716			"https://github.com/paritytech/polkadot-sdk/releases/latest/download/polkadot.asc"
717				.to_string();
718		let name = "polkadot";
719		let temp_dir = tempdir()?;
720
721		Source::Url { url, name: name.into() }
722			.source(temp_dir.path(), false, &Output, true)
723			.await?;
724		assert!(temp_dir.path().join(&name).exists());
725		Ok(())
726	}
727
728	#[tokio::test]
729	async fn from_archive_works() -> anyhow::Result<()> {
730		let temp_dir = tempdir()?;
731		let url = "https://github.com/r0gue-io/polkadot/releases/latest/download/polkadot-aarch64-apple-darwin.tar.gz";
732		let contents: Vec<_> = ["polkadot", "polkadot-execute-worker", "polkadot-prepare-worker"]
733			.into_iter()
734			.map(|b| (b, temp_dir.path().join(b)))
735			.collect();
736
737		from_archive(url, &contents, &Output).await?;
738		for (_, file) in contents {
739			assert!(file.exists());
740		}
741		Ok(())
742	}
743
744	#[tokio::test]
745	async fn from_git_works() -> anyhow::Result<()> {
746		let url = "https://github.com/hpaluch/rust-hello-world";
747		let package = "hello_world";
748		let initial_commit = "436b7dbffdfaaf7ad90bf44ae8fdcb17eeee65a3";
749		let temp_dir = tempdir()?;
750		let path = temp_dir.path().join(package);
751
752		from_git(
753			url,
754			Some(initial_commit),
755			None::<&Path>,
756			&package,
757			&[(&package, &path)],
758			true,
759			&Output,
760			false,
761		)
762		.await?;
763		assert!(path.exists());
764		Ok(())
765	}
766
767	#[tokio::test]
768	async fn from_github_archive_works() -> anyhow::Result<()> {
769		let owner = "paritytech";
770		let repository = "polkadot-sdk";
771		let package = "polkadot";
772		let temp_dir = tempdir()?;
773		let path = temp_dir.path().join(package);
774		let initial_commit = "72dba98250a6267c61772cd55f8caf193141050f";
775		let manifest = "substrate/Cargo.toml";
776
777		from_github_archive(
778			owner,
779			repository,
780			Some(initial_commit),
781			Some(manifest),
782			package,
783			&[(package, &path)],
784			true,
785			&Output,
786			true,
787		)
788		.await?;
789		assert!(path.exists());
790		Ok(())
791	}
792
793	#[tokio::test]
794	async fn from_latest_github_archive_works() -> anyhow::Result<()> {
795		let owner = "hpaluch";
796		let repository = "rust-hello-world";
797		let package = "hello_world";
798		let temp_dir = tempdir()?;
799		let path = temp_dir.path().join(package);
800
801		from_github_archive(
802			owner,
803			repository,
804			None,
805			None::<&Path>,
806			package,
807			&[(package, &path)],
808			true,
809			&Output,
810			true,
811		)
812		.await?;
813		assert!(path.exists());
814		Ok(())
815	}
816
817	#[tokio::test]
818	async fn from_local_package_works() -> anyhow::Result<()> {
819		let temp_dir = tempdir()?;
820		let name = "hello_world";
821		cmd("cargo", ["new", name, "--bin"]).dir(temp_dir.path()).run()?;
822		let manifest = temp_dir.path().join(name).join("Cargo.toml");
823
824		from_local_package(&manifest, name, false, &Output, true).await?;
825		assert!(manifest.parent().unwrap().join("target/debug").join(name).exists());
826		Ok(())
827	}
828
829	#[tokio::test]
830	async fn from_url_works() -> anyhow::Result<()> {
831		let url =
832			"https://github.com/paritytech/polkadot-sdk/releases/latest/download/polkadot.asc";
833		let temp_dir = tempdir()?;
834		let path = temp_dir.path().join("polkadot");
835
836		from_url(url, &path, &Output).await?;
837		assert!(path.exists());
838		assert_ne!(metadata(path)?.permissions().mode() & 0o755, 0);
839		Ok(())
840	}
841
842	pub(crate) struct Output;
843	impl Status for Output {
844		fn update(&self, status: &str) {
845			println!("{status}")
846		}
847	}
848}
849
850/// Traits for the sourcing of a binary.
851pub mod traits {
852	use crate::{sourcing::Error, GitHub};
853	use strum::EnumProperty;
854
855	/// The source of a binary.
856	pub trait Source: EnumProperty {
857		/// The name of the binary.
858		fn binary(&self) -> &'static str {
859			self.get_str("Binary").expect("expected specification of `Binary` name")
860		}
861
862		/// The fallback version to be used when the latest version cannot be determined.
863		fn fallback(&self) -> &str {
864			self.get_str("Fallback")
865				.expect("expected specification of `Fallback` release tag")
866		}
867
868		/// Whether pre-releases are to be used.
869		fn prerelease(&self) -> Option<bool> {
870			self.get_str("Prerelease")
871				.map(|v| v.parse().expect("expected parachain prerelease value to be true/false"))
872		}
873
874		/// Determine the available releases from the source.
875		#[allow(async_fn_in_trait)]
876		async fn releases(&self) -> Result<Vec<String>, Error> {
877			let repo = GitHub::parse(self.repository())?;
878			let releases = match repo.releases().await {
879				Ok(releases) => releases,
880				Err(_) => return Ok(vec![self.fallback().to_string()]),
881			};
882			let prerelease = self.prerelease();
883			let tag_format = self.tag_format();
884			Ok(releases
885				.iter()
886				.filter(|r| match prerelease {
887					None => !r.prerelease, // Exclude pre-releases by default
888					Some(prerelease) => r.prerelease == prerelease,
889				})
890				.map(|r| {
891					if let Some(tag_format) = tag_format {
892						// simple for now, could be regex in future
893						let tag_format = tag_format.replace("{tag}", "");
894						r.tag_name.replace(&tag_format, "")
895					} else {
896						r.tag_name.clone()
897					}
898				})
899				.collect())
900		}
901
902		/// The repository to be used.
903		fn repository(&self) -> &str {
904			self.get_str("Repository").expect("expected specification of `Repository` url")
905		}
906
907		/// If applicable, any tag format to be used - e.g. `polkadot-{tag}`.
908		fn tag_format(&self) -> Option<&str> {
909			self.get_str("TagFormat")
910		}
911	}
912
913	/// An attempted conversion into a Source.
914	pub trait TryInto {
915		/// Attempt the conversion.
916		///
917		/// # Arguments
918		/// * `specifier` - If applicable, some specifier used to determine a specific source.
919		/// * `latest` - If applicable, some specifier used to determine the latest source.
920		fn try_into(
921			&self,
922			specifier: Option<String>,
923			latest: Option<String>,
924		) -> Result<super::Source, crate::Error>;
925	}
926
927	#[cfg(test)]
928	mod tests {
929		use super::Source;
930		use strum_macros::{EnumProperty, VariantArray};
931
932		#[derive(EnumProperty, VariantArray)]
933		pub(super) enum Chain {
934			#[strum(props(
935				Repository = "https://github.com/paritytech/polkadot-sdk",
936				Binary = "polkadot",
937				Prerelease = "false",
938				Fallback = "v1.12.0",
939				TagFormat = "polkadot-{tag}"
940			))]
941			Polkadot,
942			#[strum(props(
943				Repository = "https://github.com/r0gue-io/fallback",
944				Fallback = "v1.0"
945			))]
946			Fallback,
947		}
948
949		impl Source for Chain {}
950
951		#[test]
952		fn binary_works() {
953			assert_eq!("polkadot", Chain::Polkadot.binary())
954		}
955
956		#[test]
957		fn fallback_works() {
958			assert_eq!("v1.12.0", Chain::Polkadot.fallback())
959		}
960
961		#[test]
962		fn prerelease_works() {
963			assert!(!Chain::Polkadot.prerelease().unwrap())
964		}
965
966		#[tokio::test]
967		async fn releases_works() -> anyhow::Result<()> {
968			assert!(!Chain::Polkadot.releases().await?.is_empty());
969			Ok(())
970		}
971
972		#[tokio::test]
973		async fn releases_uses_fallback() -> anyhow::Result<()> {
974			let chain = Chain::Fallback;
975			assert_eq!(chain.fallback(), chain.releases().await?[0]);
976			Ok(())
977		}
978
979		#[test]
980		fn repository_works() {
981			assert_eq!("https://github.com/paritytech/polkadot-sdk", Chain::Polkadot.repository())
982		}
983
984		#[test]
985		fn tag_format_works() {
986			assert_eq!("polkadot-{tag}", Chain::Polkadot.tag_format().unwrap())
987		}
988	}
989}