From 44f6e9c320218380862542ea4cc539a46c896f76 Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Fri, 13 Sep 2024 21:57:31 +0200 Subject: [PATCH] Add CommandInput --- crates/core/Cargo.toml | 5 +- crates/core/src/error.rs | 2 + crates/core/src/lib.rs | 4 +- crates/core/src/repository.rs | 3 + crates/core/src/repository/command_input.rs | 141 ++++++++++++++++++++ crates/core/tests/command_input.rs | 90 +++++++++++++ 6 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 crates/core/src/repository/command_input.rs create mode 100644 crates/core/tests/command_input.rs diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 008c5e5f..d95d972e 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" default = [] cli = ["merge", "clap"] merge = ["dep:merge"] -clap = ["dep:clap", "dep:shell-words"] +clap = ["dep:clap"] webdav = ["dep:dav-server", "dep:futures"] [package.metadata.docs.rs] @@ -88,7 +88,6 @@ dirs = "5.0.1" # cli support clap = { version = "4.5.16", optional = true, features = ["derive", "env", "wrap_help"] } merge = { version = "0.1.0", optional = true } -shell-words = { version = "1.1.0", optional = true } # vfs support dav-server = { version = "0.7.0", default-features = false, optional = true } @@ -107,6 +106,7 @@ gethostname = "0.5.0" humantime = "2.1.0" itertools = "0.13.0" quick_cache = "0.6.2" +shell-words = "1.1.0" strum = { version = "0.26.3", features = ["derive"] } zstd = "0.13.2" @@ -140,6 +140,7 @@ rustup-toolchain = "0.1.7" simplelog = "0.12.2" tar = "0.4.41" tempfile = { workspace = true } +toml = "0.8.19" [lints] workspace = true diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 017465d8..76e732c3 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -220,6 +220,8 @@ pub enum CommandErrorKind { NotAllowedWithAppendOnly(String), /// Specify one of the keep-* options for forget! Please use keep-none to keep no snapshot. NoKeepOption, + /// {0:?} + FromParseError(#[from] shell_words::ParseError), } /// [`CryptoErrorKind`] describes the errors that can happen while dealing with Cryptographic functions diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index ca0f1501..cdb92821 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -149,7 +149,7 @@ pub use crate::{ PathList, SnapshotGroup, SnapshotGroupCriterion, SnapshotOptions, StringList, }, repository::{ - FullIndex, IndexedFull, IndexedIds, IndexedStatus, IndexedTree, OpenStatus, Repository, - RepositoryOptions, + CommandInput, FullIndex, IndexedFull, IndexedIds, IndexedStatus, IndexedTree, OpenStatus, + Repository, RepositoryOptions, }, }; diff --git a/crates/core/src/repository.rs b/crates/core/src/repository.rs index 7cf89079..289c7210 100644 --- a/crates/core/src/repository.rs +++ b/crates/core/src/repository.rs @@ -1,8 +1,11 @@ // Note: we need a fully qualified Vec here for clap, see https://github.com/clap-rs/clap/issues/4481 #![allow(unused_qualifications)] +mod command_input; mod warm_up; +pub use command_input::CommandInput; + use std::{ cmp::Ordering, fs::File, diff --git a/crates/core/src/repository/command_input.rs b/crates/core/src/repository/command_input.rs new file mode 100644 index 00000000..be37de5b --- /dev/null +++ b/crates/core/src/repository/command_input.rs @@ -0,0 +1,141 @@ +use std::{fmt::Display, process::Command, str::FromStr}; + +use log::{debug, trace, warn}; +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, DisplayFromStr, PickFirst}; + +use crate::{error::RusticErrorKind, RusticError, RusticResult}; + +/// A command to be called which can be given as CLI option as well as in config files +/// `CommandInput` implements Serialize/Deserialize as well as FromStr. +#[serde_as] +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandInput( + // Note: we use CommandInputInternal here which itself impls FromStr in order to use serde_as PickFirst for CommandInput. + #[serde_as(as = "PickFirst<(DisplayFromStr,_)>")] CommandInputInternal, +); + +impl From> for CommandInput { + fn from(value: Vec) -> Self { + Self(CommandInputInternal::from_vec(value)) + } +} + +impl From for Vec { + fn from(value: CommandInput) -> Self { + value.0.iter().cloned().collect() + } +} + +impl CommandInput { + /// Returns if a command is set + #[must_use] + pub fn is_set(&self) -> bool { + self.0.command.is_some() + } + + /// Returns the command if it is set + /// + /// # Panics + /// + /// Panics if no command is set. + #[must_use] + pub fn command(&self) -> &str { + self.0.command.as_ref().unwrap() + } + + /// Returns the command args if it is set + #[must_use] + pub fn args(&self) -> &[String] { + &self.0.args.0 + } + + /// Runs the command if it is set + /// + /// # Errors + /// + /// `std::io::Error` if return status cannot be read + pub fn run(&self, context: &str, what: &str) -> Result<(), std::io::Error> { + if !self.is_set() { + trace!("not calling command {context}:{what} - not set"); + return Ok(()); + } + debug!("calling command {context}:{what}: {self:?}"); + let status = Command::new(self.command()).args(self.args()).status()?; + if !status.success() { + warn!("running command {context}:{what} was not successful. {status}"); + } + Ok(()) + } +} + +impl FromStr for CommandInput { + type Err = RusticError; + fn from_str(s: &str) -> Result { + Ok(Self(CommandInputInternal::from_str(s)?)) + } +} + +impl Display for CommandInput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self.0, f) + } +} + +#[serde_as] +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)] +#[serde(default)] +struct CommandInputInternal { + command: Option, + #[serde_as(as = "PickFirst<(DisplayFromStr,_)>")] + args: ArgInternal, +} + +impl CommandInputInternal { + fn iter(&self) -> impl Iterator { + self.command.iter().chain(self.args.0.iter()) + } + + fn from_vec(mut vec: Vec) -> Self { + if vec.is_empty() { + Self::default() + } else { + let command = Some(vec.remove(0)); + Self { + command, + args: ArgInternal(vec), + } + } + } +} + +impl FromStr for CommandInputInternal { + type Err = RusticError; + fn from_str(s: &str) -> Result { + Ok(Self::from_vec(split(s)?)) + } +} + +impl Display for CommandInputInternal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = shell_words::join(self.iter()); + f.write_str(&s) + } +} + +#[serde_as] +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)] +struct ArgInternal(Vec); + +impl FromStr for ArgInternal { + type Err = RusticError; + fn from_str(s: &str) -> Result { + Ok(Self(split(s)?)) + } +} + +// helper to split arguments +// TODO: Maybe use special parser (winsplit?) for windows? +fn split(s: &str) -> RusticResult> { + Ok(shell_words::split(s).map_err(|err| RusticErrorKind::Command(err.into()))?) +} diff --git a/crates/core/tests/command_input.rs b/crates/core/tests/command_input.rs new file mode 100644 index 00000000..c14caefc --- /dev/null +++ b/crates/core/tests/command_input.rs @@ -0,0 +1,90 @@ +use std::fs::File; + +use anyhow::Result; +use rustic_core::CommandInput; +use serde::{Deserialize, Serialize}; +use tempfile::tempdir; + +#[test] +fn from_str() -> Result<()> { + let cmd: CommandInput = "echo test".parse()?; + assert_eq!(cmd.command(), "echo"); + assert_eq!(cmd.args(), ["test"]); + + let cmd: CommandInput = r#"echo "test test" test"#.parse()?; + assert_eq!(cmd.command(), "echo"); + assert_eq!(cmd.args(), ["test test", "test"]); + Ok(()) +} + +#[cfg(not(windows))] +#[test] +fn from_str_failed() { + let failed_cmd: std::result::Result = "echo \"test test".parse(); + assert!(failed_cmd.is_err()); +} + +#[test] +fn toml() -> Result<()> { + #[derive(Deserialize, Serialize)] + struct Test { + command1: CommandInput, + command2: CommandInput, + command3: CommandInput, + command4: CommandInput, + } + + let test = toml::from_str::( + r#" + command1 = "echo test" + command2 = {command = "echo", args = "test test"} + command3 = {command = "echo", args = "'test test'"} + command4 = {command = "echo", args = ["test test", "test"]} + "#, + )?; + + assert_eq!(test.command1.command(), "echo"); + assert_eq!(test.command1.args(), ["test"]); + assert_eq!(test.command3.command(), "echo"); + assert_eq!(test.command3.args(), ["test test"]); + assert_eq!(test.command4.command(), "echo"); + assert_eq!(test.command4.args(), ["test test", "test"]); + + let test_ser = toml::to_string(&test)?; + assert_eq!( + test_ser, + r#"command1 = "echo test" +command2 = "echo test test" +command3 = "echo 'test test'" +command4 = "echo 'test test' test" +"# + ); + Ok(()) +} + +#[test] +fn run_empty() -> Result<()> { + // empty command + let command: CommandInput = "".parse()?; + dbg!(&command); + assert!(!command.is_set()); + command.run("test", "empty")?; + Ok(()) +} + +#[cfg(not(windows))] +#[test] +fn run_deletey() -> Result<()> { + // create a tmp file which will be removed by + let dir = tempdir()?; + let filename = dir.path().join("file"); + let _ = File::create(&filename)?; + assert!(filename.exists()); + + let command: CommandInput = format!("rm {}", filename.to_str().unwrap()).parse()?; + assert!(command.is_set()); + command.run("test", "test-call")?; + assert!(!filename.exists()); + + Ok(()) +}