tests/alias_test.rs
Ref: Size: 8.6 KiB History
//! Verb aliases across the command surface.
//!
//! The cost of a wrong verb guess is not the guess: it is that clap writes
//! usage text to stderr, exits 2, and a script that captured the output keeps
//! going with usage text where an id should be. Every alias here exists so a
//! plausible guess *succeeds* rather than failing several steps downstream.
//!
//! These tests assert at the parser: an alias must resolve to the same
//! subcommand, with the same fields, as the canonical spelling. Comparing the
//! parsed value rather than the exit status is what makes "resolves to the
//! same subcommand" a real claim.
use clap::Parser;
use git_collab::cli::Cli;
/// Parse an argv and render the resulting command for comparison.
fn parse(args: &[&str]) -> String {
let mut argv = vec!["git-collab"];
argv.extend_from_slice(args);
let cli =
Cli::try_parse_from(&argv).unwrap_or_else(|e| panic!("failed to parse {:?}: {}", args, e));
format!("{:?}", cli.command)
}
/// The alias and the canonical spelling must produce an identical command.
#[track_caller]
fn assert_same(alias: &[&str], canonical: &[&str]) {
assert_eq!(
parse(alias),
parse(canonical),
"{:?} should resolve to the same command as {:?}",
alias,
canonical
);
}
// ---------------------------------------------------------------------------
// The three asymmetries named in the issue
// ---------------------------------------------------------------------------
#[test]
fn issue_create_is_issue_open() {
assert_same(
&["issue", "create", "-t", "title", "-b", "body"],
&["issue", "open", "-t", "title", "-b", "body"],
);
}
#[test]
fn patch_open_is_patch_create() {
assert_same(
&["patch", "open", "-t", "title", "-b", "body"],
&["patch", "create", "-t", "title", "-b", "body"],
);
}
#[test]
fn keys_is_key() {
assert_same(&["keys", "list"], &["key", "list"]);
assert_same(&["keys", "add", "--self"], &["key", "add", "--self"]);
assert_same(&["keys", "remove", "abc"], &["key", "remove", "abc"]);
}
/// Key *generation* lives at the top level as `init-key` while key
/// *management* lives under `key`. Someone who found `key add` will look for
/// `key generate` next, so it has to be there.
#[test]
fn key_generate_reaches_the_same_generator_as_init_key() {
// A distinct variant by necessity — clap aliases cannot cross enums — so
// this asserts the routing rather than the parse.
use git_collab::cli::{Commands, KeyCmd};
let generated = Cli::try_parse_from(["git-collab", "key", "generate", "--force"]).unwrap();
assert!(matches!(
generated.command,
Commands::Key(KeyCmd::Generate { force: true, .. })
));
assert_same(&["key", "init"], &["key", "generate"]);
assert_same(&["key", "init-key"], &["key", "generate"]);
}
// ---------------------------------------------------------------------------
// The same asymmetries elsewhere on the surface
// ---------------------------------------------------------------------------
#[test]
fn new_is_a_synonym_for_open_and_create() {
assert_same(&["issue", "new", "-t", "t"], &["issue", "open", "-t", "t"]);
assert_same(
&["patch", "new", "-t", "t"],
&["patch", "create", "-t", "t"],
);
}
#[test]
fn ls_is_list_everywhere_there_is_a_list() {
assert_same(&["issue", "ls"], &["issue", "list"]);
assert_same(&["patch", "ls"], &["patch", "list"]);
assert_same(&["key", "ls"], &["key", "list"]);
assert_same(&["release", "ls"], &["release", "list"]);
assert_same(&["identity", "ls"], &["identity", "list"]);
}
/// Issues and patches spell removal `delete`; trusted keys spell it `remove`.
/// Both spellings, plus `rm`, work wherever removal exists.
#[test]
fn delete_remove_and_rm_are_interchangeable() {
assert_same(&["issue", "remove", "abc"], &["issue", "delete", "abc"]);
assert_same(&["issue", "rm", "abc"], &["issue", "delete", "abc"]);
assert_same(&["patch", "remove", "abc"], &["patch", "delete", "abc"]);
assert_same(&["patch", "rm", "abc"], &["patch", "delete", "abc"]);
assert_same(&["key", "delete", "abc"], &["key", "remove", "abc"]);
assert_same(&["key", "rm", "abc"], &["key", "remove", "abc"]);
assert_same(&["release", "rm", "v1"], &["release", "delete", "v1"]);
assert_same(&["release", "remove", "v1"], &["release", "delete", "v1"]);
}
#[test]
fn view_and_info_are_show() {
assert_same(&["issue", "view", "abc"], &["issue", "show", "abc"]);
assert_same(&["issue", "info", "abc"], &["issue", "show", "abc"]);
assert_same(&["patch", "view", "abc"], &["patch", "show", "abc"]);
assert_same(&["patch", "info", "abc"], &["patch", "show", "abc"]);
}
/// `hooks` is the only plural noun at the top level; `hook` must work too,
/// for the same reason `keys` must.
#[test]
fn hook_is_hooks() {
assert_same(&["hook", "status"], &["hooks", "status"]);
assert_same(&["hook", "install"], &["hooks", "install"]);
}
#[test]
fn init_key_answers_to_the_obvious_guesses() {
assert_same(&["keygen"], &["init-key"]);
assert_same(&["generate-key"], &["init-key"]);
}
#[test]
fn patch_update_is_patch_revise() {
assert_same(&["patch", "update", "abc"], &["patch", "revise", "abc"]);
}
#[test]
fn patch_co_is_patch_checkout() {
assert_same(&["patch", "co", "abc"], &["patch", "checkout", "abc"]);
}
#[test]
fn release_upload_and_create_are_release_publish() {
assert_same(
&["release", "upload", "v1", "f"],
&["release", "publish", "v1", "f"],
);
assert_same(
&["release", "create", "v1", "f"],
&["release", "publish", "v1", "f"],
);
}
/// `identity alias`/`unalias` are the odd verbs out: every other collection on
/// the surface is managed with add/remove.
#[test]
fn identity_add_and_remove_are_alias_and_unalias() {
assert_same(&["identity", "add", "a@b"], &["identity", "alias", "a@b"]);
assert_same(
&["identity", "remove", "a@b"],
&["identity", "unalias", "a@b"],
);
assert_same(&["identity", "rm", "a@b"], &["identity", "unalias", "a@b"]);
}
#[test]
fn find_and_grep_are_search() {
assert_same(&["find", "needle"], &["search", "needle"]);
assert_same(&["grep", "needle"], &["search", "needle"]);
}
// ---------------------------------------------------------------------------
// Aliases must not shadow anything, and must stay out of the advertised help.
// ---------------------------------------------------------------------------
/// An alias that collided with a real subcommand name would silently reroute a
/// correct invocation, which is worse than the problem being fixed. clap
/// panics on a duplicate, so building the command at all proves the point —
/// but assert it explicitly so the reason is on the record.
#[test]
fn the_command_tree_builds_without_alias_collisions() {
use clap::CommandFactory;
let cmd = Cli::command();
check_no_duplicate_names(&cmd);
}
fn check_no_duplicate_names(cmd: &clap::Command) {
let mut seen: Vec<String> = Vec::new();
for sub in cmd.get_subcommands() {
for name in std::iter::once(sub.get_name()).chain(sub.get_all_aliases()) {
assert!(
!seen.contains(&name.to_string()),
"'{}' appears twice under '{}'",
name,
cmd.get_name()
);
seen.push(name.to_string());
}
check_no_duplicate_names(sub);
}
}
/// Aliases are a safety net for wrong guesses, not a second vocabulary to
/// learn: `--help` must keep advertising exactly one spelling per command.
#[test]
fn every_alias_is_hidden_from_help() {
use clap::CommandFactory;
check_aliases_hidden(&Cli::command());
}
fn check_aliases_hidden(cmd: &clap::Command) {
for sub in cmd.get_subcommands() {
let visible: Vec<&str> = sub.get_visible_aliases().collect();
assert!(
visible.is_empty(),
"'{} {}' advertises aliases {:?}; aliases should be hidden",
cmd.get_name(),
sub.get_name(),
visible
);
check_aliases_hidden(sub);
}
}
/// …and the canonical spellings must still be advertised, so hiding aliases
/// has not accidentally hidden a command.
#[test]
fn canonical_commands_are_still_listed_in_help() {
let mut cmd = <Cli as clap::CommandFactory>::command();
let help = cmd.render_long_help().to_string();
for advertised in ["issue", "patch", "release", "key", "status", "search"] {
assert!(
help.lines()
.any(|l| l.trim_start().starts_with(&format!("{} ", advertised))),
"help should still list '{}':\n{}",
advertised,
help
);
}
}