diff --git a/src/find/matchers/exec.rs b/src/find/matchers/exec.rs index 51337953..611b7b46 100644 --- a/src/find/matchers/exec.rs +++ b/src/find/matchers/exec.rs @@ -22,6 +22,7 @@ pub struct SingleExecMatcher { executable: String, args: Vec, exec_in_parent_dir: bool, + interactive: bool, } impl SingleExecMatcher { @@ -29,6 +30,23 @@ impl SingleExecMatcher { executable: &str, args: &[&str], exec_in_parent_dir: bool, + ) -> Result> { + Self::new_impl(executable, args, exec_in_parent_dir, false) + } + + pub fn new_interactive( + executable: &str, + args: &[&str], + exec_in_parent_dir: bool, + ) -> Result> { + Self::new_impl(executable, args, exec_in_parent_dir, true) + } + + fn new_impl( + executable: &str, + args: &[&str], + exec_in_parent_dir: bool, + interactive: bool, ) -> Result> { let transformed_args = args .iter() @@ -47,13 +65,13 @@ impl SingleExecMatcher { executable: executable.to_string(), args: transformed_args, exec_in_parent_dir, + interactive, }) } } impl Matcher for SingleExecMatcher { - fn matches(&self, file_info: &WalkEntry, _: &mut MatcherIO) -> bool { - let mut command = Command::new(&self.executable); + fn matches(&self, file_info: &WalkEntry, matcher_io: &mut MatcherIO) -> bool { let path_to_file = if self.exec_in_parent_dir { if let Some(f) = file_info.path().file_name() { Path::new(".").join(f) @@ -64,6 +82,28 @@ impl Matcher for SingleExecMatcher { file_info.path().to_path_buf() }; + if self.interactive { + let rendered_args: Vec = self + .args + .iter() + .map(|arg| match arg { + Arg::LiteralArg(a) => a.to_string_lossy().into_owned(), + Arg::FileArg(parts) => parts + .join(path_to_file.as_os_str()) + .to_string_lossy() + .into_owned(), + }) + .collect(); + let mut prompt_parts = vec![self.executable.clone()]; + prompt_parts.extend(rendered_args); + let prompt = format!("< {} >? ", prompt_parts.join(" ")); + + if !matcher_io.confirm(&prompt) { + return false; + } + } + + let mut command = Command::new(&self.executable); for arg in &self.args { match *arg { Arg::LiteralArg(ref a) => command.arg(a.as_os_str()), diff --git a/src/find/matchers/mod.rs b/src/find/matchers/mod.rs index ca2ed15e..a3abe4f0 100644 --- a/src/find/matchers/mod.rs +++ b/src/find/matchers/mod.rs @@ -187,6 +187,14 @@ impl MatcherIO<'_> { pub fn now(&self) -> SystemTime { self.deps.now() } + + /// Prompt the user and return whether they confirmed. Delegates to + /// `Dependencies::confirm` so that matchers stay testable without a real + /// terminal — unit tests inject preset responses via `FakeDependencies`. + #[must_use] + pub fn confirm(&self, prompt: &str) -> bool { + self.deps.confirm(prompt) + } } /// A basic interface that can be used to determine whether a directory entry @@ -668,6 +676,31 @@ fn build_matcher_tree( _ => unreachable!("Encountered unexpected value {}", args[arg_index]), } } + "-ok" | "-okdir" => { + // -ok is like -exec ... ; but prompts before each invocation. + // Only ';' is accepted: POSIX does not define -ok ... + and + // GNU find rejects it (batch mode makes no sense with prompts). + let mut arg_index = i + 1; + while arg_index < args.len() && args[arg_index] != ";" { + arg_index += 1; + } + if arg_index < i + 2 || arg_index == args.len() { + // Need at least the executable and the terminating ';'. + return Err(From::from(format!("missing argument to {}", args[i]))); + } + let expression = args[i]; + let executable = args[i + 1]; + let exec_args = &args[i + 2..arg_index]; + i = arg_index; + Some( + SingleExecMatcher::new_interactive( + executable, + exec_args, + expression == "-okdir", + )? + .into_box(), + ) + } #[cfg(unix)] "-inum" => { if i >= args.len() - 1 { @@ -1620,6 +1653,67 @@ mod tests { .expect("only {} + should be considered a multi-exec"); } + #[test] + fn build_top_level_ok_not_enough_args() { + // -ok follows the same validation rules as -exec for missing arguments. + let mut config = Config::default(); + if let Err(e) = build_top_level_matcher(&["-ok"], &mut config) { + assert!(e.to_string().contains("missing argument")); + } else { + panic!("parsing -ok with no executable or semicolon should fail"); + } + + if let Err(e) = build_top_level_matcher(&["-ok", ";"], &mut config) { + assert!(e.to_string().contains("missing argument")); + } else { + panic!("parsing -ok with no executable should fail"); + } + + if let Err(e) = build_top_level_matcher(&["-ok", "foo"], &mut config) { + assert!(e.to_string().contains("missing argument")); + } else { + panic!("parsing -ok without terminating ';' should fail"); + } + } + + #[test] + fn build_top_level_ok_missing_semicolon() { + let mut config = Config::default(); + build_top_level_matcher(&["-ok", "echo", "{}"], &mut config) + .err() + .expect("parsing -ok without ';' should fail"); + } + + #[test] + fn build_top_level_ok_parses_correctly() { + let mut config = Config::default(); + build_top_level_matcher(&["-ok", "echo", "{}", ";"], &mut config) + .expect("-ok with executable, {} and ';' should succeed"); + } + + #[test] + #[cfg(unix)] + fn build_top_level_ok_matches_with_confirmation() { + // When the user confirms, -ok should match (run the command and return + // its exit status). When the user declines, -ok is false and the + // command is not run. + let abbbc = get_dir_entry_for("./test_data/simple", "abbbc"); + let mut config = Config::default(); + + // Confirmed: -ok behaves like -exec (prints nothing; has_side_effects + // suppresses the default print, same as -exec). + let matcher = build_top_level_matcher(&["-ok", "true", ";"], &mut config).unwrap(); + let deps = FakeDependencies::new(); + deps.push_confirm_response(true); + assert!(matcher.matches(&abbbc, &mut deps.new_matcher_io())); + + // Declined: -ok is false, so the default print fires. + let matcher = build_top_level_matcher(&["-ok", "true", ";"], &mut config).unwrap(); + let deps = FakeDependencies::new(); + deps.push_confirm_response(false); + assert!(!matcher.matches(&abbbc, &mut deps.new_matcher_io())); + } + #[test] fn build_top_level_multi_exec_too_many_holders() { let mut config = Config::default(); diff --git a/src/find/mod.rs b/src/find/mod.rs index b9c9cd3a..63a35735 100644 --- a/src/find/mod.rs +++ b/src/find/mod.rs @@ -9,7 +9,7 @@ pub mod matchers; use matchers::{Follow, WalkEntry}; use std::cell::RefCell; use std::error::Error; -use std::io::{stderr, stdout, Write}; +use std::io::{stderr, stdout, BufRead, BufReader, IsTerminal, Write}; use std::path::PathBuf; use std::rc::Rc; use std::time::SystemTime; @@ -57,20 +57,44 @@ impl Default for Config { pub trait Dependencies { fn get_output(&self) -> &RefCell; fn now(&self) -> SystemTime; + /// Write `prompt` to stderr and return whether the user's response is + /// affirmative (starts with 'y' or 'Y'). + /// + /// POSIX specifies that -ok writes a prompt to stderr but leaves the input + /// source implementation-defined. GNU find reads from /dev/tty so that + /// the answer comes from the real terminal even when stdin is redirected; + /// BSD find reads from stdin unconditionally. + fn confirm(&self, prompt: &str) -> bool; } /// Struct that holds the dependencies we use when run as the real executable. pub struct StandardDependencies { output: Rc>, now: SystemTime, + /// Open handle to /dev/tty for reading -ok responses, or None when stdin + /// is not a terminal (pipe/file) or we are on Windows. Opened once at + /// construction so we don't re-open it for every matched file. + tty: Option>>, } impl StandardDependencies { #[must_use] pub fn new() -> Self { + #[cfg(unix)] + let tty = if std::io::stdin().is_terminal() { + std::fs::File::open("/dev/tty") + .ok() + .map(|f| RefCell::new(BufReader::new(f))) + } else { + None + }; + #[cfg(not(unix))] + let tty = None; + Self { output: Rc::new(RefCell::new(stdout())), now: SystemTime::now(), + tty, } } } @@ -89,6 +113,31 @@ impl Dependencies for StandardDependencies { fn now(&self) -> SystemTime { self.now } + + fn confirm(&self, prompt: &str) -> bool { + // POSIX requires the prompt on stderr. + eprint!("{}", prompt); + let _ = stderr().flush(); + + // self.tty is Some when stdin was a terminal at startup: read from the + // controlling terminal so responses come from the keyboard even when + // stdin is occupied (e.g. `find -files0-from - -ok rm {} \;`). + // Otherwise fall back to stdin — BSD find's behaviour, and what we + // want when stdin is a pipe supplying scripted responses. + // EOF and errors both yield None/Err, which unwrap_or_default turns + // into an empty string — treated as "declined", matching GNU find. + let response = if let Some(tty) = &self.tty { + // Deref through RefMut to get &mut BufReader so lines() can take + // it by value without moving out of the RefCell. + (&mut *tty.borrow_mut()).lines().next() + } else { + std::io::stdin().lock().lines().next() + } + .and_then(Result::ok) + .unwrap_or_default(); + + response.trim_start().starts_with(['y', 'Y']) + } } /// The result of parsing the command-line arguments into useful forms. @@ -298,6 +347,7 @@ Early alpha implementation. Currently the only expressions supported are -perm [-/]{{octal|u=rwx,go=w}} -newer path_to_file -exec[dir] executable [args] [{{}}] [more args] ; + -ok[dir] executable [args] [{{}}] [more args] ; -sorted a non-standard extension that sorts directory contents by name before processing them. Less efficient, but allows for deterministic output. @@ -360,6 +410,8 @@ mod tests { pub struct FakeDependencies { pub output: RefCell>>, now: SystemTime, + /// Preset responses for confirm(), consumed front-to-back. + confirm_responses: RefCell>, } impl<'a> FakeDependencies { @@ -367,6 +419,7 @@ mod tests { Self { output: RefCell::new(Cursor::new(Vec::::new())), now: SystemTime::now(), + confirm_responses: RefCell::new(std::collections::VecDeque::new()), } } @@ -385,6 +438,11 @@ mod tests { cursor.read_to_string(&mut contents).unwrap(); contents } + + /// Queue a response to be returned by the next call to confirm(). + pub fn push_confirm_response(&self, response: bool) { + self.confirm_responses.borrow_mut().push_back(response); + } } impl Dependencies for FakeDependencies { @@ -395,6 +453,15 @@ mod tests { fn now(&self) -> SystemTime { self.now } + + fn confirm(&self, _prompt: &str) -> bool { + // Return the next preset response; default to false (decline) so + // that a test that forgets to queue a response fails safely. + self.confirm_responses + .borrow_mut() + .pop_front() + .unwrap_or(false) + } } fn create_file_link() { diff --git a/tests/common/test_helpers.rs b/tests/common/test_helpers.rs index 0f69a305..acf5a526 100644 --- a/tests/common/test_helpers.rs +++ b/tests/common/test_helpers.rs @@ -5,6 +5,7 @@ // https://opensource.org/licenses/MIT. use std::cell::RefCell; +use std::collections::VecDeque; use std::env; use std::io::{Cursor, Read, Write}; use std::path::Path; @@ -19,6 +20,8 @@ use findutils::find::Dependencies; pub struct FakeDependencies { pub output: RefCell>>, now: SystemTime, + /// Preset responses for confirm(), consumed front-to-back. + confirm_responses: RefCell>, } impl FakeDependencies { @@ -26,6 +29,7 @@ impl FakeDependencies { Self { output: RefCell::new(Cursor::new(Vec::::new())), now: SystemTime::now(), + confirm_responses: RefCell::new(VecDeque::new()), } } @@ -40,6 +44,11 @@ impl FakeDependencies { cursor.read_to_string(&mut contents).unwrap(); contents } + + /// Queue a response to be returned by the next call to confirm(). + pub fn push_confirm_response(&self, response: bool) { + self.confirm_responses.borrow_mut().push_back(response); + } } impl Dependencies for FakeDependencies { @@ -50,6 +59,15 @@ impl Dependencies for FakeDependencies { fn now(&self) -> SystemTime { self.now } + + fn confirm(&self, _prompt: &str) -> bool { + // Return the next preset response; default to false (decline) so + // that a test that forgets to queue a response fails safely. + self.confirm_responses + .borrow_mut() + .pop_front() + .unwrap_or(false) + } } pub fn path_to_testing_commandline() -> String { diff --git a/tests/exec_unit_tests.rs b/tests/exec_unit_tests.rs index e952e405..3e8cfbeb 100644 --- a/tests/exec_unit_tests.rs +++ b/tests/exec_unit_tests.rs @@ -336,3 +336,137 @@ fn multi_set_exit_code_if_command_fails() { matcher.finished_dir(Path::new("test_data/simple"), &mut matcher_io); assert!(matcher_io.exit_code() == 1); } + +// -ok / -okdir tests +// +// These use FakeDependencies, which answers confirm() from a preset queue +// rather than a real terminal. That tests both the "confirmed" and +// "declined" paths without needing a TTY. The integration tests in +// test_find.rs cover the stdin-fallback path (no controlling terminal). + +#[test] +fn ok_executes_when_confirmed() { + // When the user confirms, -ok runs the command and returns true on success. + let temp_dir = Builder::new() + .prefix("ok_executes_when_confirmed") + .tempdir() + .unwrap(); + let temp_dir_path = temp_dir.path().to_string_lossy(); + + let abbbc = get_dir_entry_for("test_data/simple", "abbbc"); + let matcher = SingleExecMatcher::new_interactive( + &path_to_testing_commandline(), + &[temp_dir_path.as_ref(), "abc", "{}", "xyz"], + false, + ) + .expect("Failed to create matcher"); + + let deps = FakeDependencies::new(); + deps.push_confirm_response(true); + assert!(matcher.matches(&abbbc, &mut deps.new_matcher_io())); + + let mut f = File::open(temp_dir.path().join("1.txt")).expect("Failed to open output file"); + let mut s = String::new(); + f.read_to_string(&mut s) + .expect("failed to read output file"); + assert_eq!( + s, + fix_up_slashes(&format!( + "cwd={}\nargs=\nabc\ntest_data/simple/abbbc\nxyz\n", + env::current_dir().unwrap().to_string_lossy() + )) + ); +} + +#[test] +fn ok_skips_when_declined() { + // When the user declines, -ok returns false without running the command. + let temp_dir = Builder::new() + .prefix("ok_skips_when_declined") + .tempdir() + .unwrap(); + let temp_dir_path = temp_dir.path().to_string_lossy(); + + let abbbc = get_dir_entry_for("test_data/simple", "abbbc"); + let matcher = SingleExecMatcher::new_interactive( + &path_to_testing_commandline(), + &[temp_dir_path.as_ref(), "abc", "{}", "xyz"], + false, + ) + .expect("Failed to create matcher"); + + let deps = FakeDependencies::new(); + deps.push_confirm_response(false); + assert!(!matcher.matches(&abbbc, &mut deps.new_matcher_io())); + + // The command was not run, so no output file should exist. + assert!( + !temp_dir.path().join("1.txt").exists(), + "command should not have run when user declined" + ); +} + +#[test] +fn okdir_executes_in_parent_dir_when_confirmed() { + // -okdir runs the command in the file's parent directory, same as -execdir. + let temp_dir = Builder::new() + .prefix("okdir_executes_when_confirmed") + .tempdir() + .unwrap(); + let temp_dir_path = temp_dir.path().to_string_lossy(); + + let abbbc = get_dir_entry_for("test_data/simple", "abbbc"); + let matcher = SingleExecMatcher::new_interactive( + &path_to_testing_commandline(), + &[temp_dir_path.as_ref(), "abc", "{}", "xyz"], + true, // exec_in_parent_dir = true → -okdir behaviour + ) + .expect("Failed to create matcher"); + + let deps = FakeDependencies::new(); + deps.push_confirm_response(true); + assert!(matcher.matches(&abbbc, &mut deps.new_matcher_io())); + + let mut f = File::open(temp_dir.path().join("1.txt")).expect("Failed to open output file"); + let mut s = String::new(); + f.read_to_string(&mut s) + .expect("failed to read output file"); + assert_eq!( + s, + fix_up_slashes(&format!( + "cwd={}/test_data/simple\nargs=\nabc\n./abbbc\nxyz\n", + env::current_dir().unwrap().to_string_lossy() + )) + ); +} + +#[test] +fn ok_returns_false_when_command_fails() { + // When the user confirms but the command exits non-zero, -ok returns false. + let temp_dir = Builder::new() + .prefix("ok_returns_false_when_command_fails") + .tempdir() + .unwrap(); + let temp_dir_path = temp_dir.path().to_string_lossy(); + + let abbbc = get_dir_entry_for("test_data/simple", "abbbc"); + let matcher = SingleExecMatcher::new_interactive( + &path_to_testing_commandline(), + &[ + temp_dir_path.as_ref(), + "--exit_with_failure", + "abc", + "{}", + "xyz", + ], + false, + ) + .expect("Failed to create matcher"); + + let deps = FakeDependencies::new(); + deps.push_confirm_response(true); + assert!(!matcher.matches(&abbbc, &mut deps.new_matcher_io())); + + // The command did run (output file exists), but it failed. + assert!(temp_dir.path().join("1.txt").exists()); +} diff --git a/tests/find_exec_tests.rs b/tests/find_exec_tests.rs index 510a6939..7b2b9f51 100644 --- a/tests/find_exec_tests.rs +++ b/tests/find_exec_tests.rs @@ -18,6 +18,84 @@ use common::test_helpers::{fix_up_slashes, path_to_testing_commandline, FakeDepe use findutils::find::find_main; mod common; + +#[test] +fn find_ok_confirmed() { + let temp_dir = Builder::new() + .prefix("find_ok_confirmed") + .tempdir() + .unwrap(); + let temp_dir_path = temp_dir.path().to_string_lossy(); + let deps = FakeDependencies::new(); + deps.push_confirm_response(true); + + let rc = find_main( + &[ + "find", + &fix_up_slashes("./test_data/simple"), + "-maxdepth", + "1", + "-name", + "abbbc", + "-ok", + &path_to_testing_commandline(), + temp_dir_path.as_ref(), + "abc", + "{}", + "xyz", + ";", + ], + &deps, + ); + + assert_eq!(rc, 0); + assert_eq!(deps.get_output_as_string(), ""); + + let mut f = File::open(temp_dir.path().join("1.txt")).expect("Failed to open output file"); + let mut s = String::new(); + f.read_to_string(&mut s) + .expect("failed to read output file"); + assert_eq!( + s, + fix_up_slashes(&format!( + "cwd={}\nargs=\nabc\n./test_data/simple/abbbc\nxyz\n", + env::current_dir().unwrap().to_string_lossy() + )) + ); +} + +#[test] +fn find_ok_declined() { + let temp_dir = Builder::new().prefix("find_ok_declined").tempdir().unwrap(); + let temp_dir_path = temp_dir.path().to_string_lossy(); + let deps = FakeDependencies::new(); + deps.push_confirm_response(false); + + let rc = find_main( + &[ + "find", + &fix_up_slashes("./test_data/simple"), + "-maxdepth", + "1", + "-name", + "abbbc", + "-ok", + &path_to_testing_commandline(), + temp_dir_path.as_ref(), + "abc", + "{}", + "xyz", + ";", + ], + &deps, + ); + + assert_eq!(rc, 0); + // -ok is false when declined, so no default print either. + assert_eq!(deps.get_output_as_string(), ""); + // Command did not run. + assert!(!temp_dir.path().join("1.txt").exists()); +} #[test] fn find_exec() { let temp_dir = tempfile::Builder::new() diff --git a/tests/test_find.rs b/tests/test_find.rs index 97d69e92..c51891fc 100644 --- a/tests/test_find.rs +++ b/tests/test_find.rs @@ -1001,3 +1001,153 @@ fn find_slashes() { .succeeds() .no_stderr(); } + +// -ok / -okdir integration tests +// +// These tests use pipe_in() to supply the user's response. Because pipe_in() +// makes stdin a pipe, std::io::stdin().is_terminal() returns false inside the +// find subprocess, so StandardDependencies::confirm reads from stdin directly +// instead of opening /dev/tty. No special environment variable is needed. + +#[test] +fn find_ok_yes_runs_command() { + // When the user answers "y", -ok should run the command and print output. + ucmd() + .args(&[ + "test_data/simple", + "-maxdepth", + "1", + "-name", + "abbbc", + "-ok", + "echo", + "{}", + ";", + ]) + // Pipe the affirmative response for the single file found. + .pipe_in("y\n") + .succeeds() + .stderr_contains("< echo") // prompt appeared + .stdout_contains("abbbc"); // echo ran +} + +#[test] +fn find_ok_no_skips_command() { + // When the user answers "n", -ok should not run the command. + // The expression is false so no output is produced, but find exits 0. + ucmd() + .args(&[ + "test_data/simple", + "-maxdepth", + "1", + "-name", + "abbbc", + "-ok", + "echo", + "{}", + ";", + ]) + .pipe_in("n\n") + .succeeds() + .stderr_contains("< echo") // prompt still appeared + .no_stdout(); // but echo was not run +} + +#[test] +fn find_ok_prompt_format() { + // The prompt should follow GNU find's "< executable args... >? " format. + ucmd() + .args(&[ + "test_data/simple", + "-maxdepth", + "1", + "-name", + "abbbc", + "-ok", + "echo", + "{}", + ";", + ]) + .pipe_in("n\n") + .succeeds() + .stderr_contains(format!( + "< echo {} >? ", + Path::new("test_data/simple") + .join("abbbc") + .to_string_lossy() + )); +} + +#[test] +fn find_ok_empty_response_declines() { + // An empty line (just Enter) should be treated as decline. + ucmd() + .args(&[ + "test_data/simple", + "-maxdepth", + "1", + "-name", + "abbbc", + "-ok", + "echo", + "{}", + ";", + ]) + .pipe_in("\n") + .succeeds() + .no_stdout(); +} + +#[test] +fn find_ok_accepts_y_variants() { + // "Y", "yes", and " y" (leading whitespace) should all be accepted. + for response in &["Y\n", "yes\n", " y\n"] { + ucmd() + .args(&[ + "test_data/simple", + "-maxdepth", + "1", + "-name", + "abbbc", + "-ok", + "echo", + "{}", + ";", + ]) + .pipe_in(*response) + .succeeds() + .stdout_contains("abbbc"); + } +} + +#[test] +fn find_okdir_yes_runs_command() { + // -okdir should run the command in the file's parent directory. + ucmd() + .args(&[ + "test_data/simple", + "-maxdepth", + "1", + "-name", + "abbbc", + "-okdir", + "echo", + "{}", + ";", + ]) + .pipe_in("y\n") + .succeeds() + .stderr_contains("< echo") + .stdout_contains(fix_up_slashes("./abbbc")); +} + +#[test] +fn find_ok_missing_semicolon() { + // -ok without a closing ';' should be an error (just like -exec). + ucmd() + .args(&["test_data/simple", "-ok", "echo", "{}"]) + .pipe_in("") + .fails() + .stderr_contains("missing argument to -ok") + .no_stdout(); +}