use assert_cmd::Command;
use clap::{Arg, CommandFactory};
use proc_cli::cli::Cli;
use serde_json::Value;
use std::any::TypeId;
fn proc_cmd() -> Command {
let mut cmd = Command::cargo_bin("proc").unwrap();
cmd.env("NO_COLOR", "1");
cmd
}
#[derive(Debug)]
struct Case {
what: String,
args: Vec<String>,
needles: Vec<String>,
json: bool,
}
fn display_name(arg: &Arg) -> String {
if arg.is_positional() {
arg.get_value_names()
.and_then(|names| names.first())
.map(|name| name.to_string())
.unwrap_or_else(|| arg.get_id().as_str().to_uppercase())
} else {
format!("--{}", arg.get_long().expect("an option has a long flag"))
}
}
fn takes_value(arg: &Arg) -> bool {
arg.get_action().takes_values()
}
fn needs_value(arg: &Arg) -> bool {
takes_value(arg) && arg.get_num_args().is_none_or(|n| n.min_values() >= 1)
}
fn allowed_values(arg: &Arg) -> Vec<String> {
arg.get_possible_values()
.iter()
.filter(|value| !value.is_hide_set())
.map(|value| value.get_name().to_string())
.collect()
}
fn is_numeric(arg: &Arg) -> bool {
let id = arg.get_value_parser().type_id();
[
TypeId::of::<u8>(),
TypeId::of::<u16>(),
TypeId::of::<u32>(),
TypeId::of::<u64>(),
TypeId::of::<usize>(),
TypeId::of::<i32>(),
TypeId::of::<i64>(),
TypeId::of::<f32>(),
TypeId::of::<f64>(),
]
.iter()
.any(|number| id == *number)
}
fn refused_value(arg: &Arg) -> Option<(&'static str, &'static str)> {
match arg.get_id().as_str() {
"target" | "targets" => Some((":99999", "is not a port")),
"range" => Some(("bogus", "is not a range of ports")),
_ => None,
}
}
fn custom_parser(sub: &clap::Command, arg: &Arg) -> bool {
if !takes_value(arg) || !allowed_values(arg).is_empty() || is_numeric(arg) {
return false;
}
let id = arg.get_value_parser().type_id();
let text = [TypeId::of::<String>(), TypeId::of::<std::path::PathBuf>()];
if !text.iter().any(|t| id == *t) {
return true;
}
[":99999", "bogus"].iter().any(|value| {
let mut argv = vec![sub.get_name().to_string()];
if !arg.is_positional() {
argv.push(display_name(arg));
}
argv.push(value.to_string());
matches!(
sub.clone().try_get_matches_from(argv),
Err(e) if e.kind() == clap::error::ErrorKind::ValueValidation
)
})
}
fn strings(args: &[&str]) -> Vec<String> {
args.iter().map(|a| a.to_string()).collect()
}
fn corpus() -> Vec<Case> {
let root = Cli::command();
let mut cases = vec![
Case {
what: "root: unknown flag".to_string(),
args: strings(&["--definitely-not-a-flag"]),
needles: strings(&["--definitely-not-a-flag"]),
json: true,
},
Case {
what: "root: unknown subcommand".to_string(),
args: strings(&["definitely-not-a-command"]),
needles: strings(&["definitely-not-a-command"]),
json: true,
},
];
for sub in root.get_subcommands() {
let name = sub.get_name().to_string();
let args: Vec<&Arg> = sub
.get_arguments()
.filter(|a| !matches!(a.get_id().as_str(), "help" | "version"))
.collect();
let has_json = args.iter().any(|a| a.get_long() == Some("json"));
let case = |what: String, mut rest: Vec<String>, needles: Vec<String>| {
let mut all = vec![name.clone()];
all.append(&mut rest);
Case {
what: format!("{name}: {what}"),
args: all,
needles,
json: has_json,
}
};
cases.push(case(
"unknown flag".to_string(),
strings(&["--definitely-not-a-flag"]),
strings(&["--definitely-not-a-flag"]),
));
let required: Vec<&&Arg> = args.iter().filter(|a| a.is_required_set()).collect();
if !required.is_empty() {
cases.push(case(
"missing required arguments".to_string(),
Vec::new(),
required.iter().map(|a| display_name(a)).collect(),
));
}
for arg in &args {
let shown = display_name(arg);
let allowed = allowed_values(arg);
if takes_value(arg) && (!allowed.is_empty() || is_numeric(arg)) {
let bad = "bogus-value".to_string();
let mut needles = vec![bad.clone()];
needles.extend(allowed.clone());
let rest = if arg.is_positional() {
vec![bad]
} else {
needles.push(shown.clone());
vec![shown.clone(), bad]
};
cases.push(case(format!("bad value for {shown}"), rest, needles));
}
assert!(
refused_value(arg).is_some() || !custom_parser(sub, arg),
"{name} {shown}: its parser is custom; give it a value in refused_value"
);
if let Some((bad, reason)) = refused_value(arg) {
let mut needles = strings(&[bad, reason]);
let rest = if arg.is_positional() {
vec![bad.to_string()]
} else {
needles.push(shown.clone());
vec![shown.clone(), bad.to_string()]
};
cases.push(case(format!("refused value for {shown}"), rest, needles));
}
if !arg.is_positional() && needs_value(arg) {
cases.push(case(
format!("missing value for {shown}"),
vec![shown.clone()],
vec![shown.clone()],
));
}
if arg.is_positional() {
let max = arg.get_num_args().map_or(1, |n| n.max_values());
if max < 8 {
let valid = allowed
.first()
.cloned()
.unwrap_or_else(|| "val".to_string());
let mut rest = vec![valid; max];
rest.push("extra-arg".to_string());
cases.push(case(
format!("too many values for {shown}"),
rest,
strings(&["extra-arg"]),
));
}
}
}
}
cases
}
fn assert_whole(error: &str, what: &str) {
assert!(!error.is_empty(), "{what}: empty error");
assert!(
!error.ends_with(':'),
"{what}: cut after a colon: {error:?}"
);
for noise in ["Usage:", "For more information", "tip:"] {
assert!(!error.contains(noise), "{what}: {noise:?} in {error:?}");
}
}
#[test]
fn every_parser_error_names_what_is_wrong() {
let cases = corpus();
assert!(cases.len() >= 160, "only {} cases", cases.len());
let refused = cases
.iter()
.filter(|c| c.what.contains("refused value"))
.count();
assert_eq!(refused, 13, "refused-value cases");
for case in &cases {
let what = &case.what;
let out = proc_cmd().args(&case.args).output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(4), "{what}: {stderr}");
assert!(out.stdout.is_empty(), "{what}: stdout is not empty");
for needle in &case.needles {
assert!(
stderr.contains(needle),
"{what}: {needle:?} not in {stderr}"
);
}
if !case.json {
continue;
}
let out = proc_cmd().args(&case.args).arg("--json").output().unwrap();
assert_eq!(out.status.code(), Some(4), "{what}");
let stdout = String::from_utf8_lossy(&out.stdout);
let json: Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("{what}: not one JSON document ({e}): {stdout}"));
assert_eq!(json["reason"], "invalid_input", "{what}: {json}");
assert_eq!(json["exit_code"], 4, "{what}: {json}");
assert_eq!(json["success"], false, "{what}: {json}");
let error = json["error"].as_str().expect("error is text");
assert_whole(error, what);
for needle in &case.needles {
assert!(
error.contains(needle),
"{what}: {needle:?} not in {error:?}"
);
}
}
}
#[test]
fn the_corpus_covers_the_shapes_the_bugs_had() {
let cases = corpus();
for (what, needle) in [
("kill: missing required arguments", "TARGET"),
("list: bad value for --sort", "cpu"),
("list: bad value for --status", "zombie"),
("list: missing value for --sort", "--sort"),
] {
assert!(
cases
.iter()
.any(|c| c.what == what && c.needles.iter().any(|n| n == needle)),
"no case {what:?} with {needle:?}"
);
}
}
fn json_error(args: &[&str]) -> String {
let out = proc_cmd().args(args).output().unwrap();
assert_eq!(out.status.code(), Some(4));
let json: Value = serde_json::from_slice(&out.stdout).unwrap();
json["error"].as_str().unwrap().to_string()
}
#[test]
fn the_json_error_keeps_the_missing_argument_and_the_allowed_values() {
assert_eq!(
json_error(&["kill", "-j"]),
"the following required arguments were not provided: <TARGET>..."
);
assert_eq!(
json_error(&["list", "--sort", "bogus", "-j"]),
"invalid value 'bogus' for '--sort <SORT>' [possible values: cpu, mem, pid, name]"
);
assert_eq!(
json_error(&["list", "--status", "bogus", "-j"]),
"invalid value 'bogus' for '--status <STATUS>' \
[possible values: running, sleeping, stopped, zombie]"
);
assert_eq!(
json_error(&["kill", "123", "--jsn", "-j"]),
"unexpected argument '--jsn' found\n Try: did you mean '--json'?"
);
}
const HINT_BY: &str =
"kill needs at least one target; --by filters the targets, it doesn't select them";
const HINT_IN: &str =
"kill needs at least one target; --in filters the targets, it doesn't select them";
#[test]
fn a_filter_without_a_target_says_the_filter_does_not_select() {
for (args, hint) in [
(&["kill", "--by", "wispworker"][..], HINT_BY),
(&["kill", "--by=wispworker"], HINT_BY),
(&["kill", "-b", "wispworker"], HINT_BY),
(&["kill", "-yb", "wispworker"], HINT_BY),
(&["kill", "--in"], HINT_IN),
(&["kill", "--in=."], HINT_IN),
(&["kill", "-i"], HINT_IN),
(&["k", "--in", "."], HINT_IN),
] {
let out = proc_cmd().args(args).output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(4), "{args:?}");
assert!(stderr.contains(hint), "{args:?}: {stderr}");
assert!(stderr.contains("Usage:"), "{args:?}: {stderr}");
let mut json_args = args.to_vec();
json_args.push("--json");
let error = json_error(&json_args);
assert!(
error.starts_with("the following required arguments were not provided: <TARGET>..."),
"{args:?}: {error}"
);
assert!(error.ends_with(hint), "{args:?}: {error}");
}
let error = json_error(&["wait", "-t", "5", "-b", "x", "-j"]);
assert!(
error.ends_with(
"wait needs at least one target; --by filters the targets, it doesn't select them"
),
"{error}"
);
}
#[test]
fn no_hint_where_no_filter_was_given_or_no_target_is_missing() {
assert!(!json_error(&["kill", "-j"]).contains("filters"));
assert!(!json_error(&["in", "--by", "x", "-j"]).contains("filters the targets"));
assert!(!json_error(&["kill", "--by", "x", "--bogus", "-j"]).contains("filters the targets"));
}
#[cfg(unix)]
#[test]
fn a_non_utf8_argument_is_invalid_input_not_a_panic() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let odd = OsStr::from_bytes(b"\xff");
let odd_path = OsStr::from_bytes(b"/tmp/\xff");
let odd_in = OsStr::from_bytes(b"--in=\xff");
for args in [
vec![OsStr::new("kill"), odd],
vec![OsStr::new("list"), odd],
vec![OsStr::new("in"), odd_path],
vec![OsStr::new("list"), OsStr::new("--in"), odd],
vec![OsStr::new("list"), odd_in],
vec![OsStr::new("list"), OsStr::new("--by"), odd],
] {
let out = proc_cmd().args(&args).output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(4), "{args:?}: {stderr}");
assert!(out.stdout.is_empty(), "{args:?}");
assert!(stderr.contains("is not valid UTF-8"), "{args:?}: {stderr}");
assert!(stderr.contains('\u{fffd}'), "{args:?}: {stderr}");
assert!(!stderr.contains("panicked"), "{args:?}: {stderr}");
let out = proc_cmd().args(&args).arg("--json").output().unwrap();
assert_eq!(out.status.code(), Some(4), "{args:?}");
let json: Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(json["reason"], "invalid_input", "{json}");
assert_eq!(json["action"], args[0].to_str().unwrap(), "{json}");
let error = json["error"].as_str().unwrap();
assert!(error.contains("is not valid UTF-8"), "{json}");
assert!(error.contains('\u{fffd}'), "{json}");
}
}
#[test]
fn a_typo_gets_the_closest_names() {
for (args, want) in [
(&["kil", "1"][..], "did you mean 'kill'?"),
(&["fre", ":3000"], "did you mean 'free' or 'freeze'"),
(&["list", "--sort", "cp"], "did you mean 'cpu'?"),
] {
let mut json_args = args.to_vec();
json_args.push("--json");
let error = json_error(&json_args);
assert!(
error.contains(&format!("\n Try: {want}")),
"{args:?}: {error:?}"
);
assert!(!error.contains("'k'"), "{args:?}: an alias: {error:?}");
let out = proc_cmd().args(args).output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(4), "{args:?}: {stderr}");
assert!(
stderr.contains(&format!("tip: {want}")),
"{args:?}: {stderr}"
);
assert!(!stderr.contains("\n\n\n"), "{args:?}: {stderr:?}");
assert_eq!(stderr.matches("tip:").count(), 1, "{args:?}: {stderr}");
}
}
#[test]
fn a_typed_value_cannot_cut_the_message_or_make_a_suggestion() {
let error = json_error(&["kill", ":99999\nUsage: x", "--json"]);
assert!(error.contains("is not a port"), "{error:?}");
let error = json_error(&[
"kill",
":99999\n\n tip: a similar value exists: 'x'' and more",
"--json",
]);
assert!(!error.contains("Try:"), "{error:?}");
}