use std::borrow::Cow; use std::path::{Path, PathBuf}; use miette::{IntoDiagnostic, Result, WrapErr}; use tokio::io::AsyncWriteExt; use crate::Context; /// A sequential pipeline of [`Step`]s. #[derive(Debug, Clone)] pub struct Pipeline<'a> { name: &'a str, steps: Vec>, } impl<'a> Pipeline<'a> { #[inline(always)] pub fn new(name: &'a str, steps: impl Into>>) -> Self { Self { name, steps: steps.into(), } } pub async fn invoke(&self, ctx: &Context) -> Result<()> { println!("Invoking {}...", self.name); for step in self.steps.iter() { step.invoke(ctx).await?; } Ok(()) } } #[derive(Debug, Clone)] pub enum Step<'a> { DownloadFile { /// Remote resourcee to download from. res: RemoteResource<'a>, /// Path to save the downloaded file to. file: Cow<'a, Path>, }, /// Extracts the file using the `tar` binary that ships with Windows. ExtractFile { file: Cow<'a, Path>, dest: Cow<'a, Path>, }, ExecuteCommand { /// Path to the executable file. file: Cow<'a, Path>, args: &'a [&'a str], }, CreateShortcut { /// Target of the shortcut (i.e. what is points to). target: ShortcutTarget<'a>, /// Path of the created shortcut file. file: Cow<'a, Path>, }, } impl<'a> Step<'a> { pub async fn invoke(&self, ctx: &Context) -> Result<()> { match self { Self::DownloadFile { res, file } => { if file.exists() { println!( "File {file} already downloaded.", file = file.to_str().unwrap_or("") ); } else { println!( "Downloading file {file} from {res:?}...", file = file.to_str().unwrap_or("") ); const FETCH_FILE_ERROR_MSG: &'static str = "Fetching the remote resource failed."; const WRITE_FILE_ERROR_MSG: &'static str = "Writing the remote resource to disk failed."; let url = match res { RemoteResource::Url(url) => url::Url::parse(url) .into_diagnostic() .wrap_err("Invalid url for download step.")?, RemoteResource::GitHubArtifact { repo, pattern } => { let mut release = fetch_latest_release(&ctx.reqwest, repo).await?; let pattern = ramhorns::Template::new(*pattern) .into_diagnostic() .wrap_err( "Invalid pattern for artifact matching in download step.", )?; release.meta.tag_name_strip_prefix = release .meta .tag_name .strip_prefix('v') .unwrap_or(&release.meta.tag_name); let asset_name = pattern.render(&release.meta); let artifact = release.assets.into_iter().filter(move |asset| asset.name == asset_name).next().ok_or_else(|| miette!("No artifact of the latest release matched the pattern in download step."))?; url::Url::parse(&artifact.browser_download_url) .into_diagnostic() .wrap_err( "Invalid url returned by GitHub for latest release artifact.", )? } }; let mut resp = ctx .reqwest .get(url) .send() .await .into_diagnostic() .wrap_err(FETCH_FILE_ERROR_MSG)?; let _content_length = resp.content_length(); mkdir_all(file.parent().ok_or_else(|| { miette!("Destination file for download step has no parent.") })?) .await?; let mut writer = tokio::io::BufWriter::new( tokio::fs::File::create(file.as_os_str()) .await .into_diagnostic() .wrap_err(WRITE_FILE_ERROR_MSG)?, ); while let Some(mut chunk) = resp .chunk() .await .into_diagnostic() .wrap_err(FETCH_FILE_ERROR_MSG)? { writer .write_all_buf(&mut chunk) .await .into_diagnostic() .wrap_err(WRITE_FILE_ERROR_MSG)?; } writer .flush() .await .into_diagnostic() .wrap_err(WRITE_FILE_ERROR_MSG)?; } } Self::ExtractFile { file, dest } => { println!( "Extracting {file} to {dest}...", file = file.to_str().unwrap_or(""), dest = dest.to_str().unwrap_or("") ); const EXTRACT_FILE_ERROR_MSG: &'static str = "Extracting file failed."; mkdir_all(&dest).await.wrap_err(EXTRACT_FILE_ERROR_MSG)?; let dest = tokio::fs::canonicalize(&dest) .await .into_diagnostic() .wrap_err(EXTRACT_FILE_ERROR_MSG)?; let status = tokio::process::Command::new("tar") .arg("-xf") .arg(file.as_os_str()) .current_dir(dest) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .status() .await .into_diagnostic() .wrap_err(EXTRACT_FILE_ERROR_MSG)?; ensure!(status.success(), EXTRACT_FILE_ERROR_MSG); } Self::ExecuteCommand { file, args } => { println!( "Executing command `{file} {args}`...", file = file.to_str().unwrap_or(""), args = args.into_iter().map(|s| *s).collect::(), ); const EXECUTE_COMMAND_ERROR_MSG: &'static str = "Executing command failed."; let status = tokio::process::Command::new(file.as_os_str()) .args(*args) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .status() .await .into_diagnostic() .wrap_err(EXECUTE_COMMAND_ERROR_MSG)?; ensure!(status.success(), EXECUTE_COMMAND_ERROR_MSG); } Self::CreateShortcut { target, file } => { println!( "Creating shortcut to {target:?} at {file}...", file = file.to_str().unwrap_or("") ); const CREATE_SHORTCUT_ERROR_MSG: &'static str = "Creating shortcut failed."; mkdir_all( file.parent().ok_or_else(|| { miette!("Destination file for shortcut step has no parent.") })?, ) .await?; let status = match target { ShortcutTarget::Path { path } => { tokio::process::Command::new("powershell") .arg("-Command") .arg(format!(r#"$shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut({file:?}); $shortcut.TargetPath = {path:?}; $shortcut.Save()"#)) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .status() .await .into_diagnostic() .wrap_err(CREATE_SHORTCUT_ERROR_MSG)? } ShortcutTarget::Executable { file: exec_file, args } => { use std::fmt::Write; tokio::process::Command::new("powershell") .arg("-Command") .arg(format!(r#"$shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut({file:?}); $shortcut.TargetPath = {exec_file:?}; $shortcut.Arguments = {args:?}; $shortcut.Save()"#)) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .status() .await .into_diagnostic() .wrap_err(CREATE_SHORTCUT_ERROR_MSG)? } }; ensure!(status.success(), CREATE_SHORTCUT_ERROR_MSG); } } println!("-> Done."); Ok(()) } } #[derive(Debug, Clone, Copy)] pub enum RemoteResource<'a> { // I'm not using url::Url here because it makes it impossible to make this const. Url(&'a str), GitHubArtifact { /// In the form `org/repo` repo: &'a str, /// Artifact name pattern. /// /// Any of the fields in [`GitHubReleaseMeta`] may be injected via handlebar syntax: /// `{{field_name}}`. /// with `v` prefix stripped if present. pattern: &'a str, }, } #[derive(Debug, Clone)] pub enum ShortcutTarget<'a> { /// An executable shortcut. Executable { /// The executable the shortcut should open. file: Cow<'a, Path>, /// Arguments to the executable. args: &'a str, }, /// A file or folder shortcut. Please use [`Self::Executable`] for shortcuts to binaries. Path { path: Cow<'a, Path> }, } #[derive(Debug, Clone, Serialize, Deserialize)] struct GitHubRelease<'a> { #[serde(flatten)] meta: GitHubReleaseMeta<'a>, //reactions: todo!(), assets: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, ramhorns::Content)] struct GitHubReleaseMeta<'a> { url: String, assets_url: String, upload_url: String, html_url: String, id: u64, //author: todo!(), node_id: String, tag_name: String, #[serde(skip_deserializing, default = "empty_str")] tag_name_strip_prefix: &'a str, target_commitish: String, name: String, draft: bool, prerelease: bool, created_at: String, published_at: String, tarball_url: String, zipball_url: String, body: String, } #[derive(Debug, Clone, Serialize, Deserialize)] struct GitHubReleaseAsset { url: String, id: u64, node_id: String, name: String, label: Option, //uploader: todo!(), content_type: String, state: String, size: u64, download_count: u64, created_at: String, updated_at: String, browser_download_url: String, } #[inline(always)] const fn empty_str<'a>() -> &'a str { "" } async fn mkdir_all(path: impl AsRef) -> Result<()> { tokio::fs::DirBuilder::new() .recursive(true) .create(path) .await .into_diagnostic() .wrap_err("Creating directory and any missing parents failed.") } async fn fetch_latest_release<'a, 'b>( reqwest: &'b reqwest::Client, repo: &'b str, ) -> Result> { const FETCH_META_ERROR_MSG: &'static str = "Fetching the latest release metadata from GitHub failed."; let url = url::Url::parse(&format!( "https://api.github.com/repos/{repo}/releases/latest" )) .into_diagnostic() .wrap_err("Invalid GitHub repo for download step.")?; let mut resp = reqwest .get(url) .send() .await .into_diagnostic() .wrap_err(FETCH_META_ERROR_MSG)?; let body = resp .text() .await .into_diagnostic() .wrap_err(FETCH_META_ERROR_MSG)?; let release: GitHubRelease = serde_json::from_str(&body) .into_diagnostic() .wrap_err_with(|| format!("{}: {}", FETCH_META_ERROR_MSG, body))?; Ok(release) } #[cfg(test)] mod tests { #[test] fn decode_latest_release() { tokio::runtime::Builder::new_current_thread() .enable_io() .build() .unwrap() .block_on(async move { let body = super::fetch_latest_release( &reqwest::Client::new(), "notepad-plus-plus/notepad-plus-plus", ) .await .unwrap(); assert_eq!(body.meta.draft, false); }); } }