a73x

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)
        );
    }
}