Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions src/find/matchers/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,31 @@ pub struct SingleExecMatcher {
executable: String,
args: Vec<Arg>,
exec_in_parent_dir: bool,
interactive: bool,
}

impl SingleExecMatcher {
pub fn new(
executable: &str,
args: &[&str],
exec_in_parent_dir: bool,
) -> Result<Self, Box<dyn Error>> {
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, Box<dyn Error>> {
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<Self, Box<dyn Error>> {
let transformed_args = args
.iter()
Expand All @@ -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)
Expand All @@ -64,6 +82,28 @@ impl Matcher for SingleExecMatcher {
file_info.path().to_path_buf()
};

if self.interactive {
let rendered_args: Vec<String> = 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()),
Expand Down
94 changes: 94 additions & 0 deletions src/find/matchers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
69 changes: 68 additions & 1 deletion src/find/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -57,20 +57,44 @@ impl Default for Config {
pub trait Dependencies {
fn get_output(&self) -> &RefCell<dyn Write>;
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<RefCell<dyn Write>>,
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<RefCell<BufReader<std::fs::File>>>,
}

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,
}
}
}
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -360,13 +410,16 @@ mod tests {
pub struct FakeDependencies {
pub output: RefCell<Cursor<Vec<u8>>>,
now: SystemTime,
/// Preset responses for confirm(), consumed front-to-back.
confirm_responses: RefCell<std::collections::VecDeque<bool>>,
}

impl<'a> FakeDependencies {
pub fn new() -> Self {
Self {
output: RefCell::new(Cursor::new(Vec::<u8>::new())),
now: SystemTime::now(),
confirm_responses: RefCell::new(std::collections::VecDeque::new()),
}
}

Expand All @@ -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 {
Expand All @@ -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() {
Expand Down
18 changes: 18 additions & 0 deletions tests/common/test_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,13 +20,16 @@ use findutils::find::Dependencies;
pub struct FakeDependencies {
pub output: RefCell<Cursor<Vec<u8>>>,
now: SystemTime,
/// Preset responses for confirm(), consumed front-to-back.
confirm_responses: RefCell<VecDeque<bool>>,
}

impl FakeDependencies {
pub fn new() -> Self {
Self {
output: RefCell::new(Cursor::new(Vec::<u8>::new())),
now: SystemTime::now(),
confirm_responses: RefCell::new(VecDeque::new()),
}
}

Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
Loading
Loading