Skip to content

Commit

Permalink
Add CommandInput
Browse files Browse the repository at this point in the history
  • Loading branch information
aawsome committed Sep 13, 2024
1 parent 2d52c09 commit 44f6e9c
Show file tree
Hide file tree
Showing 6 changed files with 241 additions and 4 deletions.
5 changes: 3 additions & 2 deletions crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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 }
Expand All @@ -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"

Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions crates/core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
3 changes: 3 additions & 0 deletions crates/core/src/repository.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
141 changes: 141 additions & 0 deletions crates/core/src/repository/command_input.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<String>> for CommandInput {
fn from(value: Vec<String>) -> Self {
Self(CommandInputInternal::from_vec(value))

Check warning on line 20 in crates/core/src/repository/command_input.rs

View check run for this annotation

Codecov / codecov/patch

crates/core/src/repository/command_input.rs#L19-L20

Added lines #L19 - L20 were not covered by tests
}
}

impl From<CommandInput> for Vec<String> {
fn from(value: CommandInput) -> Self {

Check warning on line 25 in crates/core/src/repository/command_input.rs

View check run for this annotation

Codecov / codecov/patch

crates/core/src/repository/command_input.rs#L25

Added line #L25 was not covered by tests
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}");

Check warning on line 66 in crates/core/src/repository/command_input.rs

View check run for this annotation

Codecov / codecov/patch

crates/core/src/repository/command_input.rs#L66

Added line #L66 was not covered by tests
}
Ok(())
}
}

impl FromStr for CommandInput {
type Err = RusticError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
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)

Check warning on line 81 in crates/core/src/repository/command_input.rs

View check run for this annotation

Codecov / codecov/patch

crates/core/src/repository/command_input.rs#L81

Added line #L81 was not covered by tests
}
}

#[serde_as]
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
#[serde(default)]
struct CommandInputInternal {
command: Option<String>,
#[serde_as(as = "PickFirst<(DisplayFromStr,_)>")]
args: ArgInternal,
}

impl CommandInputInternal {
fn iter(&self) -> impl Iterator<Item = &String> {
self.command.iter().chain(self.args.0.iter())
}

fn from_vec(mut vec: Vec<String>) -> Self {
if vec.is_empty() {
Self::default()
} else {
let command = Some(vec.remove(0));
Self {
command,
args: ArgInternal(vec),

Check warning on line 106 in crates/core/src/repository/command_input.rs

View check run for this annotation

Codecov / codecov/patch

crates/core/src/repository/command_input.rs#L106

Added line #L106 was not covered by tests
}
}
}
}

impl FromStr for CommandInputInternal {
type Err = RusticError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
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<String>);

impl FromStr for ArgInternal {
type Err = RusticError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(split(s)?))
}
}

// helper to split arguments
// TODO: Maybe use special parser (winsplit?) for windows?
fn split(s: &str) -> RusticResult<Vec<String>> {
Ok(shell_words::split(s).map_err(|err| RusticErrorKind::Command(err.into()))?)
}
90 changes: 90 additions & 0 deletions crates/core/tests/command_input.rs
Original file line number Diff line number Diff line change
@@ -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<CommandInput, _> = "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::<Test>(
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(())
}

0 comments on commit 44f6e9c

Please sign in to comment.