diff --git a/Cargo.lock b/Cargo.lock index b6db01a..ceb4cf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,18 +4,18 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -28,33 +28,33 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ "windows-sys", ] [[package]] name = "anstyle-wincon" -version = "3.0.10" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", @@ -63,27 +63,27 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.9.4" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "cfg-if" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "clap" -version = "4.5.48" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -91,9 +91,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.48" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -103,27 +103,27 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.47" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] name = "clap_lex" -version = "0.7.5" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "heck" @@ -145,15 +145,15 @@ dependencies = [ [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "lazy-regex" -version = "3.4.1" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60c7310b93682b36b98fa7ea4de998d3463ccbebd94d935d6b48ba5b6ffa7126" +checksum = "4994ba703f78b083e2f7946dac9251abd83fd43a0365f030e99b69be5b4b9ef9" dependencies = [ "lazy-regex-proc_macros", "once_cell", @@ -162,27 +162,27 @@ dependencies = [ [[package]] name = "lazy-regex-proc_macros" -version = "3.4.1" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba01db5ef81e17eb10a5e0f2109d1b3a3e29bac3070fdbd7d156bf7dbd206a1" +checksum = "fd97232314824e6dbef1918a871bb93f51070455e3715bf26e19a6d01aa977a0" dependencies = [ "proc-macro2", "quote", "regex", - "syn", + "syn 2.0.119", ] [[package]] name = "libc" -version = "0.2.176" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "multi-ssh" @@ -208,39 +208,39 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.41" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] [[package]] name = "regex" -version = "1.11.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -250,9 +250,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.11" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -261,15 +261,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.6" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "shell-words" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "strsim" @@ -279,9 +279,20 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.106" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -290,9 +301,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "utf8parse" @@ -302,9 +313,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "widestring" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" [[package]] name = "windows" @@ -354,24 +365,24 @@ dependencies = [ [[package]] name = "windows-implement" -version = "0.60.1" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "windows-interface" -version = "0.59.2" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -382,9 +393,9 @@ checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-link" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-numerics" @@ -416,28 +427,11 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.60.2" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-targets" -version = "0.53.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" -dependencies = [ - "windows-link 0.2.0", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows-link 0.2.1", ] [[package]] @@ -448,51 +442,3 @@ checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" dependencies = [ "windows-link 0.1.3", ] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" diff --git a/README.md b/README.md index 2cded86..a0a973a 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,3 @@ 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 index f2767b1..e69de29 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1,200 +0,0 @@ -use crate::log; -use crate::logger::{LogLevel, Logger}; -use std::error::Error; -use std::fmt::{Debug, 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<(), CommandSpecificError<'_, ExecutionError>>; - fn collect_output(&mut self) -> Result>; - fn collect_full_output(&mut self) -> Result>; -} - -impl LogRunnable for Command { - fn run(&mut self, logger: &Logger) -> Result<(), CommandSpecificError<'_, ExecutionError>> { - run(self, logger).map_err(|error| CommandSpecificError { - command: self, - error, - }) - } - - fn collect_output(&mut self) -> Result> { - collect_output(self, None).map_err(|error| CommandSpecificError { - command: self, - error, - }) - } - - fn collect_full_output(&mut self) -> Result> { - collect_full_output(self).map_err(|error| CommandSpecificError { - 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 = collect_full_output(command)?; - if !output.status.success() { - if let Some(logger) = logger { - log!(logger, error, "{}", String::from_utf8_lossy(&output.stdout)); - log!(logger, error, "{}", String::from_utf8_lossy(&output.stderr)); - } - Err(output.status)?; - } - Ok(output) -} - -fn collect_full_output(command: &mut Command) -> Result { - Ok(command.output()?) -} - -#[derive(Debug)] -pub struct CommandSpecificError<'a, E> { - pub command: &'a Command, - pub error: E, -} - -impl Display for CommandSpecificError<'_, E> -where - E: Display, -{ - 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().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(" ") -} - -impl Error for CommandSpecificError<'_, E> where E: Debug + Display {} - -#[derive(Debug)] -pub struct StartError(io::Error); - -impl From for StartError { - fn from(value: io::Error) -> Self { - StartError(value) - } -} - -impl Display for StartError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "Failed to start command: {}", self.0) - } -} - -impl Error for StartError {} - -#[derive(Debug)] -pub enum ExecutionError { - StartError(StartError), - BadExitStatus(ExitStatus), -} - -impl From for ExecutionError { - fn from(value: io::Error) -> Self { - Self::StartError(StartError(value)) - } -} - -impl From for ExecutionError { - fn from(value: StartError) -> 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) => Display::fmt(e, f), - ExecutionError::BadExitStatus(status) => write!(f, "Command failed with {}", status), - } - } -} - -impl Error for ExecutionError {} - -#[cfg(test)] -mod test { - use crate::command::{CommandSpecificError, ExecutionError, LogRunnable}; - 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 @ CommandSpecificError { - 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 test-ressources/python/exit_1.py': 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 @ CommandSpecificError { - 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/environment.rs b/src/environment.rs new file mode 100644 index 0000000..6328bfd --- /dev/null +++ b/src/environment.rs @@ -0,0 +1,96 @@ +use crate::shell_interface::{ + build_command_from_shell_command, CommandOutput, CommandResult, ExitStatus, ShellCommand, + ShellInterface, StartError, +}; +use std::env::VarError; +use std::ffi::{OsStr, OsString}; +use std::path::PathBuf; +use std::{env, io}; + +pub trait Environment { + fn args_os(&self) -> Vec; + fn var_os(&self, key: K) -> Option + where + K: AsRef; + fn var(&self, key: K) -> Result + where + K: AsRef, + { + self + .var_os(key) + .ok_or(VarError::NotPresent) + .and_then(|s| s.into_string().map_err(VarError::NotUnicode)) + } + fn set_var(&mut self, key: K, value: V) + where + K: AsRef, + V: AsRef; + fn get_home_directory(&self) -> Option; + fn read_line(&mut self) -> Result; +} + +#[derive(Debug, Default)] +pub struct Prod; + +impl Environment for Prod { + fn args_os(&self) -> Vec { + env::args_os().collect() + } + + fn var_os(&self, key: K) -> Option + where + K: AsRef + { + env::var_os(key) + } + + fn var(&self, key: K) -> Result + where + K: AsRef, + { + env::var(key) + } + + fn set_var(&mut 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() + } + + fn read_line(&mut self) -> Result { + let mut buffer = String::new(); + io::stdin().read_line(&mut buffer)?; + Ok(buffer.trim().to_string()) + } +} + +impl ShellInterface for Prod { + fn run_command(&mut self, command: ShellCommand) -> CommandResult { + CommandResult { + result: build_command_from_shell_command(&command) + .status() + .map(ExitStatus::from) + .map_err(StartError::from), + command, + } + } + + fn collect_command_output( + &mut self, + command: ShellCommand, + ) -> CommandResult { + CommandResult { + result: build_command_from_shell_command(&command) + .output() + .map(CommandOutput::from) + .map_err(StartError::from), + command, + } + } +} diff --git a/src/integration_test.rs b/src/integration_test.rs new file mode 100644 index 0000000..46358bf --- /dev/null +++ b/src/integration_test.rs @@ -0,0 +1,90 @@ +use crate::environment::Environment; +use crate::shell_interface::{ + CommandOutput, CommandResult, ExitStatus, ShellCommand, ShellInterface, StartError, +}; +use std::collections::{HashMap, VecDeque}; +use std::ffi::{OsStr, OsString}; +use std::io::Error; +use std::path::PathBuf; + +#[derive(Debug)] +pub struct TestEnvironment { + /// passed command line arguments + args_os: Vec, + /// set environment variables - we assume a pure environment by default + env_vars: HashMap, + /// home directory, relative to the target/test folder + home_dir: PathBuf, + /// pending lines of std input + stdin: VecDeque, + /// whether an ssh agent has been started successfully + ssh_agent_started: bool, + // TODO ssh servers and local server +} + +#[derive(Debug)] +pub struct SshServer { + pub name: String, + pub home_dir: FsEntry, +} + +#[derive(Debug)] +pub struct FsEntry { + pub name: OsString, + pub kind: FsEntryKind, +} + +#[derive(Debug)] +pub enum FsEntryKind { + Directory(Dir), + File { + contents: String, + } +} + +#[derive(Debug)] +pub struct Dir { + pub contents: Vec, +} + +impl Environment for TestEnvironment { + fn args_os(&self) -> Vec { + self.args_os.clone() + } + + fn var_os(&self, key: K) -> Option + where + K: AsRef + { + self.env_vars.get(key.as_ref()).map(|s| s.into()) + } + + fn set_var(&mut self, key: K, value: V) + where + K: AsRef, + V: AsRef, + { + self.env_vars.insert(key.as_ref().into(), value.as_ref().into()); + } + + fn get_home_directory(&self) -> Option { + PathBuf::from("target/integration_test").join(&self.home_dir).into() + } + + fn read_line(&mut self) -> Result { + self.stdin.pop_front().ok_or_else(|| Error::other("Unexpected call to read_line: No input prepared")) + } +} + +impl ShellInterface for TestEnvironment { + fn run_command(&mut self, command: ShellCommand) -> CommandResult { + todo!() + } + + fn collect_command_output( + &mut self, + command: ShellCommand, + ) -> CommandResult { + todo!() + } +} diff --git a/src/main.rs b/src/main.rs index 3919df3..834541b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,22 @@ mod action; -mod command; +mod environment; mod file; +#[cfg(test)] +mod integration_test; 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::environment::{Environment, Prod}; use crate::file::{FileMatcher, FileNameInfo}; use crate::logger::{LogLevel, Logger}; use crate::os_str_extension::OsStrExtension; use crate::os_string_builder::ReplaceWithOsStr; use crate::server::{RelativeLocalPathAnker, ServerAddress}; +use crate::shell_interface::{ScpParam, ServerCommand, ShellCommand, ShellInterface}; use clap::{Parser, Subcommand, ValueEnum}; use lazy_regex::{lazy_regex, Lazy, Regex}; use server::{Server, ServerReference}; @@ -22,15 +26,12 @@ use std::hash::Hash; use std::io::Write; use std::iter::once; use std::path::{Path, PathBuf}; -use std::str::FromStr; use std::{env, fs, io}; const SERVERS_ENV_VAR: &str = "MSSH_SERVERS"; const EDITOR_ENV_VAR: &str = "MSSH_EDITOR"; const FILE_PLACEHOLDER: &str = ""; -type ShellCmd = std::process::Command; - /// Uploads a file or executes a command on multiple configured servers /// /// Servers must either be configured via environment variable or denote their server directory with @@ -41,13 +42,13 @@ 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 - #[arg(num_args = 0.., value_parser = ServerReference::from_str)] - servers: Vec, + #[arg(num_args = 0..)] + servers: Vec, /// How verbose logging output should be #[arg(long, default_value = "info", conflicts_with_all = ["quiet", "info"])] log_level: LogLevel, @@ -71,7 +72,7 @@ enum Command { /// 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)] - file_server: Option, + file_server: Option, /// How to handle older versions of the file #[arg(short = 'a', long, default_value = "delete", default_missing_value = "archive", num_args = 0..=1)] old_version_policy: OldVersionPolicy, @@ -137,613 +138,663 @@ enum OldVersionPolicy { Delete, } -#[macro_export] -macro_rules! input { - ($prompt: tt) => {{ - print!($prompt); - io::stdout().flush().expect("failed to flush stdout"); - let mut buf = String::new(); - io::stdin() - .read_line(&mut buf) - .expect("failed to read stdin"); - buf.trim().to_string() - }}; - () => { - input!() - }; +#[derive(Debug, Default)] +pub struct Application { + pub environment: E, } -fn main() -> Result<(), String> { - let args = Args::parse(); +impl Application +where + E: Environment + ShellInterface, +{ + pub fn run(&mut 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 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(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::, _>>()?; - - 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, + pub fn run_with_args(&mut self, args: Args) -> Result<(), String> { + macro_rules! env { + () => { + &mut self.environment }; + } - //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() + 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 + .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::, _>>()? + .into_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::, _>>()?; + + 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 { + 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}"))?; + 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, + }; + + self.start_ssh_agent(&logger)?; + + //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 = 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}"))?; - 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); + if !output.status.success { + Err(format!( + "Path doesn't match any files on file-server: {}", + file.to_string_lossy() + ))?; } - let file_name = file_name_info.to_full_file_name(); + let denoted_files = output + .stdout + .split(b'\n') //split at line breaks + .into_iter() + .filter(|file_name| !file_name.is_empty()) //needed since realpath sometimes gives us empty lines + .map(PathBuf::from) + .collect::>(); - 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 - } - } + Ok(denoted_files) }) - .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}"))?; + .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 } => 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 + .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 && !self.confirm("Continue?", true) { + 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 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()), + }; + 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}"))?; + } + Action::Delete => match &server.address { + ServerAddress::Ssh { ssh_address } => { + 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}"))?; + } + 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 } => { + 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}"))?; + } + 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 } => { + 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}"))?; + } + ServerAddress::Localhost => { + 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)?; + } + } + } + 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 + && self.confirm( + format!("{duplication_notification}. Do you want to replace it?"), + false, + ) + { + 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 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}"))?; + + //open file in editor + let editor_command = 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::>(); + + ShellCommand::Editor(editor_command) + .in_env(env!()) + .run_logged(&logger) + .and_expect_success() + .into_result_with_error_logging(&logger) + .map_err(|e| format!("failed to open file in editor: {e}"))?; + + //upload file again + 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}"))?; + } + + log!(logger, "Done!"); + } + } + + 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(&mut self, logger: &Logger) -> Result<(), String> { + let env = &mut self.environment; + + //start the ssh agent + 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?")?; + + //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 + ShellCommand::ShhAdd + .in_env(env) + .run_logged(logger) + .and_expect_success() + .into_result_with_error_logging(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 confirm(&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"), + } } } - - Ok(()) } -fn osstring_from_ssh_output(output: Vec) -> OsString { - #[cfg(unix)] - { - use std::os::unix::ffi::OsStringExt; - OsString::from_vec(output) - } - - #[cfg(windows)] - { - use std::os::windows::ffi::OsStringExt; - OsString::from_wide(output.iter().map(|&b| b as u16).collect()) - } +fn main() -> Result<(), String> { + Application::::default().run() } -fn check_local_file_exists

(path: P) -> Result<(), String> +fn parse_server_configuration( + config_str: &str, + get_home_directory: F, +) -> Result, String> where - P: AsRef, + F: Fn() -> Result, { - 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> { config_str .split(',') .map(|server_entry| { - Server::from_str(server_entry, RelativeLocalPathAnker::Home) - .map_err(|e| format!("Invalid server entry '{server_entry}': {e}")) + Server::from_str( + server_entry, + RelativeLocalPathAnker::Home, + &get_home_directory, + ) + .map_err(|e| format!("Invalid server entry '{server_entry}': {e}")) }) .collect() } @@ -757,52 +808,15 @@ mod test { #[test] fn test_parse_server_configuration() { - use std::env; - use std::time::{SystemTime, UNIX_EPOCH}; + //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"); - // Create an isolated temporary HOME directory to avoid touching the real home. - // Ensure the environment is restored even if the test fails by using a guard. - struct EnvVarGuard { - key: &'static str, - prev: Option, - } - impl EnvVarGuard { - fn set_path(key: &'static str, value: &std::path::Path) -> Self { - let prev = env::var_os(key); - env::set_var(key, value); - Self { key, prev } - } - } - impl Drop for EnvVarGuard { - fn drop(&mut self) { - match &self.prev { - Some(v) => env::set_var(self.key, v), - None => env::remove_var(self.key), - } - } - } - - // Build a unique temp directory under the OS temp dir - let unique = format!( - "multi_ssh_test_home_{}_{}", - std::process::id(), - SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() - ); - let temp_home = env::temp_dir().join(unique); - fs::create_dir_all(&temp_home).expect("create temp HOME"); - - // Override HOME (and USERPROFILE for Windows) for the duration of the test - let _home_guard = EnvVarGuard::set_path("HOME", &temp_home); - let _userprofile_guard = EnvVarGuard::set_path("USERPROFILE", &temp_home); - - // Prepare the local dir inside the temp HOME - let local_dir = temp_home.join("fizz/buzz"); - fs::create_dir_all(&local_dir).expect("mkdir -p temp local dir"); - let canonical_local_dir = - fs::canonicalize(&local_dir).expect("canonicalize temp local dir"); - - let servers = - parse_server_configuration("foo:bar,.:fizz/buzz").expect("valid server configuration"); + let servers = parse_server_configuration(&format!("foo:bar,.:{LOCAL_SERVER_DIR}"), || { + Ok(home_dir.clone()) + }) + .expect("valid server configuration"); assert_eq!( vec![ Server { @@ -813,7 +827,10 @@ mod test { }, Server { address: ServerAddress::Localhost, - server_directory_path: canonical_local_dir, + server_directory_path: home_dir + .join(LOCAL_SERVER_DIR) + .canonicalize() + .expect("home dir exists"), } ], servers diff --git a/src/server.rs b/src/server.rs index 2d1ebdc..7c62268 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,4 +1,3 @@ -use crate::get_home_directory; use std::cell::LazyCell; use std::error::Error; use std::fmt::{Display, Formatter}; @@ -6,7 +5,6 @@ use std::fs; use std::hash::{Hash, Hasher}; use std::ops::Deref; use std::path::PathBuf; -use std::str::FromStr; #[derive(Debug, Clone)] pub enum ServerReference { @@ -15,6 +13,19 @@ pub enum ServerReference { } impl ServerReference { + pub fn from_str(s: &str, get_home_directory: F) -> Result + where + F: FnOnce() -> Result, + { + Server::from_str( + s, + RelativeLocalPathAnker::CurrentDirectory, + get_home_directory, + ) + .map(Self::Resolved) + .or_else(|_| Ok(Self::Identifier(s.to_string()))) + } + pub fn get_identifier(&self) -> &str { match self { ServerReference::Resolved(server) => server.address.identifier(), @@ -68,16 +79,6 @@ impl ServerReference { } } -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()))) - } -} - impl PartialEq for ServerReference { fn eq(&self, other: &Self) -> bool { self.get_identifier() == other.get_identifier() @@ -126,10 +127,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: FnOnce() -> Result, + { s.split_once(':') .ok_or(ServerParseError::MissingServerDirectory) .and_then(|(identifier, server_directory)| { @@ -245,13 +250,12 @@ impl Error for ServerParseError {} mod test_server_reference { use crate::server::{Server, ServerAddress, ServerReference}; use std::path::PathBuf; - use std::str::FromStr; #[test] fn test_from_str() { assert_eq!( ServerReference::Identifier("foo".to_string()), - ServerReference::from_str("foo").unwrap() + ServerReference::from_str("foo", || panic!("shouldn't be called")).unwrap() ); assert_eq!( ServerReference::Resolved(Server { @@ -260,7 +264,7 @@ mod test_server_reference { }, server_directory_path: PathBuf::from("server/creative2") }), - ServerReference::from_str("crea:server/creative2").unwrap() + ServerReference::from_str("crea:server/creative2", || panic!("shouldn't be called")).unwrap() ); } } diff --git a/src/shell_interface.rs b/src/shell_interface.rs new file mode 100644 index 0000000..1bf318d --- /dev/null +++ b/src/shell_interface.rs @@ -0,0 +1,562 @@ +use crate::logger::{LogLevel, Logger}; +use crate::server::{Server, ServerAddress}; +use crate::{log, osf}; +use std::error::Error; +use std::ffi::OsString; +use std::fmt::{Debug, Display, Formatter}; +use std::iter::once; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::{io, process}; + +#[derive(Debug)] +pub struct EnvCommand<'a, E> { + command: ShellCommand, + environment: &'a mut E, +} + +impl EnvCommand<'_, E> +where + E: ShellInterface, +{ + pub fn run(self) -> CommandResult { + self.environment.run_command(self.command) + } + + pub fn output(self) -> CommandResult { + self.environment.collect_command_output(self.command) + } + + pub 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, + } + } + } + } +} + +#[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: Vec, + }, +} + +impl ShellCommand { + pub fn in_env(self, environment: &mut 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

From<(&Server, P)> for ScpParam +where + P: AsRef, +{ + fn from((server, path): (&Server, P)) -> Self { + Self { + server: match &server.address { + ServerAddress::Ssh { ssh_address } => Some(ssh_address.into()), + ServerAddress::Localhost => None, + }, + path: PathBuf::from(path.as_ref()), + } + } +} + +impl From<&Path> for ScpParam { + fn from(value: &Path) -> Self { + Self { + server: None, + path: PathBuf::from(value), + } + } +} + +impl From<&ScpParam> for OsString { + fn from(value: &ScpParam) -> Self { + let mut builder = osf!(); + if let Some(server) = &value.server { + builder += format!("{server}:"); + } + builder += &value.path; + builder.build() + } +} + +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(" ") +} + +macro_rules! cmd { + ($programm: expr $(, $arg:expr )*)=> {{ + #[allow(unused_mut)] + let mut cmd = Command::new($programm); + $( cmd.arg($arg); )* + cmd + }}; +} + +pub fn build_command_from_shell_command(shell_command: &ShellCommand) -> Command { + match shell_command { + ShellCommand::Ssh { + address, + server_command, + } => cmd!( + "ssh", + address, + match server_command { + ServerCommand::Realpath { path } => osf!("realpath -e ") + path, + ServerCommand::Ls { dir } => osf!("ls ") + dir, + ServerCommand::Rm { file } => osf!("rm ") + file, + ServerCommand::Mv { + source, + destination, + } => osf!("mv ") + source + " " + destination, + ServerCommand::Execute { + working_directory, + command, + } => osf!("cd ") + working_directory + "; " + command, + } + ), + ShellCommand::Scp { + source, + destination, + } => cmd!("scp", OsString::from(source), OsString::from(destination)), + ShellCommand::SshAgent => cmd!("ssh-agent", "-s"), + ShellCommand::ShhAdd => cmd!("ssh-add"), + ShellCommand::Editor(args) => { + let mut args = args.clone(); + let mut cmd = cmd!(args.remove(0)); + cmd.args(args); + cmd + } + ShellCommand::Execute { + working_directory, + command, + } => { + let mut args = command.clone(); + let mut cmd = cmd!(args.remove(0)); + cmd.args(args).current_dir(working_directory); + cmd + } + } +} + +pub trait ShellInterface { + fn run_command(&mut self, command: ShellCommand) -> CommandResult; + fn collect_command_output( + &mut self, + command: ShellCommand, + ) -> CommandResult; +} + +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>> + where + T: MaybeCast, + { + self.result.map_err(|error| { + if let ExecutionError::BadExitStatus(t) = &error { + if let Some(output) = t.maybe_cast() { + 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 From for CommandOutput { + fn from(value: Output) -> Self { + Self { + stdout: os_string_from_ssh_output(value.stdout), + stderr: os_string_from_ssh_output(value.stderr), + status: value.status.into(), + } + } +} + +pub fn os_string_from_ssh_output(output: Vec) -> OsString { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + OsString::from_vec(output) + } + + #[cfg(windows)] + { + use std::os::windows::ffi::OsStringExt; + OsString::from_wide(output.iter().map(|&b| b as u16).collect()) + } +} + +impl AsRef for CommandOutput { + fn as_ref(&self) -> &ExitStatus { + &self.status + } +} + +#[derive(Debug, Clone)] +pub struct ExitStatus { + pub success: bool, + pub string_form: String, + #[allow(dead_code)] + pub code: Option, +} + +impl From for ExitStatus { + fn from(value: process::ExitStatus) -> Self { + Self { + success: value.success(), + string_form: value.to_string(), + code: value.code(), + } + } +} + +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 From> for String +where + E: Display, +{ + fn from(value: CommandError) -> Self { + value.to_string() + } +} + +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 From for StartError { + fn from(value: io::Error) -> Self { + StartError(value) + } +} + +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 {} + +#[cfg(test)] +mod test_commands { + use crate::shell_interface::{ScpParam, ServerCommand, ShellCommand}; + use std::path::PathBuf; + + #[test] + fn test_to_string() { + assert_eq!( + ShellCommand::Ssh { + address: "crea".to_string(), + server_command: ServerCommand::Realpath { + path: PathBuf::from("plugins/*.jar") + } + } + .to_string(), + r#"ssh crea "realpath -e plugins/*.jar""# + ); + assert_eq!( + ShellCommand::Ssh { + address: "crea".to_string(), + server_command: ServerCommand::Ls { + dir: PathBuf::from("creative/plugins") + } + } + .to_string(), + r#"ssh crea "ls creative/plugins""# + ); + assert_eq!( + ShellCommand::Ssh { + address: "crea".to_string(), + server_command: ServerCommand::Rm { + file: PathBuf::from("foo.txt") + }, + } + .to_string(), + r#"ssh crea "rm foo.txt""# + ); + assert_eq!( + ShellCommand::Ssh { + address: "crea".to_string(), + server_command: ServerCommand::Mv { + source: PathBuf::from("foo"), + destination: PathBuf::from("bar") + } + } + .to_string(), + r#"ssh crea "mv foo bar""# + ); + assert_eq!( + ShellCommand::Ssh { + address: "crea".to_string(), + server_command: ServerCommand::Execute { + working_directory: PathBuf::from(".."), + command: "sudo rm -rf *".into(), + } + } + .to_string(), + r#"ssh crea "cd ..; sudo rm -rf *""# + ); + assert_eq!( + ShellCommand::Scp { + source: ScpParam { + server: None, + path: PathBuf::from("target/mssh") + }, + destination: ScpParam { + server: Some("crea".into()), + path: PathBuf::from("/usr/bin") + }, + } + .to_string(), + r#"scp target/mssh crea:/usr/bin"# + ); + assert_eq!(ShellCommand::SshAgent.to_string(), r#"ssh-agent -s"#); + assert_eq!(ShellCommand::ShhAdd.to_string(), r#"ssh-add"#); + assert_eq!( + ShellCommand::Editor(vec!["kate".into(), "-b".into(), "test.txt".into()]).to_string(), + r#"kate -b test.txt"# + ); + assert_eq!( + ShellCommand::Execute { + working_directory: PathBuf::from("/home/me/server"), + command: vec!["java".into(), "-jar".into(), "paper.jar".into()] + } + .to_string(), + r#"java -jar paper.jar"# + ); + } +} diff --git a/test-ressources/python/exit_1.py b/test-ressources/python/exit_1.py deleted file mode 100644 index b11a11b..0000000 --- a/test-ressources/python/exit_1.py +++ /dev/null @@ -1 +0,0 @@ -exit(1) \ No newline at end of file