2022-06-09 17:47:41 -04:00
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 {
2022-06-09 18:29:58 -04:00
Self {
name ,
steps : steps . into ( ) ,
}
2022-06-09 17:47:41 -04:00
}
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 } = > {
2022-06-10 13:23:07 -04:00
if file . exists ( ) {
2022-06-10 13:30:48 -04:00
println! (
" File {file} already downloaded. " ,
file = file . to_str ( ) . unwrap_or ( " <NON UTF-8> " )
) ;
2022-06-10 13:23:07 -04:00
} 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 )
2022-06-09 17:47:41 -04:00
. into_diagnostic ( )
2022-06-10 13:23:07 -04:00
. 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 ( | | {
2022-06-09 18:29:58 -04:00
miette! ( " Destination file for download step has no parent. " )
2022-06-10 13:23:07 -04:00
} ) ? )
. 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 ( )
2022-06-09 17:47:41 -04:00
. await
. into_diagnostic ( )
2022-06-10 13:23:07 -04:00
. wrap_err ( FETCH_FILE_ERROR_MSG ) ?
{
writer
. write_all_buf ( & mut chunk )
. await
. into_diagnostic ( )
. wrap_err ( WRITE_FILE_ERROR_MSG ) ? ;
}
2022-06-09 17:47:41 -04:00
writer
2022-06-10 13:23:07 -04:00
. flush ( )
2022-06-09 17:47:41 -04:00
. await
. into_diagnostic ( )
. wrap_err ( WRITE_FILE_ERROR_MSG ) ? ;
}
}
Self ::ExtractFile { file , dest } = > {
2022-06-10 13:30:48 -04:00
println! (
" Extracting {file} to {dest}... " ,
file = file . to_str ( ) . unwrap_or ( " <NON UTF-8> " ) ,
dest = dest . to_str ( ) . unwrap_or ( " <NON UTF-8> " )
) ;
2022-06-09 18:29:58 -04:00
const EXTRACT_FILE_ERROR_MSG : & 'static str = " Extracting file failed. " ;
2022-06-10 12:48:07 -04:00
mkdir_all ( & dest ) . await . wrap_err ( EXTRACT_FILE_ERROR_MSG ) ? ;
2022-06-09 18:29:58 -04:00
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 ) ;
2022-06-09 17:47:41 -04:00
}
Self ::ExecuteCommand { file , args } = > {
2022-06-10 13:30:48 -04:00
println! (
" Executing command `{file} {args}`... " ,
file = file . to_str ( ) . unwrap_or ( " <NON UTF-8> " ) ,
args = args . into_iter ( ) . map ( | s | * s ) . collect ::< String > ( ) ,
) ;
2022-06-09 18:29:58 -04:00
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 ) ;
2022-06-09 17:47:41 -04:00
}
2022-06-10 13:01:27 -04:00
Self ::CreateShortcut { target , file } = > {
2022-06-10 13:30:48 -04:00
println! (
" Creating shortcut to {target:?} at {file}... " ,
file = file . to_str ( ) . unwrap_or ( " <NON UTF-8> " )
) ;
2022-06-10 07:53:34 -04:00
const CREATE_SHORTCUT_ERROR_MSG : & 'static str = " Creating shortcut failed. " ;
2022-06-09 18:29:58 -04:00
mkdir_all (
file . parent ( ) . ok_or_else ( | | {
miette! ( " Destination file for shortcut step has no parent. " )
} ) ? ,
)
. await ? ;
2022-06-10 07:53:34 -04:00
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 " )
2022-06-10 13:05:12 -04:00
. arg ( format! ( r # "$shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut({file:?}); $shortcut.TargetPath = {exec_file:?}; $shortcut.Arguments = {args:?}; $shortcut.Save()"# ) )
2022-06-10 07:53:34 -04:00
. 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 ) ;
2022-06-09 17:47:41 -04:00
}
}
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.
2022-06-10 13:05:12 -04:00
args : & ' a str ,
2022-06-09 17:47:41 -04:00
} ,
/// 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 < ( ) > {
2022-06-09 18:29:58 -04:00
tokio ::fs ::DirBuilder ::new ( )
. recursive ( true )
. create ( path )
. await
. into_diagnostic ( )
. wrap_err ( " Creating directory and any missing parents failed. " )
2022-06-09 17:47:41 -04:00
}
2022-06-10 12:42:38 -04:00
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 ) ? ;
2022-06-10 13:01:27 -04:00
let body = resp
. text ( )
. await
2022-06-10 12:42:38 -04:00
. 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 ) ;
} ) ;
}
}