tests/cli_surface_test.rs
Ref: Size: 12.7 KiB History
//! Every command this project tells someone to run must be a command that
//! exists.
//!
//! This is a lint, not a behavioural test. It reads the *source text* rather
//! than running anything, because the bug it exists to catch is a typo in a
//! string literal, and a typo in a string literal is only observable at
//! runtime if that particular error path happens to be exercised. Both real
//! instances reached a user before any test did:
//!
//! - `bf187d71` — a push diagnostic said `` `collab sync --remote X` ``.
//! - `a18e9b76` — the keyless-clone trust warning said `'collab key add
//! --self'`, on a security-related instruction, where the reader has the
//! least ability to notice that the command is wrong.
//!
//! The oracle is clap's own command tree, not a list maintained here. A list
//! would need updating in the same commit that adds a subcommand, which is
//! exactly the discipline that failed twice; asking clap means the test cannot
//! disagree with the binary. `clap_mangen` generates the man pages from the
//! same tree, so those are correct by construction and only their prose — the
//! doc comments in `src/cli.rs` — needs scanning, which happens below with
//! every other source file.
//!
//! # What counts as a citation
//!
//! Only *delimited* text: a backtick or single-quote run opening with the
//! binary name. That is how this codebase writes commands, and the delimiter
//! is what keeps the scan free of false positives — undelimited prose is full
//! of phrases like "the collab refs" and "a collab id" that are not commands
//! at all. README fenced-block lines starting with `$ ` count too, since the
//! quickstart is the most-copied text the project has.
//!
//! `{}` at the head of a citation is read as the binary name, so building a
//! message from [`git_collab::BINARY_NAME`] does not hide it from this scan.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use clap::CommandFactory;
use git_collab::cli::Cli;
// ---------------------------------------------------------------------------
// Which files
// ---------------------------------------------------------------------------
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
/// Every file whose strings can reach a user: all of `src/` (message literals,
/// clap `about` text, and the doc comments the man pages are rendered from)
/// plus the README.
///
/// `tests/` is deliberately excluded: a regression test's job is to assert the
/// wrong spelling is *absent*, so it has to contain the wrong spelling.
fn scanned_files() -> Vec<PathBuf> {
let root = repo_root();
let mut files = vec![root.join("README.md")];
collect_rs(&root.join("src"), &mut files);
files.sort();
assert!(
files.len() > 20,
"file walk found only {} files — the scan is not reaching src/",
files.len()
);
files
}
fn collect_rs(dir: &Path, out: &mut Vec<PathBuf>) {
let entries =
std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read {}: {}", dir.display(), e));
for entry in entries {
let path = entry.unwrap().path();
if path.is_dir() {
collect_rs(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
// ---------------------------------------------------------------------------
// Finding citations
// ---------------------------------------------------------------------------
/// One place the source text tells a reader to run something.
#[derive(Debug)]
struct Citation {
file: PathBuf,
line_no: usize,
/// The text after the binary name, up to the closing delimiter or the end
/// of the source line — whichever comes first. Ending at the line is fine
/// and intentional: the subcommand path always sits immediately after the
/// binary name, so a citation split across a Rust string continuation
/// still has everything this test reads on its first line.
rest: String,
/// The whole citation, for the failure message.
full: String,
}
/// The spellings that open a command citation, longest first so `git-collab`
/// is never matched as a bare `collab` with a `git-` prefix left behind.
const BINARY_HEADS: [&str; 3] = ["git-collab ", "{} ", "collab "];
const DELIMITERS: [char; 2] = ['`', '\''];
fn citations_in(file: &Path) -> (Vec<Citation>, Vec<Citation>) {
let text =
std::fs::read_to_string(file).unwrap_or_else(|e| panic!("read {}: {}", file.display(), e));
let mut good = Vec::new();
let mut wrong_binary = Vec::new();
for (i, line) in text.lines().enumerate() {
for (start, head, delim) in openings(line) {
let after_head = &line[start + head.len()..];
let rest = match delim {
Some(d) => after_head.split(d).next().unwrap_or(after_head),
// A `$ ` shell line in the README runs to end of line.
None => after_head,
};
let citation = Citation {
file: file.to_path_buf(),
line_no: i + 1,
rest: rest.to_string(),
full: format!("{}{}", head, rest),
};
if head == "collab " {
wrong_binary.push(citation);
} else {
good.push(citation);
}
}
}
(good, wrong_binary)
}
/// Byte offsets in `line` where a command citation opens, with the binary
/// spelling used and the delimiter that will close it (`None` for a README
/// `$ ` shell line, which closes at the newline).
fn openings(line: &str) -> Vec<(usize, &'static str, Option<char>)> {
let mut found = Vec::new();
// Delimited: a backtick or quote immediately followed by a binary name.
for (idx, ch) in line.char_indices() {
if !DELIMITERS.contains(&ch) {
continue;
}
let after = &line[idx + ch.len_utf8()..];
if let Some(head) = BINARY_HEADS.iter().find(|h| after.starts_with(**h)) {
found.push((idx + ch.len_utf8(), *head, Some(ch)));
}
}
// A README shell-prompt line. `{}` is not a shell thing, so only the two
// real spellings are looked for here.
let trimmed = line.trim_start();
if let Some(cmd) = trimmed.strip_prefix("$ ") {
if let Some(head) = ["git-collab ", "collab "]
.iter()
.find(|h| cmd.starts_with(**h))
{
let offset = line.len() - cmd.len();
found.push((offset, *head, None));
}
}
found
}
// ---------------------------------------------------------------------------
// The oracle: clap's own tree
// ---------------------------------------------------------------------------
/// Walk `rest` down the command tree, returning an error string if some token
/// names a subcommand that does not exist.
///
/// Descent stops at the first command with no subcommands of its own: from
/// there on every token is a positional argument, and `git-collab issue close
/// a1b2c3d4` must not be read as an `a1b2c3d4` subcommand of `close`.
fn check_path(rest: &str, root: &clap::Command) -> Result<(), String> {
let mut cmd = root;
let mut walked: Vec<String> = Vec::new();
for token in rest.split_whitespace() {
if cmd.get_subcommands().next().is_none() {
break;
}
if !is_subcommand_shaped(token) {
break;
}
match find_sub(cmd, token) {
Some(sub) => {
walked.push(token.to_string());
cmd = sub;
}
None => {
let mut names: BTreeSet<&str> =
cmd.get_subcommands().map(|s| s.get_name()).collect();
names.remove("help");
let under = if walked.is_empty() {
"git-collab".to_string()
} else {
format!("git-collab {}", walked.join(" "))
};
return Err(format!(
"`{}` has no subcommand `{}` (it has: {})",
under,
token,
names.into_iter().collect::<Vec<_>>().join(", ")
));
}
}
}
Ok(())
}
fn find_sub<'a>(cmd: &'a clap::Command, token: &str) -> Option<&'a clap::Command> {
cmd.get_subcommands()
.find(|s| s.get_name() == token || s.get_all_aliases().any(|a| a == token))
}
/// Whether a token could be a subcommand name at all. Flags, format
/// placeholders, shell metacharacters and `<PLACEHOLDER>`s all end the walk.
fn is_subcommand_shaped(token: &str) -> bool {
!token.is_empty()
&& token
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& !token.starts_with('-')
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
/// The exact defect from a18e9b76 and bf187d71: the binary is `git-collab`,
/// and no message may call it `collab`.
#[test]
fn no_user_facing_string_calls_the_binary_collab() {
let mut offences = Vec::new();
for file in scanned_files() {
let (_, wrong) = citations_in(&file);
for c in wrong {
offences.push(format!(
"{}:{}: `{}` — the binary is `git-collab`",
c.file.display(),
c.line_no,
c.full.trim_end()
));
}
}
assert!(
offences.is_empty(),
"user-facing text names a binary that does not exist:\n {}",
offences.join("\n ")
);
}
/// Every command citation names a subcommand path that clap actually has.
#[test]
fn every_cited_command_exists() {
let root = Cli::command();
let mut offences = Vec::new();
for file in scanned_files() {
let (cited, _) = citations_in(&file);
for c in cited {
if let Err(why) = check_path(&c.rest, &root) {
offences.push(format!("{}:{}: {}", c.file.display(), c.line_no, why));
}
}
}
assert!(
offences.is_empty(),
"user-facing text cites a command that does not exist:\n {}",
offences.join("\n ")
);
}
/// The scan is worthless if it matches nothing, and a refactor that moved
/// every message into a format argument would silently empty it. Assert it
/// still has real work to do.
#[test]
fn the_scan_actually_finds_commands() {
let root = Cli::command();
let mut total = 0;
let mut distinct: BTreeSet<String> = BTreeSet::new();
for file in scanned_files() {
let (cited, _) = citations_in(&file);
for c in cited {
total += 1;
if let Some(first) = c.rest.split_whitespace().next() {
if find_sub(&root, first).is_some() {
distinct.insert(first.to_string());
}
}
}
}
assert!(
total >= 30,
"only {} command citations found — the scanner has stopped matching",
total
);
assert!(
distinct.len() >= 8,
"citations only cover {} distinct subcommands: {:?}",
distinct.len(),
distinct
);
}
/// The lint has to be able to fail. Feeding it the two spellings that actually
/// shipped proves it is checking something, rather than passing because the
/// walk breaks out early on every input.
#[test]
fn the_lint_rejects_the_spellings_that_shipped() {
let root = Cli::command();
// bf187d71 and a18e9b76, as the citation scanner would see them.
for wrong in ["`collab sync --remote origin`", "'collab key add --self'"] {
let (good, bad) = openings(wrong)
.into_iter()
.partition::<Vec<_>, _>(|(_, head, _)| *head != "collab ");
assert!(
good.is_empty() && bad.len() == 1,
"scanner did not flag the wrong binary name in {:?}",
wrong
);
}
// A subcommand that does not exist, under a group that does.
assert!(
check_path("key trust --self", &root).is_err(),
"lint accepted `git-collab key trust`, which does not exist"
);
assert!(
check_path("frobnicate", &root).is_err(),
"lint accepted a top-level subcommand that does not exist"
);
// And the real ones still pass, including a positional that looks like a
// word and a hidden alias.
for ok in [
"key add --self",
"sync --remote origin",
"patch merge <id>",
"issue close a1b2c3d4",
"patch diff {} --revision {}",
"hooks install",
] {
assert!(
check_path(ok, &root).is_ok(),
"lint rejected the real command `git-collab {}`: {:?}",
ok,
check_path(ok, &root)
);
}
}