school-computer-toolkit/src/install.rs

377 lines
13 KiB
Rust

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<Step<'a>>,
}
impl<'a> Pipeline<'a> {
#[inline(always)]
pub fn new(name: &'a str, steps: impl Into<Vec<Step<'a>>>) -> 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("<NON UTF-8>")
);
} else {
println!(
"Downloading file {file} from {res:?}...",
file = file.to_str().unwrap_or("<NON UTF-8>")
);
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("<NON UTF-8>"),
dest = dest.to_str().unwrap_or("<NON UTF-8>")
);
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("<NON UTF-8>"),
args = args.into_iter().map(|s| *s).collect::<String>(),
);
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("<NON UTF-8>")
);
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<GitHubReleaseAsset>,
}
#[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<String>,
//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<Path>) -> 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<GitHubRelease<'a>> {
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);
});
}
}