find: implement -ok and -okdir (#650)

-ok is like -exec ... ; but prompts the user on stderr before each
invocation and only runs the command if the response is affirmative
(starts with 'y' or 'Y').  -okdir is the corresponding -execdir variant.
Only the ';' terminator is accepted: POSIX does not define -ok ... + and
GNU find rejects it.

Response source: GNU find reads from /dev/tty so that the user's answer
always comes from the real terminal even when stdin is occupied (e.g.
`find -files0-from - -ok rm {} \;` reads paths from stdin, so responses
cannot also come from there).  /dev/tty is the POSIX name for a
process's controlling terminal and exists on all Unix-like systems.  We
open /dev/tty only when stdin is itself a terminal
(std::io::stdin().is_terminal() == true).  When stdin is a pipe or file,
we read from stdin directly — matching BSD find and making scripted use
(and integration tests via pipe_in()) work naturally without any special
environment variable.  On Windows, or when /dev/tty cannot be opened, we
likewise fall back to stdin.

Implementation: rather than a separate OkMatcher that duplicated
SingleExecMatcher's fields, constructor, and exec logic, the interactive
prompt is folded into SingleExecMatcher behind an `interactive: bool`
field.  `SingleExecMatcher::new_interactive()` constructs the -ok/-okdir
variant; `matches()` adds a guarded block that builds a GNU-find-style
prompt ("< executable arg... >? ") and calls `matcher_io.confirm()`
before executing.  If the user declines, the expression is false and the
command is not run.  This keeps the arg-parsing, path resolution,
current_dir logic, and error handling in one place so bug fixes apply to
both -exec and -ok.

Dependencies::confirm() trait method: abstracts prompt+read so matchers
remain testable without a real terminal; FakeDependencies uses a
VecDeque of preset responses.

Tests:
- Unit tests (exec_unit_tests.rs): confirmed executes command, declined
  skips command and returns false, confirmed but command fails returns
  false, -okdir runs in parent dir
- Parser unit tests (matchers/mod.rs): missing-arg errors, missing
  semicolon, correct parse, confirm/decline via FakeDependencies
- Integration tests (test_find.rs): pipe_in() makes stdin a pipe so
  is_terminal() returns false and responses are read from the pipe;
  "y"/"Y"/"yes"/" y" run command, "n" and empty response skip command,
  -okdir runs in parent dir, prompt format verified, missing-semicolon
  error

Closes https://github.com/uutils/findutils/issues/8.
This commit is contained in:
Jesse Rosenstock
2026-06-04 20:31:54 +02:00
committed by GitHub
parent fd876a7a8a
commit 5269e233a6
7 changed files with 584 additions and 3 deletions
+42 -2
View File
@@ -22,6 +22,7 @@ pub struct SingleExecMatcher {
executable: String,
args: Vec<Arg>,
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, 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()
@@ -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<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()),
+94
View File
@@ -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();
+68 -1
View File
@@ -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<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,
}
}
}
@@ -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<Cursor<Vec<u8>>>,
now: SystemTime,
/// Preset responses for confirm(), consumed front-to-back.
confirm_responses: RefCell<std::collections::VecDeque<bool>>,
}
impl<'a> FakeDependencies {
@@ -367,6 +419,7 @@ mod tests {
Self {
output: RefCell::new(Cursor::new(Vec::<u8>::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() {
+18
View File
@@ -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<Cursor<Vec<u8>>>,
now: SystemTime,
/// Preset responses for confirm(), consumed front-to-back.
confirm_responses: RefCell<VecDeque<bool>>,
}
impl FakeDependencies {
@@ -26,6 +29,7 @@ impl FakeDependencies {
Self {
output: RefCell::new(Cursor::new(Vec::<u8>::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 {
+134
View File
@@ -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());
}
+78
View File
@@ -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()
+150
View File
@@ -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();
}