diff --git a/README.md b/README.md index 66e0b1b..0dfed25 100644 --- a/README.md +++ b/README.md @@ -78,3 +78,7 @@ Once you have that installed, just run cargo build --release ``` and you will find an executable in `target/release`. + +### Unit tests + +In order for the unit tests to pass, you will need `python3` diff --git a/src/command.rs b/src/command.rs new file mode 100644 index 0000000..4b9073d --- /dev/null +++ b/src/command.rs @@ -0,0 +1,156 @@ +use crate::log; +use crate::logger::{LogLevel, Logger}; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::io; +use std::iter::once; +use std::process::{Command, ExitStatus, Output}; + +pub trait LogRunnable { + fn run(&mut self, logger: &Logger) -> Result<(), SpecificExecutionError>; + fn collect_output(&mut self) -> Result; +} + +impl LogRunnable for Command { + fn run(&mut self, logger: &Logger) -> Result<(), SpecificExecutionError> { + run(self, logger).map_err(|error| SpecificExecutionError { + command: self, + error, + }) + } + + fn collect_output(&mut self) -> Result { + collect_output(self, None).map_err(|error| SpecificExecutionError { + command: self, + error, + }) + } +} + +fn run(command: &mut Command, logger: &Logger) -> Result<(), ExecutionError> { + match logger.level { + LogLevel::Debug | LogLevel::Info => { + let status = command.status()?; + if !status.success() { + Err(status)?; + } + } + LogLevel::Error => { + collect_output(command, Some(logger))?; + } + } + Ok(()) +} + +fn collect_output( + command: &mut Command, + logger: Option<&Logger>, +) -> Result { + let output = command.output()?; //pipes stdout and stderr automatically + if !output.status.success() { + if let Some(logger) = logger { + log!(logger, error, "{}", String::from_utf8_lossy(&output.stderr)); + } + Err(output.status)?; + } + Ok(output) +} + +#[derive(Debug)] +pub struct SpecificExecutionError<'a> { + pub command: &'a Command, + pub error: ExecutionError, +} + +impl Display for SpecificExecutionError<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Failed to execute command '{}': {}", + command_to_string(self.command), + self.error + ) + } +} + +fn command_to_string(command: &Command) -> String { + once(command.get_program().to_string_lossy()) + .chain(command.get_args().map(|arg| arg.to_string_lossy())) + .collect::>() + .join(" ") +} + +impl Error for SpecificExecutionError<'_> {} + +#[derive(Debug)] +pub enum ExecutionError { + StartError(io::Error), + BadExitStatus(ExitStatus), +} + +impl From for ExecutionError { + fn from(value: io::Error) -> Self { + Self::StartError(value) + } +} + +impl From for ExecutionError { + fn from(value: ExitStatus) -> Self { + Self::BadExitStatus(value) + } +} + +impl Display for ExecutionError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + ExecutionError::StartError(e) => write!(f, "Failed to start command: {}", e), + ExecutionError::BadExitStatus(status) => { + write!(f, "Command failed with {}", status) + } + } + } +} + +impl Error for ExecutionError {} + +#[cfg(test)] +mod test { + use crate::command::{ExecutionError, LogRunnable, SpecificExecutionError}; + use crate::logger::Logger; + use std::path::PathBuf; + use std::process::Command; + + #[test] + fn test_unknown_command() { + let mut command = Command::new("python7"); + let Err( + e @ SpecificExecutionError { + error: ExecutionError::StartError(_), + .. + }, + ) = command + .args([PathBuf::from("test-ressources/python/exit_1.py")]) + .run(&Logger::default()) + else { + panic!("command shouldn't exist"); + }; + assert_eq!(e.to_string(), "Failed to execute command 'python7': Failed to start command: No such file or directory (os error 2)"); + } + + #[test] + fn test_error() { + let mut command = Command::new("python3"); + let Err( + e @ SpecificExecutionError { + error: ExecutionError::BadExitStatus(_), + .. + }, + ) = command + .arg("test-ressources/python/exit_1.py") + .run(&Logger::default()) + else { + panic!("command should return exit-code 1") + }; + assert_eq!(e.to_string(), "Failed to execute command 'python3 test-ressources/python/exit_1.py': Command failed with exit status: 1"); + } +} diff --git a/src/logger.rs b/src/logger.rs index 89226b6..57ddae5 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -39,7 +39,7 @@ macro_rules! log { $logger.$level(format!($($args)*)); }; ($logger:expr, $($args:tt)*) => { - log!($logger, info, $($args)*); + log!($logger, info, $($args)*); //TODO better use default level with log function instead of assuming info as default } } diff --git a/src/main.rs b/src/main.rs index b9ec631..b9918a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,12 @@ mod action; +mod command; mod file; mod logger; mod os_string_builder; mod server; use crate::action::{Action, FileAction, ServerActions}; +use crate::command::LogRunnable; use crate::file::{FileMatcher, FileNameInfo}; use crate::logger::{LogLevel, Logger}; use crate::os_string_builder::ReplaceWithOsStr; @@ -16,7 +18,6 @@ use std::hash::Hash; use std::io::Write; use std::iter::once; use std::path::PathBuf; -use std::process::Stdio; use std::str::FromStr; use std::{env, fs}; @@ -159,7 +160,7 @@ fn main() -> Result<(), String> { file_name, } => { require_non_empty_servers(&servers)?; - start_ssh_agent()?; + start_ssh_agent(&logger)?; let file_name_info = FileNameInfo::try_from(file.clone()).map_err(|e| format!("bad file: {e}"))?; @@ -175,9 +176,8 @@ fn main() -> Result<(), String> { let output = ShellCmd::new("ssh") .arg(&server.ssh_name) .arg(osf!("ls ") + &working_directory) - .stdout(Stdio::piped()) - .output() - .map_err(|e| format!("failed to query files via ssh: {e}"))?; + .collect_output() + .map_err(|e| format!("failed to query files: {e}"))?; let output = String::from_utf8_lossy(&output.stdout); let mut file_matcher = @@ -274,19 +274,15 @@ fn main() -> Result<(), String> { ShellCmd::new("scp") .arg(file.clone()) .arg(osf!(&server.ssh_name) + ":" + &server_actions.working_directory) - .spawn() - .map_err(|e| format!("failed to upload file: {e}"))? - .wait() - .map_err(|e| format!("failed to wait for upload: {e}"))?; + .run(&logger) + .map_err(|e| format!("upload failure: {e}"))?; } Action::Delete => { ShellCmd::new("ssh") .arg(&server.ssh_name) .arg(osf!("cd ") + &server_actions.working_directory + "; rm " + &file_action.file) - .spawn() - .map_err(|e| format!("failed to send delete command: {e}"))? - .wait() - .map_err(|e| format!("failed to wait for delete command: {e}"))?; + .run(&logger) + .map_err(|e| format!("failed to delete old version: {e}"))?; } Action::Rename { new_name } => { ShellCmd::new("ssh") @@ -299,10 +295,8 @@ fn main() -> Result<(), String> { + " " + new_name, ) - .spawn() - .map_err(|e| format!("failed to send rename command: {e}"))? - .wait() - .map_err(|e| format!("failed to wait for rename command: {e}"))?; + .run(&logger) + .map_err(|e| format!("failed to rename: {e}"))?; } } } @@ -311,17 +305,15 @@ fn main() -> Result<(), String> { log!(logger, "Done!"); } Command::Command { command } => { - start_ssh_agent()?; + start_ssh_agent(&logger)?; require_non_empty_servers(&servers)?; for server in servers { log!(logger, "Running command on '{}'...", server.ssh_name); ShellCmd::new("ssh") .arg(server.ssh_name) .arg(osf!("cd ") + server.server_directory_path + "; " + &command) - .spawn() - .map_err(|_| "failed to start ssh command".to_string())? - .wait() - .map_err(|e| format!("failed to wait for ssh command completion: {e}"))?; + .run(&logger) + .map_err(|e| format!("{e}"))?; } log!(logger, "Done!"); } @@ -357,15 +349,15 @@ fn main() -> Result<(), String> { } require_non_empty_servers(&servers)?; - start_ssh_agent()?; + start_ssh_agent(&logger)?; for server in servers { log!(logger, "Downloading file from {}...", server.ssh_name); ShellCmd::new("scp") .arg(osf!(&server.ssh_name) + ":" + server.server_directory_path.join(&file)) .arg(&working_directory) - .status() - .map_err(|e| format!("failed to download file: {e}"))?; + .run(&logger) + .map_err(|e| format!("download failure: {e}"))?; //open file in editor let mut shell_args = shell_words::split(&editor) @@ -377,15 +369,15 @@ fn main() -> Result<(), String> { let command = shell_args.remove(0); ShellCmd::new(command) .args(shell_args) - .status() + .run(&logger) .map_err(|e| format!("failed to open file in editor: {e}"))?; //upload file again ShellCmd::new("scp") .arg(working_directory.join(file_name)) .arg(osf!(&server.ssh_name) + ":" + server.server_directory_path.join(&file)) - .status() - .map_err(|e| format!("failed to upload file again: {e}"))?; + .run(&logger) + .map_err(|e| format!("failed to re-upload file: {e}"))?; } log!(logger, "Done!"); @@ -403,12 +395,11 @@ fn require_non_empty_servers(servers: &[Server]) -> Result<(), String> { } } -fn start_ssh_agent() -> Result<(), String> { +fn start_ssh_agent(logger: &Logger) -> Result<(), String> { //start the ssh agent let agent_output = ShellCmd::new("ssh-agent") .arg("-s") - .stdout(Stdio::piped()) - .output() + .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() { @@ -424,10 +415,8 @@ fn start_ssh_agent() -> Result<(), String> { //add the ssh key ShellCmd::new("ssh-add") - .spawn() - .map_err(|e| format!("failed to add ssh key: {}", e))? - .wait() - .expect("failed to wait on ssh-add"); + .run(logger) + .map_err(|e| format!("failed to add ssh-key: {e}"))?; Ok(()) } diff --git a/test-ressources/python/exit_1.py b/test-ressources/python/exit_1.py new file mode 100644 index 0000000..b11a11b --- /dev/null +++ b/test-ressources/python/exit_1.py @@ -0,0 +1 @@ +exit(1) \ No newline at end of file