diff --git a/src/environment.rs b/src/environment.rs new file mode 100644 index 0000000..a51a2bc --- /dev/null +++ b/src/environment.rs @@ -0,0 +1,44 @@ +use std::env; +use std::env::VarError; +use std::ffi::{OsStr, OsString}; +use std::path::PathBuf; + +pub trait Environment { + fn args_os(&self) -> Vec; + fn var(&self, key: K) -> Result + where + K: AsRef; + fn set_var(&self, key: K, value: V) + where + K: AsRef, + V: AsRef; + fn get_home_directory(&self) -> Option; +} + +#[derive(Debug, Default)] +pub struct Prod; + +impl Environment for Prod { + fn args_os(&self) -> Vec { + env::args_os().collect() + } + + fn var(&self, key: K) -> Result + where + K: AsRef, + { + env::var(key) + } + + fn set_var(&self, key: K, value: V) + where + K: AsRef, + V: AsRef + { + env::set_var(key, value); + } + + fn get_home_directory(&self) -> Option { + homedir::my_home().ok().flatten() + } +} diff --git a/src/main.rs b/src/main.rs index cc7d05d..ad86667 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,16 @@ mod action; mod command; +mod environment; mod file; mod logger; -mod os_string_builder; mod os_str_extension; +mod os_string_builder; mod server; +mod shell_interface; use crate::action::{Action, FileAction, ServerActions}; -use crate::command::{CommandSpecificError, ExecutionError, LogRunnable}; +use crate::command::LogRunnable; +use crate::environment::{Environment, Prod}; use crate::file::{FileMatcher, FileNameInfo}; use crate::logger::{LogLevel, Logger}; use crate::os_str_extension::OsStrExtension; @@ -41,11 +44,12 @@ type ShellCmd = std::process::Command; /// Use `MSSH_SERVERS="crea:home/crea,sky:sky,lobby:,city:city2"` to configure servers. #[derive(Parser, Debug)] #[command(version, about, long_about)] -struct Args { +pub struct Args { /// The action to perform #[command(subcommand)] command: Command, /// The ssh names and optionally home directories of the servers to perform the action on + //TODO from_str always uses Prod environment -> handwrite that section #[arg(num_args = 0.., value_parser = ServerReference::from_str)] servers: Vec, /// How verbose logging output should be @@ -137,6 +141,8 @@ enum OldVersionPolicy { Delete, } +//TODO IO would also need to be handled by the environment + #[macro_export] macro_rules! input { ($prompt: tt) => {{ @@ -153,481 +159,569 @@ macro_rules! input { }; } -fn main() -> Result<(), String> { - let args = Args::parse(); +#[derive(Debug, Default)] +pub struct Application { + pub environment: E, +} - 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 - }, - }; +impl Application +where + E: Environment, +{ + pub fn run(&self) -> Result<(), String> { + let args = Args::try_parse_from(self.environment.args_os()).map_err(|e| e.to_string())?; + self.run_with_args(args) + } - let mut configured_servers = LazyCell::new(parse_server_configuration_from_env); - let servers = args - .servers - .iter() - .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::, _>>()?; + pub fn run_with_args(&self, args: Args) -> Result<(), String> { + 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 + }, + }; - match args.command { - Command::Upload { - mut files, - file_server, - old_version_policy, - mut upload_directory, - no_confirm, - file_name, - pure, - exclude, - } => { - require_non_empty_servers(&servers)?; - require_non_empty(&files, "files to upload")?; - start_ssh_agent(&logger)?; - - //resolve file server - let file_server = match file_server { - Some(server_reference) => { - 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) - } - None => None, - }; - - //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| { - let output = ShellCmd::new("ssh") - .arg(ssh_address) - .arg(osf!("realpath -e ") + file_server.server_directory_path.join(file)) - .collect_full_output() - .map_err(|e| format!("Failed to canonicalize files: {e}"))?; - - if !output.status.success() { - Err(format!( - "Path doesn't match any files on file-server: {}", - file.to_string_lossy() - ))?; - } - - let denoted_files = osstring_from_ssh_output(output.stdout) - .split(b'\n') //split at line breaks - .into_iter() - .filter(|bytes| !bytes.is_empty()) //needed since realpath sometimes gives us empty lines - .map(PathBuf::from) - .collect::>(); - - Ok(denoted_files) - }) - .collect::, String>>()? - .into_iter() - .flatten() - .collect(); - } - ServerAddress::Localhost => files - .iter() - .map(|file| file_server.server_directory_path.join(file)) - .try_for_each(check_local_file_exists)?, - }, - None => files.iter().try_for_each(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::, _>>()? - .into_iter() - .filter(|(_, info)| { - !exclude - .iter() - .any(|exclude| info.to_full_file_name().starts_with(exclude)) - }) - .collect::>(); - - 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 = match &server.address { - ServerAddress::Ssh { ssh_address } => osstring_from_ssh_output( - ShellCmd::new("ssh") - .arg(ssh_address) - .arg(osf!("ls ") + &working_directory) - .collect_output() - .map_err(|e| { - format!( - "Failed to query present files on server {}: {e}", - server.get_name() - ) - })? - .stdout, - ) - .split(b'\n') - .into_iter() - .map(OsString::from) - .collect(), - 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::, _>>()? - .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); - } - - let file_name = file_name_info.to_full_file_name(); - - let add_action = FileAction::new(file, Action::Add).expect("path points to file"); - - 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 - } - - 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")] - } - } - OldVersionPolicy::Archive => present_file_names - .iter() - .filter(|file| file_matcher.matches(file)) - .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") - }) - .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::>(); - if !actions.iter().any(|action| action.kind == Action::Replace) { - actions.push(add_action); - } - actions - } - } - }) - .collect() - }, - working_directory, + let mut configured_servers = LazyCell::new(|| self.parse_server_configuration_from_env()); + let servers = args + .servers + .iter() + .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::, String>>()? - .into_iter() - .filter(|server_actions| !server_actions.actions.is_empty()) - .collect::>(); + }) + .collect::, _>>()?; - if actions.is_empty() { - log!(logger, "Nothing to be done, everything is up to date"); - return Ok(()); - } + 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")?; + self.start_ssh_agent(&logger)?; - 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:?}"); - } - } - - if !no_confirm { - match input!("Continue? [Y|n] ").to_lowercase().as_str() { - "n" | "no" => { - log!(logger, "Aborting..."); - return Ok(()); + //resolve file server + let file_server = match file_server { + Some(server_reference) => { + 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) } - _ => {} - } - } + None => None, + }; - 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 => { - let scp_source = match &file_server { - Some(file_server) => { - osf!(match &file_server.address { - ServerAddress::Ssh { ssh_address } => format!("{ssh_address}:"), - ServerAddress::Localhost => "".to_string(), - }) + file_server.server_directory_path.join(&file_action.file) - } - None => osf!(&file_action.file), - }; - let scp_target = osf!(match &server.address { - ServerAddress::Ssh { ssh_address } => format!("{ssh_address}:"), - ServerAddress::Localhost => "".to_string(), - }) + &server_actions.working_directory; - ShellCmd::new("scp") - .arg(scp_source) - .arg(scp_target) - .run(&logger) - .map_err(|e| format!("upload failure: {e}"))?; + //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| { + let output = ShellCmd::new("ssh") + .arg(ssh_address) + .arg(osf!("realpath -e ") + file_server.server_directory_path.join(file)) + .collect_full_output() + .map_err(|e| format!("Failed to canonicalize files: {e}"))?; + + if !output.status.success() { + Err(format!( + "Path doesn't match any files on file-server: {}", + file.to_string_lossy() + ))?; + } + + let denoted_files = osstring_from_ssh_output(output.stdout) + .split(b'\n') //split at line breaks + .into_iter() + .filter(|bytes| !bytes.is_empty()) //needed since realpath sometimes gives us empty lines + .map(PathBuf::from) + .collect::>(); + + Ok(denoted_files) + }) + .collect::, String>>()? + .into_iter() + .flatten() + .collect(); } - Action::Delete => match &server.address { - ServerAddress::Ssh { ssh_address } => { - ShellCmd::new("ssh") - .arg(ssh_address) - .arg(osf!("rm ") + server_actions.working_directory.join(&file_action.file)) - .run(&logger) - .map_err(|e| format!("failed to delete old version: {e}"))?; - } - 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 } => { - ShellCmd::new("ssh") - .arg(ssh_address) - .arg( - osf!("mv ") - + server_actions.working_directory.join(&file_action.file) - + " " - + server_actions.working_directory.join(&new_name), - ) - .run(&logger) - .map_err(|e| format!("failed to rename: {e}"))?; - } - 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}"))?; - } - }, - } + 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)?, } - } - log!(logger, "Done!"); - } - Command::Command { command } => { - start_ssh_agent(&logger)?; - require_non_empty_servers(&servers)?; - for server in servers { - log!(logger, "Running command on '{}'...", server.get_name()); - match &server.address { - ServerAddress::Ssh { ssh_address } => { - ShellCmd::new("ssh") - .arg(ssh_address) - .arg(osf!("cd ") + server.server_directory_path + "; " + &command) - .run(&logger) - .map_err(|e| format!("{e}"))?; - } - ServerAddress::Localhost => { - let mut command_args = - shell_words::split(&command).map_err(|e| format!("failed to parse command: {e}"))?; - ShellCmd::new(command_args.remove(0)) - .args(&command_args) - .current_dir(&server.server_directory_path) - .run(&logger) - .map_err(|e| format!("{e}"))?; - } - } - } - log!(logger, "Done!"); - } - Command::Editor { - file, - editor, - download_directory, - override_existing, - } => { - //determine download directory - let download_directory = match download_directory { - Some(download_directory) => download_directory, - None => { - let home_dir = - get_home_directory().map_err(|e| format!("Can't determine download directory: {e}"))?; - home_dir.join("Downloads") - } - }; - - //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::, _>>() - .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() - ); - - if !args.quiet { - match input!("{duplication_notification}. Do you want to replace it? [N|y] ") - .to_lowercase() - .as_str() - { - "y" | "yes" => break 'duplicate_check, - _ => {} - } - } - - return Err(format!( - "{duplication_notification}. You can override it with --override or -f" - )); - } - } - - require_non_empty_servers(&servers)?; - start_ssh_agent(&logger)?; - - for server in servers { - log!(logger, "Getting file from {}...", server.get_name()); - let file_source = osf!(match &server.address { - ServerAddress::Ssh { ssh_address } => format!("{ssh_address}:"), - ServerAddress::Localhost => "".to_string(), - }) + server.server_directory_path.join(&file); - ShellCmd::new("scp") - .arg(&file_source) - .arg(&download_directory) - .run(&logger) - .map_err(|e| format!("download failure: {e}"))?; - - //open file in editor - let mut editor_command_args = shell_words::split(&editor) - .map_err(|e| format!("failed to parse editor command: {e}"))? + let file_details = files .into_iter() - .map(|part| { - part.replace_with_os_str(FILE_PLACEHOLDER, download_directory.join(file_name)) + .map(|file| { + FileNameInfo::try_from(file.clone()) + .map(|info| (file.clone(), info)) + .map_err(|e| format!("Bad file '{}': {e}", file.to_string_lossy())) + }) + .collect::, _>>()? + .into_iter() + .filter(|(_, info)| { + !exclude + .iter() + .any(|exclude| info.to_full_file_name().starts_with(exclude)) }) .collect::>(); - let command = editor_command_args.remove(0); - ShellCmd::new(command) - .args(editor_command_args) - .run(&logger) - .map_err(|e| format!("failed to open file in editor: {e}"))?; + log!(logger, debug, "Files to upload: "); + for (file, _) in &file_details { + log!(logger, debug, "- {}", file.to_string_lossy()); + } - //upload file again - ShellCmd::new("scp") - .arg(download_directory.join(file_name)) - .arg(&file_source) - .run(&logger) - .map_err(|e| format!("failed to re-upload file: {e}"))?; + //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 = match &server.address { + ServerAddress::Ssh { ssh_address } => osstring_from_ssh_output( + ShellCmd::new("ssh") + .arg(ssh_address) + .arg(osf!("ls ") + &working_directory) + .collect_output() + .map_err(|e| { + format!( + "Failed to query present files on server {}: {e}", + server.get_name() + ) + })? + .stdout, + ) + .split(b'\n') + .into_iter() + .map(OsString::from) + .collect(), + 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::, _>>()? + .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); + } + + let file_name = file_name_info.to_full_file_name(); + + let add_action = + FileAction::new(file, Action::Add).expect("path points to file"); + + 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 + } + + 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")] + } + } + OldVersionPolicy::Archive => present_file_names + .iter() + .filter(|file| file_matcher.matches(file)) + .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") + }) + .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::>(); + if !actions.iter().any(|action| action.kind == Action::Replace) { + actions.push(add_action); + } + actions + } + } + }) + .collect() + }, + working_directory, + }) + }) + .collect::, String>>()? + .into_iter() + .filter(|server_actions| !server_actions.actions.is_empty()) + .collect::>(); + + if actions.is_empty() { + log!(logger, "Nothing to be done, everything is up to date"); + return Ok(()); + } + + 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:?}"); + } + } + + if !no_confirm { + match input!("Continue? [Y|n] ").to_lowercase().as_str() { + "n" | "no" => { + log!(logger, "Aborting..."); + return Ok(()); + } + _ => {} + } + } + + 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 => { + let scp_source = match &file_server { + Some(file_server) => { + osf!(match &file_server.address { + ServerAddress::Ssh { ssh_address } => format!("{ssh_address}:"), + ServerAddress::Localhost => "".to_string(), + }) + file_server.server_directory_path.join(&file_action.file) + } + None => osf!(&file_action.file), + }; + let scp_target = osf!(match &server.address { + ServerAddress::Ssh { ssh_address } => format!("{ssh_address}:"), + ServerAddress::Localhost => "".to_string(), + }) + &server_actions.working_directory; + ShellCmd::new("scp") + .arg(scp_source) + .arg(scp_target) + .run(&logger) + .map_err(|e| format!("upload failure: {e}"))?; + } + Action::Delete => match &server.address { + ServerAddress::Ssh { ssh_address } => { + ShellCmd::new("ssh") + .arg(ssh_address) + .arg(osf!("rm ") + server_actions.working_directory.join(&file_action.file)) + .run(&logger) + .map_err(|e| format!("failed to delete old version: {e}"))?; + } + 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 } => { + ShellCmd::new("ssh") + .arg(ssh_address) + .arg( + osf!("mv ") + + server_actions.working_directory.join(&file_action.file) + + " " + + server_actions.working_directory.join(&new_name), + ) + .run(&logger) + .map_err(|e| format!("failed to rename: {e}"))?; + } + 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}"))?; + } + }, + } + } + } + + 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 } => { + ShellCmd::new("ssh") + .arg(ssh_address) + .arg(osf!("cd ") + server.server_directory_path + "; " + &command) + .run(&logger) + .map_err(|e| format!("{e}"))?; + } + ServerAddress::Localhost => { + let mut command_args = shell_words::split(&command) + .map_err(|e| format!("failed to parse command: {e}"))?; + ShellCmd::new(command_args.remove(0)) + .args(&command_args) + .current_dir(&server.server_directory_path) + .run(&logger) + .map_err(|e| format!("{e}"))?; + } + } + } + log!(logger, "Done!"); + } + Command::Editor { + file, + editor, + download_directory, + override_existing, + } => { + //determine download directory + let download_directory = match download_directory { + Some(download_directory) => download_directory, + None => { + let home_dir = self.get_home_directory() + .map_err(|e| format!("Missing download-directory: {e}"))?; + home_dir.join("Downloads") + } + }; - log!(logger, "Done!"); + //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::, _>>() + .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() + ); + + if !args.quiet { + match input!("{duplication_notification}. Do you want to replace it? [N|y] ") + .to_lowercase() + .as_str() + { + "y" | "yes" => break 'duplicate_check, + _ => {} + } + } + + return Err(format!( + "{duplication_notification}. You can override it with --override or -f" + )); + } + } + + Self::require_non_empty_servers(&servers)?; + self.start_ssh_agent(&logger)?; + + for server in servers { + log!(logger, "Getting file from {}...", server.get_name()); + let file_source = osf!(match &server.address { + ServerAddress::Ssh { ssh_address } => format!("{ssh_address}:"), + ServerAddress::Localhost => "".to_string(), + }) + server.server_directory_path.join(&file); + ShellCmd::new("scp") + .arg(&file_source) + .arg(&download_directory) + .run(&logger) + .map_err(|e| format!("download failure: {e}"))?; + + //open file in editor + let mut editor_command_args = shell_words::split(&editor) + .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::>(); + + let command = editor_command_args.remove(0); + ShellCmd::new(command) + .args(editor_command_args) + .run(&logger) + .map_err(|e| format!("failed to open file in editor: {e}"))?; + + //upload file again + ShellCmd::new("scp") + .arg(download_directory.join(file_name)) + .arg(&file_source) + .run(&logger) + .map_err(|e| format!("failed to re-upload file: {e}"))?; + } + + log!(logger, "Done!"); + } } + + Ok(()) } - Ok(()) + fn check_local_file_exists

