2024-12-13 00:03:25 +01:00
mod action ;
2025-02-04 16:33:00 +01:00
mod environment ;
2024-12-12 19:45:45 +01:00
mod file ;
2025-02-07 14:08:38 +01:00
#[ cfg(test) ]
mod integration_test ;
2024-12-17 05:49:37 +01:00
mod logger ;
2025-02-03 22:33:30 +01:00
mod os_str_extension ;
2025-02-04 16:33:00 +01:00
mod os_string_builder ;
2024-12-13 00:03:25 +01:00
mod server ;
2025-02-04 16:33:00 +01:00
mod shell_interface ;
2024-12-12 11:59:00 +01:00
2024-12-13 00:03:25 +01:00
use crate ::action ::{ Action , FileAction , ServerActions } ;
2025-02-04 16:33:00 +01:00
use crate ::environment ::{ Environment , Prod } ;
2024-12-13 00:03:25 +01:00
use crate ::file ::{ FileMatcher , FileNameInfo } ;
2024-12-16 15:48:05 +01:00
use crate ::logger ::{ LogLevel , Logger } ;
2025-02-03 22:33:30 +01:00
use crate ::os_str_extension ::OsStrExtension ;
2024-12-15 14:34:19 +01:00
use crate ::os_string_builder ::ReplaceWithOsStr ;
2025-02-03 00:29:23 +01:00
use crate ::server ::{ RelativeLocalPathAnker , ServerAddress } ;
2025-02-07 14:08:38 +01:00
use crate ::shell_interface ::{ ScpParam , ServerCommand , ShellCommand } ;
2024-12-11 14:13:32 +01:00
use clap ::{ Parser , Subcommand , ValueEnum } ;
2024-12-12 11:59:00 +01:00
use lazy_regex ::{ lazy_regex , Lazy , Regex } ;
use server ::{ Server , ServerReference } ;
2024-12-11 21:27:13 +01:00
use std ::cell ::LazyCell ;
2025-02-03 22:11:25 +01:00
use std ::ffi ::OsString ;
2024-12-12 11:59:00 +01:00
use std ::hash ::Hash ;
2024-12-13 00:03:25 +01:00
use std ::io ::Write ;
use std ::iter ::once ;
2025-02-02 14:16:16 +01:00
use std ::path ::{ Path , PathBuf } ;
2025-01-08 18:34:59 +01:00
use std ::{ env , fs , io } ;
2024-12-11 10:42:44 +01:00
2024-12-11 14:13:32 +01:00
const SERVERS_ENV_VAR : & str = " MSSH_SERVERS " ;
2024-12-15 14:34:19 +01:00
const EDITOR_ENV_VAR : & str = " MSSH_EDITOR " ;
const FILE_PLACEHOLDER : & str = " <file> " ;
2024-12-11 14:13:32 +01:00
/// Uploads a file or executes a command on multiple configured servers
2024-12-11 10:42:44 +01:00
///
2024-12-11 14:13:32 +01:00
/// Servers must either be configured via environment variable or denote their server directory with
/// a double colon: crea:home/crea.
2024-12-11 10:42:44 +01:00
///
2024-12-11 14:13:32 +01:00
/// --- Configuration via environment variable ---
2024-12-11 10:42:44 +01:00
///
2024-12-15 14:34:19 +01:00
/// Use `MSSH_SERVERS="crea:home/crea,sky:sky,lobby:,city:city2"` to configure servers.
2024-12-11 10:42:44 +01:00
#[ derive(Parser, Debug) ]
#[ command(version, about, long_about) ]
2025-02-04 16:33:00 +01:00
pub struct Args {
2024-12-11 14:13:32 +01:00
/// The action to perform
#[ command(subcommand) ]
command : Command ,
/// The ssh names and optionally home directories of the servers to perform the action on
2025-02-04 23:43:11 +01:00
#[ arg(num_args = 0..) ]
servers : Vec < String > ,
2024-12-16 15:48:05 +01:00
/// How verbose logging output should be
2024-12-17 05:38:39 +01:00
#[ arg(long, default_value = " info " , conflicts_with_all = [ " quiet " , " info " ] ) ]
log_level : LogLevel ,
2024-12-16 15:48:05 +01:00
/// Only log errors
2024-12-17 05:38:39 +01:00
#[ arg(short, long, default_value = " false " , conflicts_with_all = [ " info " ] ) ]
2024-12-16 15:48:05 +01:00
quiet : bool ,
/// Log additional debugging info
2024-12-17 05:49:37 +01:00
#[ arg(short = 'v', long, default_value = " false " ) ]
2024-12-16 15:48:05 +01:00
info : bool ,
2024-12-11 14:13:32 +01:00
}
#[ derive(Subcommand, Debug) ]
enum Command {
/// Upload a file to the servers
#[ command(visible_short_flag_alias = 'u') ]
Upload {
2025-02-02 22:04:59 +01:00
/// The files to upload
files : Vec < PathBuf > ,
2025-02-02 14:16:16 +01:00
/// The ssh server to get the file from.
///
/// When this option is set, the file path must be absolute, or relative to the server directory.
/// The upload-directory has no influence on where the file will be taken from.
#[ arg(short = 'S', long) ]
2025-02-04 23:43:11 +01:00
file_server : Option < String > ,
2024-12-11 14:13:32 +01:00
/// How to handle older versions of the file
2024-12-13 00:46:35 +01:00
#[ arg(short = 'a', long, default_value = " delete " , default_missing_value = " archive " , num_args = 0..=1) ]
2024-12-11 14:13:32 +01:00
old_version_policy : OldVersionPolicy ,
2024-12-12 12:07:07 +01:00
/// The directory where to upload to, relative to the server directory
2025-02-03 15:36:16 +01:00
#[ arg(short = 'd', long, default_value = " plugins " ) ]
2024-12-12 09:50:32 +01:00
upload_directory : PathBuf ,
2024-12-12 11:59:00 +01:00
/// Skip the confirmation dialog
2024-12-13 00:46:35 +01:00
#[ arg(long, default_value = " false " ) ]
2024-12-12 11:59:00 +01:00
no_confirm : bool ,
2024-12-12 12:07:07 +01:00
/// The prefix of the name of older versions of the file, which should be replaced or deleted
#[ arg(short, long) ]
file_name : Option < String > ,
2025-02-03 15:36:16 +01:00
/// Only upload files which are not present yet on the target server
#[ arg(short, long, default_value = " false " ) ]
pure : bool ,
2025-02-03 16:08:36 +01:00
/// The prefixes of the names of files not to upload
#[ arg(short, long, num_args = 0..) ]
exclude : Vec < String > ,
2024-12-11 14:13:32 +01:00
} ,
/// Execute a command on the servers
#[ command(visible_short_flag_alias = 'c') ]
2024-12-15 14:34:19 +01:00
#[ allow(clippy::enum_variant_names) ]
2024-12-11 14:13:32 +01:00
Command {
/// The command to execute
command : String ,
} ,
2024-12-15 14:34:19 +01:00
/// Open a file in your local editor and upload it afterward.
///
/// The editor will be opened for every server in the order provided by you.
/// The command to start the editor can be configured via environment variable `MSSH_EDITOR`:
///
/// `export MSSH_EDITOR="nano <file>"`
///
/// File is a placeholder that will be replaced with the actual name of the file.
#[ command(visible_short_flag_alias = 'e') ]
Editor {
/// A path to the file to edit, relative to the server directory
file : PathBuf ,
/// The command to start the editor. Supports the placeholder `<file>`, e.g. "nano <file>".
///
/// If omitted, the command will be taken from the environment variable `MSSH_EDITOR`.
#[ arg(short, long) ]
editor : Option < String > ,
2025-02-01 13:52:34 +01:00
/// The directory where to save the file to.
///
/// Default directory is `~/Downloads`
#[ arg(short = 'd', long) ]
download_directory : Option < PathBuf > ,
2024-12-15 14:34:19 +01:00
/// Override existing files in the working directory
#[ arg(short = 'f', long = " override " , default_value = " false " ) ]
override_existing : bool ,
} ,
2024-12-11 14:13:32 +01:00
}
#[ derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default, ValueEnum) ]
enum OldVersionPolicy {
/// Ignore the existence of older versions
Ignore ,
/// Rename older versions: foo.jar -> foo.jarr
Archive ,
/// Delete older versions
#[ default ]
Delete ,
2024-12-11 10:42:44 +01:00
}
2025-02-04 16:33:00 +01:00
#[ derive(Debug, Default) ]
pub struct Application < E > {
pub environment : E ,
}
2024-12-11 10:42:44 +01:00
2025-02-04 16:33:00 +01:00
impl < E > Application < E >
where
2025-02-07 14:08:38 +01:00
E : Environment ,
2025-02-04 16:33:00 +01:00
{
2025-02-04 23:09:02 +01:00
pub fn run ( & mut self ) -> Result < ( ) , String > {
2025-02-04 16:33:00 +01:00
let args = Args ::try_parse_from ( self . environment . args_os ( ) ) . map_err ( | e | e . to_string ( ) ) ? ;
self . run_with_args ( args )
}
2025-02-03 12:33:15 +01:00
2025-02-04 23:09:02 +01:00
pub fn run_with_args ( & mut self , args : Args ) -> Result < ( ) , String > {
2025-02-05 23:55:06 +01:00
macro_rules ! env {
( ) = > {
& mut self . environment
} ;
}
2025-02-04 23:09:02 +01:00
2025-02-04 16:33:00 +01:00
let logger = Logger {
//all the below options are conflicting with each other so an if else is fine
level : if args . quiet {
LogLevel ::Error
} else if args . info {
LogLevel ::Debug
} else {
args . log_level
} ,
} ;
let mut configured_servers = LazyCell ::new ( | | self . parse_server_configuration_from_env ( ) ) ;
let servers = args
. servers
2025-02-04 23:43:11 +01:00
. into_iter ( )
. map ( | ref_str | {
ServerReference ::from_str ( & ref_str , | | self . get_home_directory ( ) )
. map_err ( | e | format! ( " Invalid server reference ' {ref_str} ': {e} " ) )
} )
. collect ::< Result < Vec < _ > , _ > > ( ) ?
. into_iter ( )
2025-02-04 16:33:00 +01:00
. map ( | server_reference | {
let server_identifier = server_reference . get_identifier ( ) ;
server_reference
. clone ( )
. try_resolve_lazy ( & mut configured_servers )
. map_err ( | msg | format! ( " Can't resolve server directory for ' {server_identifier} ': {msg} " ) )
. and_then ( | opt_server | {
opt_server . ok_or ( format! (
" no server directory has been configured for server '{server_identifier}' "
) )
} )
} )
. collect ::< Result < Vec < _ > , _ > > ( ) ? ;
match args . command {
Command ::Upload {
mut files ,
file_server ,
old_version_policy ,
mut upload_directory ,
no_confirm ,
file_name ,
pure ,
exclude ,
} = > {
Self ::require_non_empty_servers ( & servers ) ? ;
Self ::require_non_empty ( & files , " files to upload " ) ? ;
//resolve file server
let file_server = match file_server {
2025-02-04 23:43:11 +01:00
Some ( ref_str ) = > {
let server_reference =
ServerReference ::from_str ( & ref_str , | | self . get_home_directory ( ) )
. map_err ( | e | format! ( " Invalid file-server reference ' {ref_str} ': {e} " ) ) ? ;
2025-02-04 16:33:00 +01:00
let file_server_identifier = server_reference . get_identifier ( ) . to_string ( ) ;
let server = server_reference . try_resolve_lazy ( & mut configured_servers )
. map_err ( | e | format! ( " Can't resolve server directory for file-server ' {file_server_identifier} ': {e} " ) ) ?
. ok_or_else ( | | format! ( " no server directory has been configured for file-server ' {file_server_identifier} ' " ) ) ? ;
Some ( server )
2025-02-02 22:04:59 +01:00
}
2025-02-04 16:33:00 +01:00
None = > None ,
} ;
2025-02-04 23:09:02 +01:00
self . start_ssh_agent ( & logger ) ? ;
2025-02-04 16:33:00 +01:00
//make sure files exist
match & file_server {
Some ( file_server ) = > match & file_server . address {
ServerAddress ::Ssh { ssh_address } = > {
//canonicalize remote files -> also makes sure they exist
files = files
. iter ( )
. map ( | file | {
2025-02-05 23:55:06 +01:00
let output = ShellCommand ::Ssh {
address : ssh_address . to_string ( ) ,
server_command : ServerCommand ::Realpath {
path : file_server . server_directory_path . join ( file ) ,
} ,
}
. in_env ( env! ( ) )
. output ( )
. into_result ( )
. map_err ( | e | format! ( " Failed to canonicalize files: {e} " ) ) ? ;
2025-02-04 16:33:00 +01:00
2025-02-05 23:55:06 +01:00
if ! output . status . success {
2025-02-04 16:33:00 +01:00
Err ( format! (
" Path doesn't match any files on file-server: {} " ,
file . to_string_lossy ( )
) ) ? ;
}
2025-02-02 14:16:16 +01:00
2025-02-05 23:55:06 +01:00
let denoted_files = output
. stdout
2025-02-04 16:33:00 +01:00
. split ( b '\n' ) //split at line breaks
. into_iter ( )
2025-02-05 23:55:06 +01:00
. filter ( | file_name | ! file_name . is_empty ( ) ) //needed since realpath sometimes gives us empty lines
2025-02-04 16:33:00 +01:00
. map ( PathBuf ::from )
. collect ::< Vec < _ > > ( ) ;
2024-12-13 00:03:25 +01:00
2025-02-04 16:33:00 +01:00
Ok ( denoted_files )
} )
. collect ::< Result < Vec < _ > , String > > ( ) ?
2025-02-03 22:11:25 +01:00
. into_iter ( )
2025-02-04 16:33:00 +01:00
. flatten ( )
. collect ( ) ;
}
ServerAddress ::Localhost = > files
. iter ( )
. map ( | file | file_server . server_directory_path . join ( file ) )
. try_for_each ( Self ::check_local_file_exists ) ? ,
} ,
None = > files . iter ( ) . try_for_each ( Self ::check_local_file_exists ) ? ,
}
let file_details = files
. into_iter ( )
. map ( | file | {
FileNameInfo ::try_from ( file . clone ( ) )
. map ( | info | ( file . clone ( ) , info ) )
. map_err ( | e | format! ( " Bad file ' {} ': {e} " , file . to_string_lossy ( ) ) )
} )
. collect ::< Result < Vec < _ > , _ > > ( ) ?
. into_iter ( )
. filter ( | ( _ , info ) | {
! exclude
. iter ( )
. any ( | exclude | info . to_full_file_name ( ) . starts_with ( exclude ) )
} )
. collect ::< Vec < _ > > ( ) ;
log! ( logger , debug , " Files to upload: " ) ;
for ( file , _ ) in & file_details {
log! ( logger , debug , " - {} " , file . to_string_lossy ( ) ) ;
}
//create overview of what has to be done on each server
let actions = servers
. iter ( )
. map ( | server | {
//on local server canonicalize upload_directory
if let ServerAddress ::Localhost = & server . address {
//create upload directory if it doesn't exist
fs ::create_dir_all ( & upload_directory )
. map_err ( | e | format! ( " Failed to create upload-directory: {e} " ) ) ? ;
upload_directory = fs ::canonicalize ( & upload_directory )
. map_err ( | e | format! ( " failed to resolve upload-directory: {e} " ) ) ? ;
}
let working_directory = server . server_directory_path . join ( & upload_directory ) ;
Ok ( ServerActions {
server ,
actions : {
let present_file_names : Vec < OsString > = match & server . address {
2025-02-05 23:55:06 +01:00
ServerAddress ::Ssh { ssh_address } = > ShellCommand ::Ssh {
address : ssh_address . to_string ( ) ,
server_command : ServerCommand ::Ls {
dir : working_directory . clone ( ) ,
} ,
}
. in_env ( env! ( ) )
. output ( )
. and_expect_success ( )
. into_result_with_error_logging ( & logger )
. map_err ( | e | {
format! (
" Failed to query present files on server {}: {e} " ,
server . get_name ( )
)
} ) ?
. stdout
2025-02-04 16:33:00 +01:00
. split ( b '\n' )
2025-02-03 17:55:34 +01:00
. into_iter ( )
2025-02-04 16:33:00 +01:00
. map ( OsString ::from )
2025-02-03 17:55:34 +01:00
. collect ( ) ,
2025-02-04 16:33:00 +01:00
ServerAddress ::Localhost = > fs ::read_dir ( & working_directory )
. map_err ( | e | format! ( " Failed to get files in working directory: {e} " ) ) ?
. map ( | entry | {
entry . map_err ( | e | format! ( " Failed to access directory entry: {e} " ) )
} )
. collect ::< Result < Vec < _ > , _ > > ( ) ?
. into_iter ( )
. filter_map ( | entry | {
if entry . path ( ) . is_file ( ) {
Some ( entry . file_name ( ) )
} else {
None
}
} )
. collect ( ) ,
} ;
file_details
. iter ( )
. flat_map ( | ( file , file_name_info ) | {
let mut file_matcher = FileMatcher ::from (
file_name
. as_ref ( )
. map ( OsString ::from )
. unwrap_or ( file_name_info . name . to_os_string ( ) ) ,
) ;
if let Some ( extension ) = file_name_info . extension . as_ref ( ) {
file_matcher = file_matcher . and_extension ( extension ) ;
}
2024-12-13 00:03:25 +01:00
2025-02-04 16:33:00 +01:00
let file_name = file_name_info . to_full_file_name ( ) ;
2024-12-13 00:03:25 +01:00
2025-02-04 16:33:00 +01:00
let add_action =
FileAction ::new ( file , Action ::Add ) . expect ( " path points to file " ) ;
2024-12-13 00:46:35 +01:00
2025-02-04 16:33:00 +01:00
if pure & & present_file_names . iter ( ) . any ( | file | * file = = file_name ) {
log! (
logger ,
debug ,
" file is already present on {}: {} " ,
server . get_name ( ) ,
file_name . to_string_lossy ( )
) ;
return vec! [ ] ; //ignore that file, since it is already present
}
2025-02-03 15:36:16 +01:00
2025-02-04 16:33:00 +01:00
match old_version_policy {
OldVersionPolicy ::Ignore = > {
if ! present_file_names . iter ( ) . any ( | file | * file = = file_name ) {
vec! [ add_action ] //file doesn't exist yet
} else {
vec! [ FileAction ::new ( file , Action ::Replace ) . expect ( " path points to file " ) ]
}
2025-02-03 15:36:16 +01:00
}
2025-02-04 16:33:00 +01:00
OldVersionPolicy ::Archive = > present_file_names
2025-02-03 18:06:57 +01:00
. iter ( )
2025-02-03 18:01:54 +01:00
. filter ( | file | file_matcher . matches ( file ) )
2025-02-04 16:33:00 +01:00
. map ( | file | {
FileAction ::new (
file ,
Action ::rename (
osf! ( file )
+ file
. to_string_lossy ( )
. chars ( )
. last ( )
. unwrap_or ( '1' )
. to_string ( ) ,
) ,
)
. expect ( " path points to file " )
2025-02-02 22:04:59 +01:00
} )
2025-02-04 16:33:00 +01:00
. chain ( once ( add_action ) )
. collect ( ) ,
OldVersionPolicy ::Delete = > {
let mut actions = present_file_names
. iter ( )
. filter ( | file | file_matcher . matches ( file ) )
. map ( | present_file | {
//special case -> file has the same name as current file, then we just need to replace it
if * present_file = = file_name {
FileAction ::new ( file , Action ::Replace ) . expect ( " path points to file " )
} else {
FileAction ::new ( present_file , Action ::Delete )
. expect ( " path points to file " )
}
} )
. collect ::< Vec < _ > > ( ) ;
if ! actions . iter ( ) . any ( | action | action . kind = = Action ::Replace ) {
actions . push ( add_action ) ;
}
actions
2024-12-13 13:07:52 +01:00
}
2025-02-02 22:04:59 +01:00
}
2025-02-04 16:33:00 +01:00
} )
. collect ( )
} ,
working_directory ,
} )
2024-12-13 00:03:25 +01:00
} )
2025-02-04 16:33:00 +01:00
. collect ::< Result < Vec < _ > , String > > ( ) ?
. into_iter ( )
. filter ( | server_actions | ! server_actions . actions . is_empty ( ) )
. collect ::< Vec < _ > > ( ) ;
2024-12-13 00:03:25 +01:00
2025-02-04 16:33:00 +01:00
if actions . is_empty ( ) {
log! ( logger , " Nothing to be done, everything is up to date " ) ;
return Ok ( ( ) ) ;
2025-02-03 23:52:42 +01:00
}
2024-12-13 00:03:25 +01:00
2025-02-04 16:33:00 +01:00
log! ( logger , " The following actions will be performed: " ) ;
for server_actions in & actions {
log! ( logger , " {server_actions} " ) ;
log! ( logger , debug , " Detailed file actions: " ) ;
for file_action in & server_actions . actions {
log! ( logger , debug , " {file_action:?} " ) ;
2024-12-13 00:03:25 +01:00
}
}
2025-02-04 23:09:02 +01:00
if ! no_confirm & & ! self . confirm ( " Continue? " , true ) {
log! ( logger , " Aborting... " ) ;
return Ok ( ( ) ) ;
2025-02-04 16:33:00 +01:00
}
for server_actions in actions {
let server = server_actions . server ;
log! ( logger , " Performing actions on {}... " , server . get_name ( ) ) ;
for file_action in server_actions . actions {
match file_action . kind {
Action ::Add | Action ::Replace = > {
2025-02-05 23:55:06 +01:00
let source = match & file_server {
Some ( file_server ) = > ScpParam ::from ( (
file_server ,
file_server . server_directory_path . join ( & file_action . file ) ,
) ) ,
None = > ScpParam ::from ( file_action . file . as_path ( ) ) ,
2025-02-04 16:33:00 +01:00
} ;
2025-02-05 23:55:06 +01:00
let destination = ScpParam ::from ( ( server , & server_actions . working_directory ) ) ;
ShellCommand ::Scp {
source ,
destination ,
}
. in_env ( env! ( ) )
. run_logged ( & logger )
. and_expect_success ( )
. into_result_with_error_logging ( & logger )
. map_err ( | e | format! ( " upload failure: {e} " ) ) ? ;
2025-02-02 02:23:25 +01:00
}
2025-02-04 16:33:00 +01:00
Action ::Delete = > match & server . address {
ServerAddress ::Ssh { ssh_address } = > {
2025-02-05 23:55:06 +01:00
ShellCommand ::Ssh {
address : ssh_address . to_string ( ) ,
server_command : ServerCommand ::Rm {
file : server_actions . working_directory . join ( & file_action . file ) ,
} ,
}
. in_env ( env! ( ) )
. run_logged ( & logger )
. and_expect_success ( )
. into_result_with_error_logging ( & logger )
. map_err ( | e | format! ( " failed to delete old version: {e} " ) ) ? ;
2025-02-04 16:33:00 +01:00
}
ServerAddress ::Localhost = > {
fs ::remove_file ( server_actions . working_directory . join ( & file_action . file ) )
. map_err ( | e | format! ( " failed to delete old version: {e} " ) ) ? ;
}
} ,
Action ::Rename { new_name } = > match & server . address {
ServerAddress ::Ssh { ssh_address } = > {
2025-02-05 23:55:06 +01:00
ShellCommand ::Ssh {
address : ssh_address . to_string ( ) ,
server_command : ServerCommand ::Mv {
source : server_actions . working_directory . join ( & file_action . file ) ,
destination : server_actions . working_directory . join ( & new_name ) ,
} ,
}
. in_env ( env! ( ) )
. run_logged ( & logger )
. and_expect_success ( )
. into_result_with_error_logging ( & logger )
. map_err ( | e | format! ( " failed to rename: {e} " ) ) ? ;
2025-02-04 16:33:00 +01:00
}
ServerAddress ::Localhost = > {
let dir = & server_actions . working_directory ;
fs ::rename ( dir . join ( & file_action . file ) , dir . join ( & new_name ) )
. map_err ( | e | format! ( " failed to rename: {e} " ) ) ? ;
}
} ,
}
2024-12-13 00:03:25 +01:00
}
}
2025-02-04 16:33:00 +01:00
log! ( logger , " Done! " ) ;
}
Command ::Command { command } = > {
self . start_ssh_agent ( & logger ) ? ;
Self ::require_non_empty_servers ( & servers ) ? ;
for server in servers {
log! ( logger , " Running command on '{}'... " , server . get_name ( ) ) ;
match & server . address {
ServerAddress ::Ssh { ssh_address } = > {
2025-02-05 23:55:06 +01:00
ShellCommand ::Ssh {
address : ssh_address . to_string ( ) ,
server_command : ServerCommand ::Execute {
working_directory : server . server_directory_path . clone ( ) ,
command : OsString ::from ( & command ) ,
} ,
}
. in_env ( env! ( ) )
. run_logged ( & logger )
. and_expect_success ( )
. into_result_with_error_logging ( & logger )
. map_err ( | e | format! ( " {e} " ) ) ? ;
2025-02-04 16:33:00 +01:00
}
ServerAddress ::Localhost = > {
2025-02-05 23:55:06 +01:00
let command = shell_words ::split ( & command )
. map_err ( | e | format! ( " failed to parse command: {e} " ) ) ?
. into_iter ( )
. map ( OsString ::from )
. collect ( ) ;
ShellCommand ::Execute {
working_directory : server . server_directory_path . clone ( ) ,
command ,
}
. in_env ( env! ( ) )
. run_logged ( & logger )
. and_expect_success ( )
. into_result_with_error_logging ( & logger ) ? ;
2025-02-04 16:33:00 +01:00
}
2025-02-02 02:23:25 +01:00
}
}
2025-02-04 16:33:00 +01:00
log! ( logger , " Done! " ) ;
2024-12-12 11:59:00 +01:00
}
2025-02-04 16:33:00 +01:00
Command ::Editor {
file ,
editor ,
download_directory ,
override_existing ,
} = > {
//determine download directory
let download_directory = match download_directory {
Some ( download_directory ) = > download_directory ,
None = > {
2025-02-04 23:09:02 +01:00
let home_dir = self
. get_home_directory ( )
2025-02-04 16:33:00 +01:00
. map_err ( | e | format! ( " Missing download-directory: {e} " ) ) ? ;
home_dir . join ( " Downloads " )
2025-01-08 18:34:59 +01:00
}
2025-02-04 16:33:00 +01:00
} ;
//get editor
let editor = editor . ok_or ( ( ) ) . or_else ( | _ | env ::var ( EDITOR_ENV_VAR ) . map_err ( | e | format! ( " You have not specified an editor. Please do so using the --editor flag or the {EDITOR_ENV_VAR} environment variable: {e} " ) ) ) ? ;
fs ::create_dir_all ( & download_directory )
. map_err ( | e | format! ( " failed to create working directory: {e} " ) ) ? ;
//make sure file doesn't exist in working directory yet, or it will be overridden
let file_name = file
. file_name ( )
. ok_or ( " can only edit files, not directories " ) ? ;
' duplicate_check : {
if ! override_existing
& & fs ::read_dir ( & download_directory )
. map_err ( | e | format! ( " failed to open working directory: {e} " ) ) ?
. collect ::< Result < Vec < _ > , _ > > ( )
. map_err ( | e | format! ( " error while querying working directory contents: {e} " ) ) ?
. iter ( )
. any ( | entry | entry . file_name ( ) = = file_name )
{
let duplication_notification = format! (
" A file with the name {} already exists in {} " ,
file_name . to_string_lossy ( ) ,
download_directory . to_string_lossy ( )
) ;
2025-02-04 23:43:11 +01:00
if ! args . quiet
& & self . confirm (
format! ( " {duplication_notification} . Do you want to replace it? " ) ,
false ,
)
{
2025-02-04 23:09:02 +01:00
break 'duplicate_check ;
2025-02-04 16:33:00 +01:00
}
2025-01-15 13:38:56 +01:00
2025-02-04 16:33:00 +01:00
return Err ( format! (
" {duplication_notification}. You can override it with --override or -f "
) ) ;
}
2025-01-08 18:34:59 +01:00
}
2024-12-15 14:34:19 +01:00
2025-02-04 16:33:00 +01:00
Self ::require_non_empty_servers ( & servers ) ? ;
self . start_ssh_agent ( & logger ) ? ;
for server in servers {
log! ( logger , " Getting file from {}... " , server . get_name ( ) ) ;
2025-02-05 23:55:06 +01:00
let source = ScpParam ::from ( ( & server , server . server_directory_path . join ( & file ) ) ) ;
ShellCommand ::Scp {
source : source . clone ( ) ,
destination : ScpParam ::from ( download_directory . as_path ( ) ) ,
}
. in_env ( env! ( ) )
. run_logged ( & logger )
. and_expect_success ( )
. into_result_with_error_logging ( & logger )
. map_err ( | e | format! ( " download failure: {e} " ) ) ? ;
2025-02-04 16:33:00 +01:00
//open file in editor
2025-02-05 23:55:06 +01:00
let editor_command = shell_words ::split ( & editor )
2025-02-04 16:33:00 +01:00
. map_err ( | e | format! ( " failed to parse editor command: {e} " ) ) ?
. into_iter ( )
. map ( | part | {
part . replace_with_os_str ( FILE_PLACEHOLDER , download_directory . join ( file_name ) )
} )
. collect ::< Vec < _ > > ( ) ;
2025-02-05 23:55:06 +01:00
ShellCommand ::Editor ( editor_command )
. in_env ( env! ( ) )
. run_logged ( & logger )
. and_expect_success ( )
. into_result_with_error_logging ( & logger )
2025-02-04 16:33:00 +01:00
. map_err ( | e | format! ( " failed to open file in editor: {e} " ) ) ? ;
//upload file again
2025-02-05 23:55:06 +01:00
ShellCommand ::Scp {
source : ScpParam ::from ( download_directory . join ( file_name ) . as_path ( ) ) ,
destination : source ,
}
. in_env ( env! ( ) )
. run_logged ( & logger )
. and_expect_success ( )
. into_result_with_error_logging ( & logger )
. map_err ( | e | format! ( " failed to re-upload file: {e} " ) ) ? ;
2025-02-04 16:33:00 +01:00
}
2024-12-15 14:34:19 +01:00
2025-02-04 16:33:00 +01:00
log! ( logger , " Done! " ) ;
2024-12-15 14:34:19 +01:00
}
}
2024-12-12 11:59:00 +01:00
2025-02-04 16:33:00 +01:00
Ok ( ( ) )
2025-02-03 22:11:25 +01:00
}
2025-02-04 16:33:00 +01:00
fn check_local_file_exists < P > ( path : P ) -> Result < ( ) , String >
where
P : AsRef < Path > ,
2025-02-03 22:11:25 +01:00
{
2025-02-04 16:33:00 +01:00
let path = path . as_ref ( ) ;
if ! path . is_file ( ) {
return Err ( format! (
" {} does not point to a file " ,
path . to_string_lossy ( )
) ) ;
}
2025-02-03 22:11:25 +01:00
2025-02-04 16:33:00 +01:00
Ok ( ( ) )
2025-02-02 14:16:16 +01:00
}
2025-02-04 16:33:00 +01:00
fn require_non_empty_servers < T > ( servers : & [ T ] ) -> Result < ( ) , String > {
Self ::require_non_empty ( servers , " servers for this operation " )
}
2025-02-02 14:16:16 +01:00
2025-02-04 16:33:00 +01:00
fn require_non_empty < T > ( slice : & [ T ] , slice_name : & str ) -> Result < ( ) , String > {
if slice . is_empty ( ) {
Err ( format! (
" You did not provide any {slice_name}. Please see --help "
) ) ?
}
Ok ( ( ) )
2025-02-02 22:04:59 +01:00
}
2025-02-04 23:09:02 +01:00
fn start_ssh_agent ( & mut self , logger : & Logger ) -> Result < ( ) , String > {
let env = & mut self . environment ;
2025-02-04 16:33:00 +01:00
//start the ssh agent
2025-02-05 23:55:06 +01:00
let agent_output = ShellCommand ::SshAgent
. in_env ( env )
. output ( )
. and_expect_success ( )
. into_result_with_error_logging ( logger )
. map_err ( | e | format! ( " Failed to start ssh agent: {e} " ) ) ? ;
let agent_stdout = & agent_output
. stdout
. into_string ( )
. map_err ( | _ | " ssh-agent returned invalid utf-8 - how did this even happen? " ) ? ;
2025-02-02 02:23:25 +01:00
2025-02-04 16:33:00 +01:00
//set the env vars from the agent
static ENV_VAR_REGEX : Lazy < Regex > = lazy_regex! ( " (.+?)=(.+?); " ) ;
2025-02-05 23:55:06 +01:00
for capture in ENV_VAR_REGEX . captures_iter ( agent_stdout ) {
2025-02-04 16:33:00 +01:00
let ( _ , [ env_var , value ] ) = capture . extract ( ) ;
env . set_var ( env_var , value ) ;
}
2025-02-03 01:47:36 +01:00
2025-02-04 16:33:00 +01:00
//add the ssh key
2025-02-05 23:55:06 +01:00
ShellCommand ::ShhAdd
. in_env ( env )
. run_logged ( logger )
. and_expect_success ( )
. into_result_with_error_logging ( logger )
2025-02-04 16:33:00 +01:00
. map_err ( | e | format! ( " failed to add ssh-key: {e} " ) ) ? ;
Ok ( ( ) )
2024-12-17 06:02:25 +01:00
}
2025-02-04 16:33:00 +01:00
fn parse_server_configuration_from_env ( & self ) -> Result < Vec < Server > , String > {
self
. environment
. var ( SERVERS_ENV_VAR )
. map_err ( | _ | format! ( " Missing environment variable {} " , SERVERS_ENV_VAR ) )
. and_then ( | value | parse_server_configuration ( & value , | | self . get_home_directory ( ) ) )
2024-12-13 21:03:05 +01:00
}
2025-02-04 23:09:02 +01:00
2025-02-04 16:33:00 +01:00
fn get_home_directory ( & self ) -> Result < PathBuf , String > {
2025-02-04 23:09:02 +01:00
self
. environment
. get_home_directory ( )
. ok_or ( " Failed to find your home directory " . to_string ( ) )
}
fn confirm < S > ( & mut self , prompt : S , default_value : bool ) -> bool
where
S : ToString ,
{
loop {
print! (
" {}[{}] " ,
prompt . to_string ( ) ,
if default_value { " Y|n " } else { " y|N " }
) ;
io ::stdout ( ) . flush ( ) . expect ( " failed to flush stdout " ) ;
let line = self
. environment
. read_line ( )
. expect ( " Failed to read console input " ) ;
match line . to_lowercase ( ) . as_str ( ) {
" " = > return default_value ,
" y " | " yes " = > return true ,
" n " | " no " = > return false ,
_ = > println! ( " Invalid input, please choose one of the provided options " ) ,
}
}
2024-12-12 11:59:00 +01:00
}
2025-02-04 16:33:00 +01:00
}
2024-12-12 11:59:00 +01:00
2025-02-04 16:33:00 +01:00
fn main ( ) -> Result < ( ) , String > {
Application ::< Prod > ::default ( ) . run ( )
2024-12-11 10:42:44 +01:00
}
2025-02-04 23:09:02 +01:00
fn parse_server_configuration < F > (
config_str : & str ,
get_home_directory : F ,
) -> Result < Vec < Server > , String >
where
F : Fn ( ) -> Result < PathBuf , String > ,
{
2024-12-11 16:37:15 +01:00
config_str
. split ( ',' )
. map ( | server_entry | {
2025-02-04 23:09:02 +01:00
Server ::from_str (
server_entry ,
RelativeLocalPathAnker ::Home ,
& get_home_directory ,
)
. map_err ( | e | format! ( " Invalid server entry ' {server_entry} ': {e} " ) )
2024-12-11 10:42:44 +01:00
} )
2024-12-11 16:37:15 +01:00
. collect ( )
2024-12-11 10:42:44 +01:00
}
#[ cfg(test) ]
mod test {
2024-12-12 11:59:00 +01:00
use crate ::parse_server_configuration ;
2025-02-02 02:23:25 +01:00
use crate ::server ::{ Server , ServerAddress } ;
use std ::fs ;
2024-12-11 10:42:44 +01:00
use std ::path ::PathBuf ;
#[ test ]
fn test_parse_server_configuration ( ) {
2025-02-07 13:03:22 +01:00
//setup directory structure for test
let home_dir = PathBuf ::from ( " target/test " ) ;
const LOCAL_SERVER_DIR : & str = " fizz/buzz " ;
fs ::create_dir_all ( home_dir . join ( LOCAL_SERVER_DIR ) ) . expect ( " failed to create server directory " ) ;
let servers = parse_server_configuration ( & format! ( " foo:bar,.: {LOCAL_SERVER_DIR} " ) , | | {
Ok ( home_dir . clone ( ) )
} )
. expect ( " valid server configuration " ) ;
2024-12-11 14:13:32 +01:00
assert_eq! (
vec! [
Server {
2025-02-02 02:23:25 +01:00
address : ServerAddress ::Ssh {
ssh_address : " foo " . to_string ( )
} ,
2024-12-11 14:13:32 +01:00
server_directory_path : PathBuf ::from ( " bar " ) ,
} ,
Server {
2025-02-02 02:23:25 +01:00
address : ServerAddress ::Localhost ,
2025-02-07 13:03:22 +01:00
server_directory_path : home_dir
. join ( LOCAL_SERVER_DIR )
. canonicalize ( )
. expect ( " home dir exists " ) ,
2024-12-11 14:13:32 +01:00
}
] ,
servers
) ;
2024-12-11 10:42:44 +01:00
}
2024-12-13 12:50:05 +01:00
2025-02-02 02:23:25 +01:00
/// When we join an absolute path to a relative path, it becomes a relative path
2024-12-13 12:50:05 +01:00
#[ test ]
fn path_experiment ( ) {
let server_dir = PathBuf ::from ( " steptech " ) ;
let upload_dir = PathBuf ::from ( " /home " ) ; //absolute path
let joined = server_dir . join ( upload_dir ) ;
assert_eq! ( PathBuf ::from ( " /home " ) , joined ) ;
}
2025-02-02 02:23:25 +01:00
2025-02-03 00:51:56 +01:00
/// When renaming a file in a folder, the folder is relevant in the new name
2025-02-02 02:23:25 +01:00
#[ test ]
fn rename_experiment ( ) {
fs ::rename ( " test-ressources/files/test " , " test-ressources/files/test1 " )
. expect ( " failed to rename test file " ) ;
fs ::rename ( " test-ressources/files/test1 " , " test-ressources/files/test " )
. expect ( " failed to rename test1 file back to test " ) ;
}
2025-02-03 00:51:56 +01:00
#[ test ]
fn mkdir_experiment ( ) {
fs ::create_dir_all ( " ./test-ressources/files/../python " )
. expect ( " failed to create directory with relative path " ) ;
}
2024-12-11 10:42:44 +01:00
}