diff --git a/Cargo.lock b/Cargo.lock index 53a7fbc..bc01057 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,6 +215,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "difflib" version = "0.4.0" @@ -285,6 +291,7 @@ dependencies = [ "once_cell", "onig", "predicates", + "pretty_assertions", "regex", "serial_test", "tempfile", @@ -591,6 +598,16 @@ dependencies = [ "treeline", ] +[[package]] +name = "pretty_assertions" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro2" version = "1.0.60" @@ -1058,3 +1075,9 @@ name = "windows_x86_64_msvc" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" diff --git a/Cargo.toml b/Cargo.toml index cf1a045..8596547 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ nix = { version = "0.28", features = ["fs"] } predicates = "3" serial_test = "3.0" tempfile = "3" +pretty_assertions = "1.4.0" [[bin]] name = "find" diff --git a/src/xargs/mod.rs b/src/xargs/mod.rs index a923570..74144b7 100644 --- a/src/xargs/mod.rs +++ b/src/xargs/mod.rs @@ -28,6 +28,8 @@ mod options { pub const MAX_PROCS: &str = "max-procs"; pub const NO_RUN_IF_EMPTY: &str = "no-run-if-empty"; pub const NULL: &str = "null"; + pub const REPLACE: &str = "replace"; + pub const REPLACE_I: &str = "replace-I"; pub const VERBOSE: &str = "verbose"; } @@ -40,6 +42,7 @@ struct Options { max_lines: Option, no_run_if_empty: bool, null: bool, + replace: Option, verbose: bool, } @@ -340,13 +343,14 @@ struct CommandBuilderOptions { limiters: LimiterCollection, verbose: bool, close_stdin: bool, + replace: Option, } - impl CommandBuilderOptions { fn new( action: ExecAction, env: HashMap, mut limiters: LimiterCollection, + replace: Option, ) -> Result { let initial_args = match &action { ExecAction::Command(args) => args.iter().map(|arg| arg.as_ref()).collect(), @@ -366,6 +370,7 @@ impl CommandBuilderOptions { limiters, verbose: false, close_stdin: false, + replace, }) } } @@ -398,14 +403,41 @@ impl CommandBuilder<'_> { }; let mut command = Command::new(entry_point); - command - .args(initial_args) - .args(&self.extra_args) - .env_clear() - .envs(&self.options.env); + + if let Some(replace_str) = &self.options.replace { + // we replace the first instance of the replacement string with + // the extra args, and then replace all instances of the replacement + let replacement = self + .extra_args + .iter() + .map(|s| s.to_string_lossy()) + .collect::>() + .join(" "); + let initial_args: Vec = initial_args + .iter() + .map(|arg| { + let arg_str = arg.to_string_lossy(); + OsString::from(arg_str.replace(replace_str, &replacement)) + }) + .collect(); + + command + .args(&initial_args) + .env_clear() + .envs(&self.options.env); + } else { + // don't do any replacement + command + .args(initial_args) + .args(&self.extra_args) + .env_clear() + .envs(&self.options.env); + }; + if self.options.close_stdin { command.stdin(Stdio::null()); } + if self.options.verbose { eprintln!("{command:?}"); } @@ -692,12 +724,14 @@ fn process_input( } fn parse_delimiter(s: &str) -> Result { - if let Some(hex) = s.strip_prefix("\\x") { - u8::from_str_radix(hex, 16).map_err(|e| format!("Invalid hex sequence: {}", e)) - } else if let Some(oct) = s.strip_prefix("\\0") { - u8::from_str_radix(oct, 8).map_err(|e| format!("Invalid octal sequence: {}", e)) - } else if let Some(special) = s.strip_prefix('\\') { - match special { + match s.strip_prefix('\\') { + Some(hex) if hex.starts_with('x') => { + u8::from_str_radix(&hex[1..], 16).map_err(|e| format!("Invalid hex sequence: {}", e)) + } + Some(oct) if oct.starts_with('0') => { + u8::from_str_radix(&oct[1..], 8).map_err(|e| format!("Invalid octal sequence: {}", e)) + } + Some(special) => match special { "a" => Ok(b'\x07'), "b" => Ok(b'\x08'), "f" => Ok(b'\x0C'), @@ -705,17 +739,12 @@ fn parse_delimiter(s: &str) -> Result { "r" => Ok(b'\r'), "t" => Ok(b'\t'), "v" => Ok(b'\x0B'), - "0" => Ok(b'\0'), "\\" => Ok(b'\\'), - _ => Err(format!("Invalid escape sequence: {s}")), - } - } else { - let bytes = s.as_bytes(); - if bytes.len() == 1 { - Ok(bytes[0]) - } else { - Err("Delimiter must be one byte".to_owned()) - } + "0" => Ok(b'\0'), + _ => Err(format!("Invalid escape sequence: \\{}", special)), + }, + None if s.len() == 1 => Ok(s.as_bytes()[0]), + None => Err("Delimiter must be one byte".to_owned()), } } @@ -819,6 +848,28 @@ fn do_xargs(args: &[&str]) -> Result { .help("Be verbose") .action(ArgAction::SetTrue), ) + .arg( + Arg::new(options::REPLACE) + .long(options::REPLACE) + .short('i') + .num_args(0..=1) + .require_equals(true) + .value_parser(clap::value_parser!(String)) + .value_name("R") + .help( + "Replace R in INITIAL-ARGS with names read from standard input; \ + if R is unspecified, assume {}", + ), + ) + .arg( + Arg::new(options::REPLACE_I) + .short('I') + .num_args(1) + .help("same as --replace=R") + .value_name("R") + .overrides_with(options::REPLACE) + .value_parser(clap::value_parser!(String)), + ) .try_get_matches_from(args); let matches = match matches { @@ -837,6 +888,16 @@ fn do_xargs(args: &[&str]) -> Result { max_lines: matches.get_one::(options::MAX_LINES).copied(), no_run_if_empty: matches.get_flag(options::NO_RUN_IF_EMPTY), null: matches.get_flag(options::NULL), + replace: [options::REPLACE_I, options::REPLACE] + .iter() + .find_map(|&option| { + matches.contains_id(option).then(|| { + matches + .get_one::(option) + .map(|value| value.to_owned()) + .unwrap_or_else(|| "{}".to_string()) + }) + }), verbose: matches.get_flag(options::VERBOSE), }; @@ -861,7 +922,6 @@ fn do_xargs(args: &[&str]) -> Result { } _ => ExecAction::Echo, }; - let env = std::env::vars_os().collect(); let mut limiters = LimiterCollection::new(); @@ -891,9 +951,10 @@ fn do_xargs(args: &[&str]) -> Result { limiters.add(MaxCharsCommandSizeLimiter::new_system(&env)); - let mut builder_options = CommandBuilderOptions::new(action, env, limiters).map_err(|_| { - "Base command and environment are too large to fit into one command execution" - })?; + let mut builder_options = + CommandBuilderOptions::new(action, env, limiters, options.replace.clone()).map_err( + |_| "Base command and environment are too large to fit into one command execution", + )?; builder_options.verbose = options.verbose; builder_options.close_stdin = options.arg_file.is_none(); diff --git a/tests/xargs_tests.rs b/tests/xargs_tests.rs index a4eb549..fb6aa9a 100644 --- a/tests/xargs_tests.rs +++ b/tests/xargs_tests.rs @@ -12,6 +12,7 @@ use assert_cmd::Command; use predicates::prelude::*; use common::test_helpers::path_to_testing_commandline; +use pretty_assertions::assert_eq; mod common; @@ -208,7 +209,7 @@ fn xargs_exit_on_large() { #[test] fn xargs_exec() { - Command::cargo_bin("xargs") + let result = Command::cargo_bin("xargs") .expect("found binary") .args([ "-n2", @@ -218,13 +219,20 @@ fn xargs_exec() { "--no_print_cwd", ]) .write_stdin("a b c\nd") - .assert() - .success() - .stderr(predicate::str::is_empty()) - .stdout(predicate::str::diff( - "stdin=\nargs=\n--print_stdin\n--no_print_cwd\na\nb\n\ + .output(); + assert!(result.is_ok(), "xargs failed: {:?}", result); + let result = result.unwrap(); + assert_eq!(result.status.code(), Some(0)); + + assert!(result.stderr.is_empty(), "stderr: {:?}", result); + + let stdout_string = String::from_utf8(result.stdout).expect("Found invalid UTF-8"); + + assert_eq!( + stdout_string, + "stdin=\nargs=\n--print_stdin\n--no_print_cwd\na\nb\n\ stdin=\nargs=\n--print_stdin\n--no_print_cwd\nc\nd\n", - )); + ); } #[test] @@ -234,7 +242,7 @@ fn xargs_exec_stdin_open() { write!(temp_file, "a b c").unwrap(); temp_file.seek(SeekFrom::Start(0)).unwrap(); - Command::cargo_bin("xargs") + let result = Command::cargo_bin("xargs") .expect("found binary") .args([ "-a", @@ -245,17 +253,25 @@ fn xargs_exec_stdin_open() { "--no_print_cwd", ]) .write_stdin("test") - .assert() - .success() - .stderr(predicate::str::is_empty()) - .stdout(predicate::str::diff( - "stdin=test\nargs=\n--print_stdin\n--no_print_cwd\na\nb\nc\n", - )); + .output(); + + assert!(result.is_ok(), "xargs failed: {:?}", result); + let result = result.unwrap(); + assert_eq!(result.status.code(), Some(0)); + + assert!(result.stderr.is_empty(), "stderr: {:?}", result); + + let stdout_string = String::from_utf8(result.stdout).expect("Found invalid UTF-8"); + + assert_eq!( + stdout_string, + "stdin=test\nargs=\n--print_stdin\n--no_print_cwd\na\nb\nc\n", + ); } #[test] fn xargs_exec_failure() { - Command::cargo_bin("xargs") + let result = Command::cargo_bin("xargs") .expect("found binary") .args([ "-n1", @@ -265,19 +281,26 @@ fn xargs_exec_failure() { "--exit_with_failure", ]) .write_stdin("a b") - .assert() - .failure() - .code(123) - .stderr(predicate::str::is_empty()) - .stdout( - "args=\n--no_print_cwd\n--exit_with_failure\na\n\ + .output(); + + assert!(result.is_ok(), "xargs failed: {:?}", result); + let result = result.unwrap(); + assert_eq!(result.status.code(), Some(123)); + + assert!(result.stderr.is_empty(), "stderr: {:?}", result); + + let stdout_string = String::from_utf8(result.stdout).expect("Found invalid UTF-8"); + + assert_eq!( + stdout_string, + "args=\n--no_print_cwd\n--exit_with_failure\na\n\ args=\n--no_print_cwd\n--exit_with_failure\nb\n", - ); + ); } #[test] fn xargs_exec_urgent_failure() { - Command::cargo_bin("xargs") + let result = Command::cargo_bin("xargs") .expect("found binary") .args([ "-n1", @@ -287,17 +310,26 @@ fn xargs_exec_urgent_failure() { "--exit_with_urgent_failure", ]) .write_stdin("a b") - .assert() - .failure() - .code(124) - .stderr(predicate::str::contains("Error:")) - .stdout("args=\n--no_print_cwd\n--exit_with_urgent_failure\na\n"); + .output(); + + assert!(result.is_ok(), "xargs failed: {:?}", result); + let result = result.unwrap(); + assert_eq!(result.status.code(), Some(124)); + + assert!(!result.stderr.is_empty(), "stderr: {:?}", result); + + let stdout_string = String::from_utf8(result.stdout).expect("Found invalid UTF-8"); + + assert_eq!( + stdout_string, + "args=\n--no_print_cwd\n--exit_with_urgent_failure\na\n" + ); } #[test] #[cfg(unix)] fn xargs_exec_with_signal() { - Command::cargo_bin("xargs") + let result = Command::cargo_bin("xargs") .expect("found binary") .args([ "-n1", @@ -307,11 +339,19 @@ fn xargs_exec_with_signal() { "--exit_with_signal", ]) .write_stdin("a b") - .assert() - .failure() - .code(125) - .stderr(predicate::str::contains("Error:")) - .stdout("args=\n--no_print_cwd\n--exit_with_signal\na\n"); + .output(); + + assert!(result.is_ok(), "xargs failed: {:?}", result); + let result = result.unwrap(); + assert_eq!(result.status.code(), Some(125)); + assert!(!result.stderr.is_empty(), "stderr: {:?}", result); + + let stdout_string = String::from_utf8(result.stdout).expect("Found invalid UTF-8"); + + assert_eq!( + stdout_string, + "args=\n--no_print_cwd\n--exit_with_signal\na\n" + ); } #[test] @@ -385,3 +425,80 @@ fn xargs_zero_lines() { .stderr(predicate::str::contains("Value must be > 0, not: 0")) .stdout(predicate::str::is_empty()); } + +#[test] +fn xargs_replace() { + Command::cargo_bin("xargs") + .expect("found binary") + .args(["-i={}", "echo", "{} bar"]) + .write_stdin("foo") + .assert() + .stdout(predicate::str::contains("foo bar")); + + Command::cargo_bin("xargs") + .expect("found binary") + .args(["-i=_", "echo", "_ bar"]) + .write_stdin("foo") + .assert() + .stdout(predicate::str::contains("foo bar")); + + Command::cargo_bin("xargs") + .expect("found binary") + .args(["--replace=_", "echo", "_ _ bar"]) + .write_stdin("foo") + .assert() + .stdout(predicate::str::contains("foo foo bar")); + + Command::cargo_bin("xargs") + .expect("found binary") + .args(["-i=_", "echo", "_ _ bar"]) + .write_stdin("foo") + .assert() + .stdout(predicate::str::contains("foo foo bar")); + + Command::cargo_bin("xargs") + .expect("found binary") + .args(["-i", "echo", "{} {} bar"]) + .write_stdin("foo") + .assert() + .stdout(predicate::str::contains("foo foo bar")); + + Command::cargo_bin("xargs") + .expect("found binary") + .args(["-I={}", "echo", "{} bar {}"]) + .write_stdin("foo") + .assert() + .stdout(predicate::str::contains("foo bar foo")); + + // Combine the two options to see which one wins + Command::cargo_bin("xargs") + .expect("found binary") + .args(["-I=_", "-i", "echo", "{} bar {}"]) + .write_stdin("foo") + .assert() + .stdout(predicate::str::contains("foo bar foo")); + + // other order + Command::cargo_bin("xargs") + .expect("found binary") + .args(["-i", "-I=_", "echo", "{} bar {}"]) + .write_stdin("foo") + .assert() + .stdout(predicate::str::contains("{} bar {}")); + + Command::cargo_bin("xargs") + .expect("found binary") + .args(["-i", "-I", "_", "echo", "{} bar _"]) + .write_stdin("foo") + .assert() + .stdout(predicate::str::contains("{} bar foo")); + + // Expected to fail + Command::cargo_bin("xargs") + .expect("found binary") + .args(["-I", "echo", "_ _ bar"]) + .write_stdin("foo") + .assert() + .failure() + .stderr(predicate::str::contains("Error: Command not found")); +}