(path: P) -> Result<(), String> + where + P: AsRef, + { + let path = path.as_ref(); + if !path.is_file() { + return Err(format!( + "{} does not point to a file", + path.to_string_lossy() + )); + } + + Ok(()) + } + + fn require_non_empty_servers(servers: &[T]) -> Result<(), String> { + Self::require_non_empty(servers, "servers for this operation") + } + + fn require_non_empty(slice: &[T], slice_name: &str) -> Result<(), String> { + if slice.is_empty() { + Err(format!( + "You did not provide any {slice_name}. Please see --help" + ))? + } + Ok(()) + } + + fn start_ssh_agent(&self, logger: &Logger) -> Result<(), String> { + let env = &self.environment; + + //start the ssh agent + let agent_output = ShellCmd::new("ssh-agent") + .arg("-s") + .collect_output() + .map_err(|e| format!("failed to start ssh agent: {e}"))?; + let agent_stdout = String::from_utf8_lossy(&agent_output.stdout); + if !agent_output.status.success() { + return Err("failed to start ssh agent; maybe try to run ssh-agent manually?".to_string()); + } + + //set the env vars from the agent + static ENV_VAR_REGEX: Lazy = lazy_regex!("(.+?)=(.+?);"); + for capture in ENV_VAR_REGEX.captures_iter(&agent_stdout) { + let (_, [env_var, value]) = capture.extract(); + env.set_var(env_var, value); + } + + //add the ssh key + ShellCmd::new("ssh-add") + .run(logger) + .map_err(|e| format!("failed to add ssh-key: {e}"))?; + Ok(()) + } + + fn parse_server_configuration_from_env(&self) -> Result, 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())) + } + + fn get_home_directory(&self) -> Result { + self.environment.get_home_directory().ok_or("Failed to find your home directory".to_string()) + } } +fn main() -> Result<(), String> { + Application::::default().run() +} + +//This will be moved into the shell-interface fn osstring_from_ssh_output(output: Vec) -> OsString { #[cfg(unix)] { @@ -642,107 +736,11 @@ fn osstring_from_ssh_output(output: Vec) -> OsString { } } -fn check_local_file_exists

(path: P) -> Result<(), String> -where - P: AsRef, -{ - let path = path.as_ref(); - if !path.is_file() { - return Err(format!( - "{} does not point to a file", - path.to_string_lossy() - )); - } - - Ok(()) -} - -#[allow(dead_code)] -fn check_file_exists_on_server( - path: P, - ssh_address: S, - server_directory: D, -) -> Result<(), String> -where - P: AsRef, - S: AsRef, - D: AsRef, -{ - let full_path = server_directory.as_ref().join(path); - match &ShellCmd::new("ssh") - .arg(ssh_address.as_ref()) - .arg(osf!("test -f ") + &full_path) - .collect_output() - { - Ok(_) => Ok(()), //file exists on file server - Err(CommandSpecificError { - error: ExecutionError::BadExitStatus(_), //test failed - .. - }) => Err(format!( - "File '{}' doesn't exist on file-server", - full_path.to_string_lossy() - )), - Err(e) => Err(format!( - "Failed to check whether file exists on file-server: {e}" - )), - } -} - -fn get_home_directory() -> Result { - homedir::my_home() - .map_err(|e| format!("Failed to determine home directory: {e}")) - .and_then(|home_dir| home_dir.ok_or("Failed to find home directory".to_string())) -} - -fn require_non_empty_servers(servers: &[T]) -> Result<(), String> { - require_non_empty(servers, "servers for this operation") -} - -fn require_non_empty(slice: &[T], slice_name: &str) -> Result<(), String> { - if slice.is_empty() { - Err(format!( - "You did not provide any {slice_name}. Please see --help" - ))? - } - Ok(()) -} - -fn start_ssh_agent(logger: &Logger) -> Result<(), String> { - //start the ssh agent - let agent_output = ShellCmd::new("ssh-agent") - .arg("-s") - .collect_output() - .map_err(|e| format!("failed to start ssh agent: {e}"))?; - let agent_stdout = String::from_utf8_lossy(&agent_output.stdout); - if !agent_output.status.success() { - return Err("failed to start ssh agent; maybe try to run ssh-agent manually?".to_string()); - } - - //set the env vars from the agent - static ENV_VAR_REGEX: Lazy = lazy_regex!("(.+?)=(.+?);"); - for capture in ENV_VAR_REGEX.captures_iter(&agent_stdout) { - let (_, [env_var, value]) = capture.extract(); - env::set_var(env_var, value); - } - - //add the ssh key - ShellCmd::new("ssh-add") - .run(logger) - .map_err(|e| format!("failed to add ssh-key: {e}"))?; - Ok(()) -} - -fn parse_server_configuration_from_env() -> Result, String> { - env::var(SERVERS_ENV_VAR) - .map_err(|_| format!("Missing environment variable {}", SERVERS_ENV_VAR)) - .and_then(|value| parse_server_configuration(&value)) -} - -fn parse_server_configuration(config_str: &str) -> Result, String> { +fn parse_server_configuration(config_str: &str, get_home_directory: F) -> Result, String> where F: Fn() -> Result { config_str .split(',') .map(|server_entry| { - Server::from_str(server_entry, RelativeLocalPathAnker::Home) + Server::from_str(server_entry, RelativeLocalPathAnker::Home, &get_home_directory) .map_err(|e| format!("Invalid server entry '{server_entry}': {e}")) }) .collect() @@ -758,7 +756,7 @@ mod test { #[test] fn test_parse_server_configuration() { let servers = - parse_server_configuration("foo:bar,.:fizz/buzz").expect("valid server configuration"); + parse_server_configuration("foo:bar,.:fizz/buzz", || Ok(PathBuf::from("/test"))).expect("valid server configuration"); assert_eq!( vec![ Server { @@ -769,7 +767,7 @@ mod test { }, Server { address: ServerAddress::Localhost, - server_directory_path: PathBuf::from("fizz/buzz"), + server_directory_path: PathBuf::from("/test/fizz/buzz"), } ], servers diff --git a/src/server.rs b/src/server.rs index 2d1ebdc..cf240a3 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,4 +1,4 @@ -use crate::get_home_directory; +use crate::environment::{Environment, Prod}; use std::cell::LazyCell; use std::error::Error; use std::fmt::{Display, Formatter}; @@ -72,9 +72,13 @@ impl FromStr for ServerReference { type Err = ServerReferenceParseError; fn from_str(s: &str) -> Result { - Server::from_str(s, RelativeLocalPathAnker::CurrentDirectory) - .map(Self::Resolved) - .or_else(|_| Ok(Self::Identifier(s.to_string()))) + Server::from_str(s, RelativeLocalPathAnker::CurrentDirectory, || { + Prod::default() + .get_home_directory() + .ok_or("missing home directory".to_string()) + }) + .map(Self::Resolved) + .or_else(|_| Ok(Self::Identifier(s.to_string()))) } } @@ -126,10 +130,14 @@ impl Server { } } - pub fn from_str( + pub fn from_str( s: &str, relative_local_path_anker: RelativeLocalPathAnker, - ) -> Result { + get_home_directory: F, + ) -> Result + where + F: Fn() -> Result, + { s.split_once(':') .ok_or(ServerParseError::MissingServerDirectory) .and_then(|(identifier, server_directory)| { diff --git a/src/shell_interface.rs b/src/shell_interface.rs new file mode 100644 index 0000000..d16dd5b --- /dev/null +++ b/src/shell_interface.rs @@ -0,0 +1,313 @@ +use crate::log; +use crate::logger::{LogLevel, Logger}; +use std::error::Error; +use std::ffi::OsString; +use std::fmt::{Debug, Display, Formatter}; +use std::io; +use std::iter::once; +use std::path::PathBuf; +use std::process::Command; + +#[derive(Debug)] +pub struct EnvCommand<'a, E> { + command: ShellCommand, + environment: &'a E, +} + +#[derive(Debug, Clone)] +pub enum ShellCommand { + Ssh { + address: String, + server_command: ServerCommand, + }, + Scp { + source: ScpParam, + destination: ScpParam, + }, + SshAgent, + ShhAdd, + Editor(Vec), + Execute { + working_directory: PathBuf, + command: OsString, + }, +} + +impl ShellCommand { + pub fn at(self, environment: &E) -> EnvCommand { + EnvCommand { + command: self, + environment, + } + } +} + +#[derive(Debug, Clone)] +pub enum ServerCommand { + Realpath { + path: PathBuf, + }, + Ls { + dir: PathBuf, + }, + Rm { + file: PathBuf, + }, + Mv { + source: PathBuf, + destination: PathBuf, + }, + Execute { + working_directory: PathBuf, + command: OsString, + }, +} + +#[derive(Debug, Clone)] +pub struct ScpParam { + pub server: Option, + pub path: PathBuf, +} + +impl Display for ShellCommand { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + command_to_string(&build_command_from_shell_command(self)) + ) + } +} + +fn command_to_string(command: &Command) -> String { + once(command.get_program().to_string_lossy().to_string()) + .chain(command.get_args().map(|arg| { + let arg_str = arg.to_string_lossy(); + if arg_str.contains(' ') { + format!("\"{arg_str}\"") + } else { + arg_str.to_string() + } + })) + .collect::>() + .join(" ") +} + +fn build_command_from_shell_command(shell_command: &ShellCommand) -> Command { + todo!() +} + +pub trait ShellInterface { + fn run(self) -> CommandResult; + fn output(self) -> CommandResult; + fn run_logged(self, logger: &Logger) -> CommandResult + where + Self: Sized, + { + match logger.level { + LogLevel::Debug | LogLevel::Info => { + let res = self.run(); + CommandResult { + result: res.result.map(LoggedRunOutput::from), + command: res.command, + } + } + LogLevel::Error => { + let res = self.output(); + CommandResult { + result: res.result.map(LoggedRunOutput::from), + command: res.command, + } + } + } + } +} + +pub trait MaybeCast { + fn maybe_cast(&self) -> Option<&T>; +} + +impl MaybeCast for T { + fn maybe_cast(&self) -> Option<&T> { + Some(self) + } +} + +#[derive(Debug)] +pub enum LoggedRunOutput { + ExitStatus(ExitStatus), + CommandOutput(CommandOutput), +} + +impl From for LoggedRunOutput { + fn from(value: ExitStatus) -> Self { + Self::ExitStatus(value) + } +} + +impl From for LoggedRunOutput { + fn from(value: CommandOutput) -> Self { + Self::CommandOutput(value) + } +} + +impl AsRef for LoggedRunOutput { + fn as_ref(&self) -> &ExitStatus { + match self { + LoggedRunOutput::ExitStatus(status) => status, + LoggedRunOutput::CommandOutput(output) => output.as_ref(), + } + } +} + +impl MaybeCast for LoggedRunOutput { + fn maybe_cast(&self) -> Option<&CommandOutput> { + match self { + LoggedRunOutput::ExitStatus(_) => None, + LoggedRunOutput::CommandOutput(output) => Some(output), + } + } +} + +#[derive(Debug)] +pub struct CommandResult { + pub command: ShellCommand, + pub result: Result, +} + +impl CommandResult { + pub fn into_result(self) -> Result> { + self.result.map_err(|error| CommandError { + command: self.command, + error, + }) + } +} + +impl CommandResult { + pub fn and_expect_success(self) -> CommandResult> + where + T: AsRef, + { + CommandResult { + result: self.result.map_err(ExecutionError::from).and_then(|t| { + if t.as_ref().success { + Ok(t) + } else { + Err(ExecutionError::BadExitStatus(t)) + } + }), + command: self.command, + } + } +} + +impl CommandResult> { + pub fn into_result_with_error_logging( + self, + logger: &Logger, + ) -> Result>> { + self.result.map_err(|error| { + if let ExecutionError::BadExitStatus(output) = &error { + log!(logger, error, "{}", output.stdout.to_string_lossy()); + log!(logger, error, "{}", output.stderr.to_string_lossy()); + } + CommandError { + command: self.command, + error, + } + }) + } +} + +#[derive(Debug, Clone)] +pub struct CommandOutput { + pub stdout: OsString, + pub stderr: OsString, + pub status: ExitStatus, +} + +impl AsRef for CommandOutput { + fn as_ref(&self) -> &ExitStatus { + &self.status + } +} + +#[derive(Debug, Clone)] +pub struct ExitStatus { + pub success: bool, + pub string_form: String, + pub code: Option, +} + +impl AsRef for ExitStatus { + fn as_ref(&self) -> &ExitStatus { + self + } +} + +impl Display for ExitStatus { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self.string_form, f) + } +} + +#[derive(Debug)] +pub struct CommandError { + pub command: ShellCommand, + pub error: E, +} + +impl Display for CommandError +where + E: Display, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Error while running command '{}': {}", + self.command, self.error + ) + } +} + +impl Error for CommandError where E: Error {} + +#[derive(Debug)] +pub struct StartError(io::Error); + +impl Display for StartError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "Failed to run command: {}", self.0) + } +} + +impl Error for StartError {} + +#[derive(Debug)] +pub enum ExecutionError { + StartError(StartError), + BadExitStatus(T), +} + +impl From for ExecutionError { + fn from(value: StartError) -> Self { + Self::StartError(value) + } +} + +impl Display for ExecutionError +where + T: AsRef, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + ExecutionError::StartError(e) => Display::fmt(e, f), + ExecutionError::BadExitStatus(status) => { + write!(f, "execution failed with {}", status.as_ref()) + } + } + } +} + +impl Error for ExecutionError where T: AsRef + Debug {}