use std::process::Command;
const MAX_HELP_EXAMPLES: usize = 3;
#[test]
fn no_arguments_prints_help_successfully() {
let bare = Command::new(env!("CARGO_BIN_EXE_canic"))
.output()
.expect("run bare canic");
let help = Command::new(env!("CARGO_BIN_EXE_canic"))
.arg("--help")
.output()
.expect("run canic help");
assert!(bare.status.success());
assert!(help.status.success());
assert!(bare.stderr.is_empty());
assert!(help.stderr.is_empty());
assert!(!bare.stdout.is_empty());
assert_eq!(bare.stdout, help.stdout);
}
#[test]
fn command_help_is_ordered_and_concise() {
check_command_group(&[]);
}
fn check_command_group(path: &[String]) {
assert!(
path.len() < 8,
"unexpectedly deep command tree at `canic {}`",
path.join(" ")
);
let help = command_help(path);
assert!(
help_usage_matches(path, &help),
"help belongs to another command: {path:?}\n{help}"
);
let examples = example_commands(&help);
assert!(
examples.len() <= MAX_HELP_EXAMPLES,
"`canic {} --help` has {} examples; expected at most {MAX_HELP_EXAMPLES}\n\n{help}",
path.join(" "),
examples.len()
);
let subcommands = functional_subcommands(&help);
let mut expected = subcommands.clone();
expected.sort_unstable();
assert_eq!(
subcommands,
expected,
"functional subcommands are not alphabetical for `canic {}`\n\n{help}",
path.join(" ")
);
for subcommand in subcommands {
let mut child = path.to_vec();
child.push(subcommand);
check_command_group(&child);
}
}
fn example_commands(help: &str) -> Vec<&str> {
let mut in_examples = false;
let mut examples = Vec::new();
for line in help.lines() {
if line.trim() == "Examples:" {
in_examples = true;
continue;
}
if !in_examples || line.trim().is_empty() {
continue;
}
if !line.starts_with(char::is_whitespace) {
break;
}
if line.trim_start().starts_with("canic ") {
examples.push(line.trim());
}
}
examples
}
fn command_help(path: &[String]) -> String {
let output = Command::new(env!("CARGO_BIN_EXE_canic"))
.args(path)
.arg("--help")
.output()
.expect("run canic help");
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(output.status.success(), "help failed for {path:?}: {text}");
assert!(
output.stderr.is_empty(),
"help wrote to stderr for {path:?}: {text}"
);
strip_ansi(&text)
}
fn help_usage_matches(path: &[String], help: &str) -> bool {
if path.is_empty() {
return true;
}
let prefix = format!("Usage: canic {}", path.join(" "));
help.lines().any(|line| {
line.strip_prefix(&prefix)
.is_some_and(|suffix| suffix.is_empty() || suffix.starts_with(char::is_whitespace))
})
}
fn functional_subcommands(help: &str) -> Vec<String> {
let mut in_commands = false;
let mut subcommands = Vec::new();
for line in help.lines() {
if line.trim() == "Commands:" {
in_commands = true;
continue;
}
if !in_commands {
continue;
}
if line.trim().is_empty() {
continue;
}
let indentation = line.len() - line.trim_start().len();
if indentation == 0 {
break;
}
if indentation != 2 {
continue;
}
let name = line
.split_whitespace()
.next()
.expect("indented command line has a name");
if name != "help" {
subcommands.push(name.to_string());
}
}
subcommands
}
fn strip_ansi(text: &str) -> String {
let mut plain = String::new();
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' && chars.peek() == Some(&'[') {
chars.next();
for ch in chars.by_ref() {
if ch == 'm' {
break;
}
}
} else {
plain.push(ch);
}
}
plain
}