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>, /// Optional icon of the shortcut. icon: Option>, /// 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 } => { 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 } => { 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 release: GitHubRelease = ctx.reqwest.get(url) .send() .await .into_diagnostic() .wrap_err(FETCH_META_ERROR_MSG)? .json() .await .into_diagnostic() .wrap_err(FETCH_META_ERROR_MSG)?; 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) .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 } => { mkdir_all(dest).await?; todo!(); } Self::ExecuteCommand { file, args } => { todo!(); } Self::CreateShortcut { target, icon, file } => { mkdir_all(file.parent().ok_or_else(|| miette!("Destination file for shortcut step has no parent."))?).await?; todo!(); } } 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. arg: &'a [&'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().mode(0o775).recursive(true).create(path).await.into_diagnostic().wrap_err("Creating directory and any missing parents failed.") }