diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 31f6aea3b..0248b7101 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -133,7 +133,7 @@ jobs: matrix: include: - version: "1.91" - crates: usage-argv usage-derive usage-config usage-validation usage-rs + crates: usage-argv usage-derive usage-config usage-validation usage-rs usage-test - version: "1.95" crates: usage-lib usage-config-build clap_usage usage-cli steps: diff --git a/Cargo.lock b/Cargo.lock index 56c2a5dd5..51227f693 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2541,9 +2541,17 @@ dependencies = [ "usage-argv", "usage-derive", "usage-lib", + "usage-test", "usage-validation", ] +[[package]] +name = "usage-test" +version = "5.1.0" +dependencies = [ + "usage-argv", +] + [[package]] name = "usage-validation" version = "5.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3036efb51..77184f041 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "config", "config-build", "derive", + "test", "usage-rs", "validation", "clap_usage", @@ -47,6 +48,7 @@ usage-config = { path = "./config", version = "5.1.0" } usage-derive = { path = "./derive", version = "5.1.0" } usage-lib = { path = "./lib", version = "5.1.0", features = ["clap", "validation"] } usage-rs = { path = "./usage-rs", version = "5.1.0" } +usage-test = { path = "./test", version = "5.1.0" } usage-validation = { path = "./validation", version = "5.1.0" } [workspace.metadata.release] diff --git a/PLAN.md b/PLAN.md index 80ece63f9..62917747a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -333,6 +333,27 @@ tasks --usage"`, so task names are meant to come from running that. usage-argv d already failed, and says what clap would have said, colour included, down to suggesting what was probably meant. `parse()` renders and exits the way a program does; `parse_from` hands the error back. +- [x] **A test harness for an adopter's own suite** — `usage-test`, reached as + `usage::test` behind a dev-dependency feature. A CLI's observable surface is + three things, and all three were reachable but unpleasant: `parse_from` + wants a `&[&OsStr]` a test cannot write as a literal and hands back a + compact code rather than the message a user reads; a help page needed a + route rebuilt by hand; and a completion answer needed `split` and + `candidates` assembled per test. Now `outcome` returns what `parse()` would + have _done_ — a struct, a page, a version, or a failure, each with the + stream and status it would have used — `help`/`help_tree` render one page or + the whole tree by the path a user types, and `candidates`/`completion` + answer a half-typed line. **Nothing in it formats a page**, which is the + rule that makes it worth having: every page comes from + `usage_argv::help::page` and every failure from `render_failure`, the same + functions the process calls, so a passing test is a statement about what + users see rather than about a second renderer that happens to agree today. + That function is the other half of the change: which page a help request + becomes — short, long, recursive, by route or by address, view or not — was + ~150 lines emitted into every derive three times over, and is now decided + once in usage-argv and called from both places. A facade test asserts the two + halves agree: the page `help(spec, &["build"], Page::Long)` renders is + byte-for-byte the one `ex build --help` produces. ### What clap can say that we cannot diff --git a/argv/src/help.rs b/argv/src/help.rs index 080010dc5..31927c84a 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -2533,6 +2533,80 @@ pub fn render_all_view_at_styled( Some(recursive_help(&viewed, path, chain, style, true)) } +/// Which page a help request asks for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Page { + /// `-h`: the short page for one command. + Short, + /// `--help`: the long page for one command. + Long, + /// [`ArgAction::HelpAll`](crate::ArgAction::HelpAll): the long page for the command and + /// every visible descendant. + All, +} + +/// The page a help request becomes, by the route the words took. +/// +/// The parser reports the command a request arrived at, but a page is about the route that +/// reached it: one `Subcommands` type mounted under two parents is one address, and a page +/// found by searching for that address carries the first mount's path and globals. Falls back +/// to rendering by address where the route cannot be rebuilt, which only a command from +/// another CLI's tables can reach. +/// +/// One function rather than a shape each caller reassembles. `parse()` renders a request this +/// way, and so does anything that wants to know what a command line would have printed +/// without running the program — a test harness, most of all, since a page it renders +/// differently from the process is a page that proves nothing. +pub fn page( + spec: &Spec<'_>, + root: &Command<'_>, + argv: &[&std::ffi::OsStr], + cmd: &Command<'_>, + page: Page, + style: Style, +) -> Option { + match route_to(root, argv, cmd) { + Some(route) => match page { + Page::Short => render_at_styled(spec, &route, false, style), + Page::Long => render_at_styled(spec, &route, true, style), + Page::All => render_all_at_styled(spec, &route, style), + }, + None => match page { + Page::Short => render_styled(spec, cmd, false, style), + Page::Long => render_styled(spec, cmd, true, style), + Page::All => render_all_styled(spec, cmd, style), + }, + } +} + +/// The page a help request becomes when a declared executable view is what the user invoked. +/// +/// `argv` includes argv0 here, as [`route_to_view`] requires: the view's own name is what +/// selected it. The fallback is the canonical page, which is better than nothing where the +/// route cannot be rebuilt. +pub fn page_view( + spec: &Spec<'_>, + root: &Command<'_>, + argv: &[&std::ffi::OsStr], + cmd: &Command<'_>, + view: &ViewMeta<'_>, + page: Page, + style: Style, +) -> Option { + match route_to_view(root, argv, cmd, view) { + Some(route) => match page { + Page::Short => render_view_at_styled(spec, &route, view, false, style), + Page::Long => render_view_at_styled(spec, &route, view, true, style), + Page::All => render_all_view_at_styled(spec, &route, view, style), + }, + None => match page { + Page::Short => render_styled(spec, cmd, false, style), + Page::Long => render_styled(spec, cmd, true, style), + Page::All => render_all_styled(spec, cmd, style), + }, + } +} + pub(crate) fn view_root_flags<'a>( spec: &'a Spec<'a>, promoted: &CommandMeta<'a>, diff --git a/argv/src/lib.rs b/argv/src/lib.rs index 59dce52c0..4b4ccd8b1 100644 --- a/argv/src/lib.rs +++ b/argv/src/lib.rs @@ -979,6 +979,21 @@ pub fn render_failure(spec: &spec::Spec<'_>, argv: &[&OsStr], error: &Error<'_, diagnostic::render(spec, argv, error, diagnostic::Style::auto()) } +/// A parse failure, never coloured. +/// +/// [`render_failure`] asks the environment whether to colour, which is right for a process and +/// wrong for anything that keeps the string: a test that asserts on a message, or a snapshot of +/// one, would pass or fail by whether stderr happened to be a terminal. The renderer is the +/// same; only the answer to that question is fixed. +#[cfg(feature = "diagnostics")] +pub fn render_failure_plain( + spec: &spec::Spec<'_>, + argv: &[&OsStr], + error: &Error<'_, '_>, +) -> String { + diagnostic::render(spec, argv, error, diagnostic::Style::PLAIN) +} + /// Render a failure through a spec-declared executable view. /// /// `argv` is the original full argv, including the view executable as argv0. @@ -1002,6 +1017,16 @@ pub fn render_failure(spec: &spec::Spec<'_>, argv: &[&OsStr], error: &Error<'_, ::std::format!("error: {error:?}\n") } +/// A parse failure without the renderer, which is plain either way. +#[cfg(all(feature = "spec", not(feature = "diagnostics")))] +pub fn render_failure_plain( + spec: &spec::Spec<'_>, + argv: &[&OsStr], + error: &Error<'_, '_>, +) -> String { + render_failure(spec, argv, error) +} + /// Render a failure through a declared view without the optional diagnostics renderer. #[cfg(all(feature = "spec", not(feature = "diagnostics")))] pub fn render_failure_view( diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 7cd2a6c7c..fe09ffd7f 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -477,6 +477,36 @@ pub fn emit(cli: &Cli) -> TokenStream { let __usage_spec = Self::spec(); } }; + + // One renderer for every help request. Which page a request becomes — and whether it is the + // route the words took or a fallback by address — is decided once, in usage-argv, rather + // than three times here in code nobody reads until it is wrong. It is also what lets a test + // harness render the page this program would have printed rather than one of its own. + let page_of = |style: TokenStream| { + quote! { + let __usage_page = match __usage_selected_view { + ::std::option::Option::Some(view) => usage_argv::help::page_view( + __usage_spec, + Self::command(), + &__usage_all_refs, + cmd, + view, + __usage_want, + #style, + ), + ::std::option::Option::None => usage_argv::help::page( + __usage_spec, + Self::command(), + &__usage_argv, + cmd, + __usage_want, + #style, + ), + }; + } + }; + let render_page = page_of(quote!(usage_argv::help::Style::auto())); + let render_page_stderr = page_of(quote!(usage_argv::help::Style::auto_stderr())); let runtime_program = cli .runtime_bin .as_ref() @@ -987,50 +1017,12 @@ pub fn emit(cli: &Cli) -> TokenStream { } ::std::result::Result::Err(usage_argv::Error::Help { cmd, long }) => { #effective_spec - // By the route the words took, not by the command's address: one - // `Subcommands` type mounted under two parents is one address, and a - // page found by searching for it carries the first mount's path and - // globals. Falls back where the route cannot be rebuilt. - let __usage_route = match __usage_selected_view { - ::std::option::Option::Some(view) => - usage_argv::help::route_to_view( - Self::command(), &__usage_all_refs, cmd, view, - ), - ::std::option::Option::None => usage_argv::help::route_to( - Self::command(), &__usage_argv, cmd, - ), - }; - let __usage_page = match __usage_route { - ::std::option::Option::Some(route) => { - match __usage_selected_view { - ::std::option::Option::Some(view) => { - usage_argv::help::render_view_at_styled( - __usage_spec, - &route, - view, - long, - usage_argv::help::Style::auto(), - ) - } - ::std::option::Option::None => { - usage_argv::help::render_at_styled( - __usage_spec, - &route, - long, - usage_argv::help::Style::auto(), - ) - } - } - } - ::std::option::Option::None => { - usage_argv::help::render_styled( - __usage_spec, - cmd, - long, - usage_argv::help::Style::auto(), - ) - } + let __usage_want = if long { + usage_argv::help::Page::Long + } else { + usage_argv::help::Page::Short }; + #render_page match __usage_page { ::std::option::Option::Some(page) => { ::std::print!("{page}"); @@ -1042,46 +1034,8 @@ pub fn emit(cli: &Cli) -> TokenStream { } ::std::result::Result::Err(usage_argv::Error::MissingArgsHelp { cmd }) => { #effective_spec - let __usage_route = match __usage_selected_view { - ::std::option::Option::Some(view) => - usage_argv::help::route_to_view( - Self::command(), &__usage_all_refs, cmd, view, - ), - ::std::option::Option::None => usage_argv::help::route_to( - Self::command(), &__usage_argv, cmd, - ), - }; - let __usage_page = match __usage_route { - ::std::option::Option::Some(route) => { - match __usage_selected_view { - ::std::option::Option::Some(view) => { - usage_argv::help::render_view_at_styled( - __usage_spec, - &route, - view, - false, - usage_argv::help::Style::auto_stderr(), - ) - } - ::std::option::Option::None => { - usage_argv::help::render_at_styled( - __usage_spec, - &route, - false, - usage_argv::help::Style::auto_stderr(), - ) - } - } - } - ::std::option::Option::None => { - usage_argv::help::render_styled( - __usage_spec, - cmd, - false, - usage_argv::help::Style::auto_stderr(), - ) - } - }; + let __usage_want = usage_argv::help::Page::Short; + #render_page_stderr match __usage_page { ::std::option::Option::Some(page) => { ::std::eprint!("{page}"); @@ -1092,43 +1046,8 @@ pub fn emit(cli: &Cli) -> TokenStream { } ::std::result::Result::Err(usage_argv::Error::HelpAll { cmd }) => { #effective_spec - let __usage_route = match __usage_selected_view { - ::std::option::Option::Some(view) => - usage_argv::help::route_to_view( - Self::command(), &__usage_all_refs, cmd, view, - ), - ::std::option::Option::None => usage_argv::help::route_to( - Self::command(), &__usage_argv, cmd, - ), - }; - let __usage_page = match __usage_route { - ::std::option::Option::Some(route) => { - match __usage_selected_view { - ::std::option::Option::Some(view) => { - usage_argv::help::render_all_view_at_styled( - __usage_spec, - &route, - view, - usage_argv::help::Style::auto(), - ) - } - ::std::option::Option::None => { - usage_argv::help::render_all_at_styled( - __usage_spec, - &route, - usage_argv::help::Style::auto(), - ) - } - } - } - ::std::option::Option::None => { - usage_argv::help::render_all_styled( - __usage_spec, - cmd, - usage_argv::help::Style::auto(), - ) - } - }; + let __usage_want = usage_argv::help::Page::All; + #render_page match __usage_page { ::std::option::Option::Some(page) => { ::std::print!("{page}"); diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index fc15ee463..7c05f617f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -71,6 +71,7 @@ export default defineConfig({ { text: "Help and Errors", link: "/rust/help" }, { text: "Performance", link: "/rust/performance" }, { text: "Completions", link: "/rust/completions" }, + { text: "Testing", link: "/rust/testing" }, { text: "Spec Output", link: "/rust/spec" } ] }, diff --git a/docs/rust/index.md b/docs/rust/index.md index e5d499642..5c902967c 100644 --- a/docs/rust/index.md +++ b/docs/rust/index.md @@ -59,20 +59,22 @@ name, so depending on `usage-rs` under any name works. `usage-rs` is a facade. Applications should depend on it alone. The split underneath stays available for low-level adopters that want a thinner surface: -| Crate | Role | -| -------------- | -------------------------------------------------------------------------- | -| `usage-rs` | The one package an application depends on; re-exports the whole runtime | -| `usage-derive` | The derive macros: `Cli`, `Args`, `Subcommands`, `ValueEnum` | -| `usage-argv` | The zero-allocation, zero-dependency runtime the derive emits code against | +| Crate | Role | +| -------------- | -------------------------------------------------------------------------------------- | +| `usage-rs` | The one package an application depends on; re-exports the whole runtime | +| `usage-derive` | The derive macros: `Cli`, `Args`, `Subcommands`, `ValueEnum` | +| `usage-argv` | The zero-allocation, zero-dependency runtime the derive emits code against | +| `usage-test` | Test helpers: what a command line parses to, what a page says, what a shell is offered | ### Cargo features -| Feature | Default | What it enables | -| ------------- | :-----: | ------------------------------------------------------------ | -| `spec` | ✅ | Spec metadata and `to_kdl()`; gates the derives | -| `help` | ✅ | `-h` / `--help` page rendering | -| `diagnostics` | ✅ | clap-shaped error messages from `render_failure` | -| `completions` | | Shell completion scripts and the runtime completion protocol | +| Feature | Default | What it enables | +| ------------- | :-----: | ----------------------------------------------------------------------------------------------------------------- | +| `spec` | ✅ | Spec metadata and `to_kdl()`; gates the derives | +| `help` | ✅ | `-h` / `--help` page rendering | +| `diagnostics` | ✅ | clap-shaped error messages from `render_failure` | +| `completions` | | Shell completion scripts and the runtime completion protocol | +| `test` | | `usage::test`: parse and help assertions (a dev-dependency feature; completion assertions want `completions` too) | `#[usage(completion)]` without the `completions` feature is a deliberate `compile_error!` that tells you which feature to add. To drop diagnostics (or help) from a binary that does not want diff --git a/docs/rust/testing.md b/docs/rust/testing.md new file mode 100644 index 000000000..778735444 --- /dev/null +++ b/docs/rust/testing.md @@ -0,0 +1,175 @@ +# Testing + +::: warning Draft +This page is a draft. Some of what it documents is still in open pull requests, and details may +change before release. +::: + +A CLI's observable surface is three things: what a command line parses to, what a user reads +when it does not, and what a shell offers while one is being typed. `usage::test` asserts on all +three from the static tables the derive already emitted — no process to spawn, no terminal to +fake, no snapshot of a binary's stdout. + +```toml +[dev-dependencies] +usage = { package = "usage-rs", version = "6", features = ["test"] } +``` + +The feature belongs in `dev-dependencies`: nothing in an application's own code calls it. + +Nothing here formats a page or a message of its own. Every page comes from the same function +`parse()` renders a help request with, and every failure from the same one it prints — a harness +that renders its own approximation is a harness whose passing tests mean nothing. + +## What a command line does + +`outcome` gives back what `parse()` would have _done_, as a value: a struct, a page, a version, +or a failure. + +```rust +use usage::test::{self as harness, Outcome}; + +#[test] +fn a_command_line_parses() { + let words = harness::argv(["-j", "4", "build", "release"]); + let cli = harness::parse(Ex::spec(), &words.words(), Ex::parse_from).unwrap(); + + assert_eq!(cli.jobs, Some(4)); +} +``` + +`argv` owns the words, because a parse entry point borrows them. `Ex::parse_from` is passed as a +function item — the harness calls it — so the CLI's own generated parser is what runs. + +`parse` is the two-way version: the struct, or the text a user would have read instead. + +```rust +#[test] +fn a_bad_value_says_what_was_wrong_with_it() { + let words = harness::argv(["--jobs", "many"]); + let message = harness::parse(Ex::spec(), &words.words(), Ex::parse_from).unwrap_err(); + + assert!(message.contains("invalid value 'many'")); +} +``` + +That string is the rendered diagnostic, not a debug-printed error code — the same bytes the +process writes to stderr. Which means the message your users read is a thing your test suite can +hold still. + +When the difference between a failure and a question matters, `outcome` keeps them apart, along +with the stream and exit status each would have used: + +```rust +#[test] +fn an_empty_command_line_shows_help() { + let words = harness::argv([] as [&str; 0]); + + let Outcome::Help(printed) = harness::outcome(Bare::spec(), &words.words(), Bare::parse_from) + else { + panic!("arg_required_else_help shows help"); + }; + assert!(printed.stderr); // help nobody asked for is not stdout's business + assert_eq!(printed.code, 2); +} +``` + +| Outcome | What it is | +| --------- | ----------------------------------------------------------------------- | +| `Parsed` | the struct | +| `Help` | a page — asked for on stdout, or shown on stderr with a non-zero status | +| `Version` | `-V` / `--version` | +| `Failed` | the rendered diagnostic, on stderr, with clap's status | + +## Help, page by page + +`help` renders one command's page, found by the words a user would type. `&[]` is the root, and +aliases work, because a test should be able to ask the way a user would. + +```rust +use usage::test::Page; + +assert!(harness::help(Ex::spec(), &["build"], Page::Long).contains("--out")); +``` + +A path that names no command panics and lists what the parent does have — a test asking about a +command that has since been renamed should say so, not quietly assert about a different page. + +`help_tree` is the drift test: every command in the tree, depth-first, in one string. + +```rust +#[test] +fn help_has_not_drifted() { + insta::assert_snapshot!(harness::help_tree(Ex::spec(), Page::Long)); +} +``` + +```text +=== ex === +A tool that does things +... +=== ex build === +Build the thing +... +=== ex secret (hidden) === +``` + +Any change to any page — a flag's help, a new subcommand, a heading that moved — is one diff in +one file. Hidden commands are included and marked: a hidden command still has a page that can +regress. + +## What a shell would offer + +Completion assertions need both features — `test` for the harness, `completions` for the runtime +that answers a line: + +```toml +[dev-dependencies] +usage = { package = "usage-rs", version = "6", features = ["test", "completions"] } +``` + +`candidates` then answers the question a shell asks: given this half-typed line, what could this +word be? The line includes the program name, exactly as a shell passes it. + +```rust +assert_eq!(harness::candidates(Ex::spec(), "ex bui"), ["build"]); +``` + +`described` adds the text a shell shows beside each candidate, and `completion` returns the whole +answer — including whether the position admits paths, which is how a test says that `` +offers files and `--jobs` does not. + +```rust +let answer = harness::completion(Ex::spec(), "ex build --out "); +assert_eq!(answer.files, Some(usage::test::Files::Any)); +``` + +`completion_at` takes the cursor as a byte offset and the shell, which is what makes a test about +completing in the _middle_ of a command line possible at all: + +```rust +use usage::test::Shell; + +let answer = harness::completion_at(Ex::spec(), "ex bui release", "ex bui".len(), Shell::Bash); +``` + +## The one-line spec test + +Structural checks on the declaration itself are not in this module. They are in `to_kdl`, which +asserts in debug builds that the tree is coherent — no duplicate keys, no duplicate flag +spellings across a `flatten` boundary, no argument no word can reach. The one-line test that +fires them is on the [Spec Output](/rust/spec#round-trip-guarantee) page, and is worth writing +beside these; it parses the emitted KDL, so it needs `usage-lib` as a dev-dependency of its +own. + +## What is not covered + +- **Executable views.** These entry points are the plain ones; a view is selected by argv0 in + `parse_from_argv`. +- **A runtime identity.** A CLI whose version or name is computed at run time reports its + declared spec values here, since the harness has the spec and not the program. +- **A build without `diagnostics`.** The failure text is whatever that build would print, + which without the renderer is the compact error rather than the clap-shaped message. The + facade's defaults include it. +- **Colour.** Pages and messages come back plain. A snapshot with escape sequences in it is a + snapshot nobody can read; colour is asserted in usage's own suite, not in yours. diff --git a/test/Cargo.toml b/test/Cargo.toml new file mode 100644 index 000000000..e0654a0af --- /dev/null +++ b/test/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "usage-test" +description = "Test helpers for CLIs built with usage" +version = "5.1.0" +edition = "2021" +rust-version = "1.91" +homepage = { workspace = true } +documentation = { workspace = true } +repository = { workspace = true } +authors = { workspace = true } +license = { workspace = true } + +# A test harness may have dependencies where the runtime may not, and still has none: +# everything here is the cold half of usage-argv, asked a question instead of printed. +[dependencies] +usage-argv = { workspace = true, features = ["spec"] } + +[features] +# Asking what a CLI would offer for a half-typed line. Off by default, so a CLI that only +# wants parse and help assertions does not pull the completion runtime into its build. +completions = ["usage-argv/complete"] + +[package.metadata.release] +shared-version = true +release = true diff --git a/test/src/lib.rs b/test/src/lib.rs new file mode 100644 index 000000000..10fc510ee --- /dev/null +++ b/test/src/lib.rs @@ -0,0 +1,382 @@ +//! Test helpers for a CLI built with usage. +//! +//! A CLI's observable surface is three things: what a command line parses to, what a user is +//! shown when it does not, and what a shell offers while one is being typed. All three are +//! testable from the static tables the derive already emits — no process to spawn, no +//! terminal to fake — and none of them was pleasant to reach for. This crate is the reaching. +//! +//! What it is *not* is a second implementation of any of them. Every page here comes from +//! [`usage_argv::help::page`], the same function `parse()` renders a help request with, and +//! every failure from [`usage_argv::render_failure_plain`], the same renderer it prints with — +//! asked for plain text, so an assertion does not turn on whether stderr was a terminal. A +//! harness that renders its own approximation of a help page is a harness whose passing tests +//! mean nothing, so the rule is that nothing in this crate formats a page. +//! +//! ``` +//! # use usage_argv::spec::Spec; +//! # fn ex(spec: &'static Spec<'static>) { +//! use usage_test as test; +//! +//! // What the tree's help looks like, all of it, in one snapshot. +//! let pages = test::help_tree(spec, test::Page::Long); +//! +//! // What a user sees when a command line does not parse. +//! let words = test::argv(["--nope"]); +//! let outcome = test::outcome(spec, &words.words(), some_cli_parse_from); +//! # fn some_cli_parse_from<'v>( +//! # argv: &[&'v std::ffi::OsStr], +//! # ) -> Result<(), usage_argv::Error<'static, 'v>> { +//! # Ok(()) +//! # } +//! # let _ = (pages, outcome); +//! # } +//! ``` +//! +//! Enable it as a dev-dependency feature of the facade: +//! +//! ```toml +//! [dev-dependencies] +//! usage = { package = "usage-rs", version = "6", features = ["test"] } +//! ``` + +#![forbid(unsafe_code)] + +use std::ffi::{OsStr, OsString}; + +use usage_argv::help::Style; +use usage_argv::spec::{CommandMeta, Spec}; +use usage_argv::Command; +use usage_argv::Error; + +pub use usage_argv::help::Page; + +#[cfg(feature = "completions")] +pub use usage_argv::complete::{Completions, Files, Shell}; + +/// Words a parse entry point can borrow. +/// +/// [`parse_from`](usage_argv) takes `&[&OsStr]`, which a test cannot write as a literal: the +/// `OsString`s have to outlive the slice pointing at them. This owns them, and [`Argv::words`] +/// is the slice. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Argv { + words: Vec, +} + +impl Argv { + /// The words, from anything that can become an `OsString` — including a `&str` literal and + /// a byte sequence no `str` can hold, which is exactly the case worth testing. + pub fn new(words: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self { + words: words.into_iter().map(Into::into).collect(), + } + } + + /// The slice a parse entry point takes. + pub fn words(&self) -> Vec<&OsStr> { + self.words.iter().map(OsString::as_os_str).collect() + } +} + +/// [`Argv::new`], for a call site that reads better without the type. +pub fn argv(words: I) -> Argv +where + I: IntoIterator, + S: Into, +{ + Argv::new(words) +} + +/// What a program would have written, where, and what it would have exited with. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Printed { + /// The text, exactly as the process would have written it — plain, never coloured, because + /// a snapshot with escape sequences in it is a snapshot nobody can read. + pub text: String, + /// Whether it goes to stderr. Help asked for goes to stdout; help shown because a command + /// line was wrong does not. + pub stderr: bool, + /// The exit status that would have followed. + pub code: i32, +} + +/// What one command line does to a CLI. +/// +/// The four things `parse()` can do, as a value instead of a side effect: a struct, a page, a +/// version, or a failure. A test asserting that `ex` with no arguments prints help is asserting +/// on [`Outcome::Help`], which is not something a `Result` can say. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Outcome { + /// It parsed. + Parsed(T), + /// Help was asked for — or shown because nothing was asked for at all, which is the case + /// with `stderr` set and a non-zero code. + Help(Printed), + /// A version was asked for. + Version(Printed), + /// The command line did not parse, and this is what the user reads. + Failed(Printed), +} + +impl Outcome { + /// The parsed struct, panicking with what the CLI would have printed instead. + pub fn parsed(self) -> T { + match self { + Outcome::Parsed(parsed) => parsed, + other => panic!( + "the command line did not parse; the CLI would have printed:\n{}", + other.text().unwrap_or_default() + ), + } + } + + /// What would have been printed, for anything that is not a parse. + pub fn printed(&self) -> Option<&Printed> { + match self { + Outcome::Parsed(_) => None, + Outcome::Help(printed) | Outcome::Version(printed) | Outcome::Failed(printed) => { + Some(printed) + } + } + } + + /// The text of [`Outcome::printed`], for an assertion that only cares what it says. + pub fn text(&self) -> Option<&str> { + self.printed().map(|printed| printed.text.as_str()) + } +} + +/// What a CLI would do with one command line. +/// +/// Pass the CLI's own `parse_from` — `outcome(Ex::spec(), &words.words(), Ex::parse_from)` — +/// which is the function item, not a call. Nothing is printed and nothing exits; the page or +/// message the process would have produced comes back in the [`Outcome`]. +/// +/// A CLI whose identity is computed at run time (`#[usage(version = …)]` and its neighbours) +/// reports its declared spec values here, since the harness has the spec and not the program. +/// Executable views are the same: this is the plain entry point, and a view is selected by +/// argv0 in `parse_from_argv`. +/// +/// A failure's text is whatever the build being tested would print: the clap-shaped message +/// where `diagnostics` is on, as the facade's defaults have it, and the compact error where a +/// parser-only build turned the renderer off. +pub fn outcome<'v, T>( + spec: &Spec<'_>, + argv: &[&'v OsStr], + parse_from: impl FnOnce(&[&'v OsStr]) -> Result>, +) -> Outcome { + match parse_from(argv) { + Ok(parsed) => Outcome::Parsed(parsed), + Err(Error::Version { long }) => { + let bin = spec.bin.unwrap_or(spec.name); + let version = if long { + spec.long_version.or(spec.version) + } else { + spec.version + }; + Outcome::Version(Printed { + text: format!("{bin} {}\n", version.unwrap_or_default()), + stderr: false, + code: 0, + }) + } + Err(Error::Help { cmd, long }) => Outcome::Help(page( + spec, + argv, + cmd, + if long { Page::Long } else { Page::Short }, + false, + 0, + )), + // Help nobody asked for, because the command line was empty: stderr, and clap's status. + Err(Error::MissingArgsHelp { cmd }) => { + Outcome::Help(page(spec, argv, cmd, Page::Short, true, 2)) + } + Err(Error::HelpAll { cmd }) => Outcome::Help(page(spec, argv, cmd, Page::All, false, 0)), + Err(error) => Outcome::Failed(Printed { + // The plain renderer, not the process one: `render_failure` asks the environment + // whether to colour, and a test whose expected string depends on whether stderr is + // a terminal is a test that passes in one place and fails in the other. + text: usage_argv::render_failure_plain(spec, argv, &error), + stderr: true, + code: 2, + }), + } +} + +/// The parsed struct, or the text a user would have read instead. +/// +/// [`outcome`] without the four-way distinction, for the common test: parse this, and if it +/// does not parse, show me what the user gets. A help or version request is text here too, +/// since neither is a parsed struct. +pub fn parse<'v, T>( + spec: &Spec<'_>, + argv: &[&'v OsStr], + parse_from: impl FnOnce(&[&'v OsStr]) -> Result>, +) -> Result { + match outcome(spec, argv, parse_from) { + Outcome::Parsed(parsed) => Ok(parsed), + other => Err(other.text().unwrap_or_default().to_string()), + } +} + +/// One command's help page, found by the words a user would type. +/// +/// `path` names the command without the binary: `&[]` is the root, `&["config", "ls"]` is a +/// nested one. Aliases work, because a test should be able to ask the way a user would. +/// +/// Panics when the path names no command, listing what the parent does have — a test naming a +/// command that was renamed should say so, not quietly assert about the wrong page. +pub fn help(spec: &Spec<'_>, path: &[&str], want: Page) -> String { + let route = route(spec, path); + render(spec, &route, want).expect("a route built from the spec's own tables should render") +} + +/// Every command's help page, depth-first, in one string. +/// +/// The drift test: snapshot this, and any change to any page of any command — a flag's help, +/// a new subcommand, a heading that moved — shows up as a diff in one place. Hidden commands +/// are included and marked, since a hidden command still has a page that can regress. +pub fn help_tree(spec: &Spec<'_>, want: Page) -> String { + let mut out = String::new(); + let bin = spec.bin.unwrap_or(spec.name); + let mut stack = vec![(vec![bin], vec![spec.root])]; + while let Some((path, chain)) = stack.pop() { + let meta = *chain + .last() + .expect("a chain always holds the command it describes"); + let route: Vec<&Command<'_>> = chain.iter().map(|meta| meta.cmd).collect(); + if !out.is_empty() { + out.push('\n'); + } + out.push_str("=== "); + out.push_str(&path.join(" ")); + if meta.hide { + out.push_str(" (hidden)"); + } + out.push_str(" ===\n"); + if let Some(page) = render(spec, &route, want) { + out.push_str(&page); + } + + // Reversed, because the stack pops backwards and a reader expects declaration order. + for sub in meta.subcommands.iter().rev() { + let mut path = path.clone(); + path.push(sub.cmd.name); + let mut chain = chain.clone(); + chain.push(sub); + stack.push((path, chain)); + } + } + out +} + +/// What a shell would be offered for a half-typed line, cursor at the end. +/// +/// The line includes the program name, exactly as a shell passes it: `"ex config --for"`. +#[cfg(feature = "completions")] +pub fn candidates(spec: &Spec<'_>, line: &str) -> Vec { + described(spec, line) + .into_iter() + .map(|(value, _)| value) + .collect() +} + +/// [`candidates`], with the description a shell shows beside each one. +#[cfg(feature = "completions")] +pub fn described(spec: &Spec<'_>, line: &str) -> Vec<(String, Option)> { + let split = usage_argv::complete::split(line, line.len(), Shell::Bash); + usage_argv::complete::candidates(spec, &split) + .into_iter() + .map(|candidate| { + ( + candidate.value, + candidate.description.map(|text| text.into_owned()), + ) + }) + .collect() +} + +/// The whole completion answer, including whether the position admits paths. +/// +/// [`candidates`] is the common question; this is for a test about the other half — that a +/// `` argument offers files and a `--jobs` value does not. +#[cfg(feature = "completions")] +pub fn completion<'a>(spec: &'a Spec<'a>, line: &str) -> Completions<'a> { + completion_at(spec, line, line.len(), Shell::Bash) +} + +/// [`completion`] with the cursor and the shell spelled out. +/// +/// `cursor` is a byte offset into `line`, which is how a shell reports it — and what makes a +/// test about completing in the *middle* of a command line possible at all. +#[cfg(feature = "completions")] +pub fn completion_at<'a>( + spec: &'a Spec<'a>, + line: &str, + cursor: usize, + shell: Shell, +) -> Completions<'a> { + let split = usage_argv::complete::split(line, cursor, shell); + usage_argv::complete::complete(spec, &split) +} + +/// The page a help request becomes, as a [`Printed`]. +fn page( + spec: &Spec<'_>, + argv: &[&OsStr], + cmd: &Command<'_>, + want: Page, + stderr: bool, + code: i32, +) -> Printed { + // The command a request arrived at is not a route: the same `Subcommands` type mounted + // twice is one address. `help::page` rebuilds the route from the words, which is what the + // program does with the same argv. + let text = usage_argv::help::page(spec, spec.root.cmd, argv, cmd, want, Style::PLAIN) + .unwrap_or_default(); + Printed { text, stderr, code } +} + +fn render(spec: &Spec<'_>, route: &[&Command<'_>], want: Page) -> Option { + match want { + Page::Short => usage_argv::help::render_at_styled(spec, route, false, Style::PLAIN), + Page::Long => usage_argv::help::render_at_styled(spec, route, true, Style::PLAIN), + Page::All => usage_argv::help::render_all_at_styled(spec, route, Style::PLAIN), + } +} + +/// The route a path names, by name and then by alias, one level at a time. +fn route<'a>(spec: &Spec<'a>, path: &[&str]) -> Vec<&'a Command<'a>> { + let mut here: &'a CommandMeta<'a> = spec.root; + let mut route = vec![here.cmd]; + for name in path { + let next = here + .subcommands + .iter() + .find(|sub| sub.cmd.name == *name) + .or_else(|| { + here.subcommands + .iter() + .find(|sub| sub.cmd.aliases.contains(name)) + }); + let Some(next) = next else { + panic!( + "{:?} names no subcommand of {:?}; it has {:?}", + name, + here.cmd.name, + here.subcommands + .iter() + .map(|sub| sub.cmd.name) + .collect::>() + ) + }; + here = next; + route.push(here.cmd); + } + route +} diff --git a/usage-rs/Cargo.toml b/usage-rs/Cargo.toml index 9969662bc..4e5b2809c 100644 --- a/usage-rs/Cargo.toml +++ b/usage-rs/Cargo.toml @@ -13,6 +13,7 @@ license = { workspace = true } [dependencies] usage-argv = { workspace = true } usage-derive = { workspace = true, optional = true } +usage-test = { workspace = true, optional = true } usage-validation = { workspace = true, optional = true } [features] @@ -23,11 +24,14 @@ usage-validation = { workspace = true, optional = true } default = ["spec", "help", "diagnostics"] spec = ["usage-argv/spec", "dep:usage-derive"] help = ["spec"] -completions = ["spec", "usage-argv/complete"] +completions = ["spec", "usage-argv/complete", "usage-test?/completions"] # Kept as a spelling close to the runtime feature for low-level adopters. complete = ["completions"] diagnostics = ["spec", "usage-argv/diagnostics"] validation = ["dep:usage-validation"] +# Assertions about what a command line parses to, what a page says, and what a shell is +# offered. A dev-dependency feature: nothing in an application's own code calls it. +test = ["spec", "help", "dep:usage-test"] [package.metadata.release] shared-version = true diff --git a/usage-rs/src/lib.rs b/usage-rs/src/lib.rs index 7c9e8a3cd..da8ff8f98 100644 --- a/usage-rs/src/lib.rs +++ b/usage-rs/src/lib.rs @@ -48,6 +48,8 @@ pub use usage_argv as argv; pub use usage_argv::*; #[cfg(feature = "spec")] pub use usage_derive::{Args, Cli, Subcommands, ValueEnum}; +#[cfg(feature = "test")] +pub use usage_test as test; #[cfg(feature = "validation")] pub use usage_validation as validation; diff --git a/usage-rs/tests/harness.rs b/usage-rs/tests/harness.rs new file mode 100644 index 000000000..6d107e26a --- /dev/null +++ b/usage-rs/tests/harness.rs @@ -0,0 +1,291 @@ +//! What an adopter's own tests look like, written against the harness they get from the facade. +//! +//! Each test here is the shape a CLI's test suite is expected to take, so the file doubles as +//! the worked example the documentation points at. +#![cfg(all(feature = "test", feature = "spec"))] + +use usage_rs::test::{self as harness, Outcome, Page}; +use usage_rs::{Args, Cli, Subcommands}; + +/// A tool that does things. +/// +/// Longer prose, so the short and long pages differ. +#[derive(Cli, Debug, PartialEq, Eq)] +#[usage(bin = "ex", version = "1.2.3")] +struct Ex { + /// How many jobs to run at once + #[usage(short = 'j', long)] + jobs: Option, + #[usage(subcommand)] + command: Command, +} + +#[derive(Subcommands, Debug, PartialEq, Eq)] +enum Command { + Build(Build), + Secret(Secret), +} + +/// Build the thing +#[derive(Args, Debug, PartialEq, Eq)] +#[usage(visible_alias = "b")] +struct Build { + /// Where to write it + #[usage(long)] + out: Option, + /// What to build + target: String, +} + +/// Not for you +#[derive(Args, Debug, PartialEq, Eq)] +#[usage(hide)] +struct Secret; + +/// A command line with nothing on it, which is its own case. +#[derive(Cli, Debug)] +#[usage(bin = "bare", arg_required_else_help)] +struct Bare { + /// A file to read + file: Option, +} + +#[test] +fn a_command_line_parses_to_the_struct_it_declares() { + let words = harness::argv(["-j", "4", "build", "--out", "dist", "release"]); + let parsed = harness::parse(Ex::spec(), &words.words(), Ex::parse_from) + .expect("the command line should parse"); + + assert_eq!( + parsed, + Ex { + jobs: Some(4), + command: Command::Build(Build { + out: Some("dist".to_string()), + target: "release".to_string(), + }), + } + ); +} + +#[test] +fn a_failure_comes_back_as_the_text_a_user_reads() { + let words = harness::argv(["--jobs", "many", "build", "release"]); + let message = harness::parse(Ex::spec(), &words.words(), Ex::parse_from) + .expect_err("`many` is not a number of jobs"); + + // The rendered diagnostic, not a debug-printed error code: what the user is shown is what + // a test about the user's experience has to be able to assert on. + assert!(message.contains("invalid value 'many'"), "{message}"); + assert!(message.contains("invalid digit"), "{message}"); +} + +#[test] +fn a_failure_is_plain_text_wherever_the_test_runs() { + // The process asks the environment whether to colour, and gets a different answer under + // `cargo test` in a terminal than in CI. A string a test asserts on cannot turn on that: + // this is the regression guard for the harness rendering plain rather than `auto`. + // + // Set for the whole process rather than for one call, because that is the only way the + // renderer can be asked, and nothing else in this binary reads it: the harness never calls + // `Style::auto`. + unsafe { std::env::set_var("CLICOLOR_FORCE", "1") }; + let words = harness::argv(["--jobs", "many", "build", "release"]); + let message = harness::parse(Ex::spec(), &words.words(), Ex::parse_from) + .expect_err("`many` is not a number of jobs"); + unsafe { std::env::remove_var("CLICOLOR_FORCE") }; + + assert!( + !message.contains('\u{1b}'), + "the message should carry no escape sequences: {message:?}" + ); +} + +#[test] +fn an_unknown_flag_falls_through_where_a_cli_stays_lax() { + // usage is lax by default, so this is a test about what *does* happen rather than about a + // rejection: `--nope` is left alone, and the word after it is the one with nowhere to go. + let words = harness::argv(["build", "--nope", "release"]); + let message = harness::parse(Ex::spec(), &words.words(), Ex::parse_from) + .expect_err("`release` has already filled the one argument `build` declares"); + + assert!(message.contains("'release'"), "{message}"); +} + +#[test] +fn a_missing_required_argument_names_itself() { + let words = harness::argv(["build"]); + let message = harness::parse(Ex::spec(), &words.words(), Ex::parse_from) + .expect_err("`build` requires a target"); + + assert!(message.contains(""), "{message}"); +} + +#[test] +fn help_asked_for_goes_to_stdout_with_a_zero_status() { + let words = harness::argv(["build", "--help"]); + let outcome = harness::outcome(Ex::spec(), &words.words(), Ex::parse_from); + + let Outcome::Help(printed) = outcome else { + panic!("`--help` is a help request: {outcome:?}"); + }; + assert!(!printed.stderr, "a question's answer is not an error"); + assert_eq!(printed.code, 0); + assert!(printed.text.contains("Build the thing"), "{}", printed.text); + assert!(printed.text.contains("--out"), "{}", printed.text); +} + +#[test] +fn help_nobody_asked_for_goes_to_stderr_with_clap_s_status() { + let words = harness::argv([] as [&str; 0]); + let outcome = harness::outcome(Bare::spec(), &words.words(), Bare::parse_from); + + let Outcome::Help(printed) = outcome else { + panic!("`arg_required_else_help` shows help for an empty command line: {outcome:?}"); + }; + assert!(printed.stderr, "unasked-for help is not stdout's business"); + assert_eq!(printed.code, 2); + assert!(printed.text.contains("Usage: bare"), "{}", printed.text); +} + +#[test] +fn a_version_request_is_its_own_outcome() { + let words = harness::argv(["--version"]); + let outcome = harness::outcome(Ex::spec(), &words.words(), Ex::parse_from); + + let Outcome::Version(printed) = outcome else { + panic!("`--version` is a version request: {outcome:?}"); + }; + assert_eq!(printed.text, "ex 1.2.3\n"); + assert_eq!(printed.code, 0); +} + +#[test] +fn a_page_by_path_is_the_page_a_user_would_have_been_shown() { + // The invariant that makes both halves of the harness worth having: asking for a page by + // the path a user types gives exactly what the command line asking for it produces. + let words = harness::argv(["build", "--help"]); + let printed = harness::outcome(Ex::spec(), &words.words(), Ex::parse_from) + .printed() + .expect("a help request prints") + .text + .clone(); + + assert_eq!(harness::help(Ex::spec(), &["build"], Page::Long), printed); +} + +#[test] +fn a_page_can_be_asked_for_by_alias() { + assert_eq!( + harness::help(Ex::spec(), &["b"], Page::Long), + harness::help(Ex::spec(), &["build"], Page::Long), + ); +} + +#[test] +fn the_short_and_long_pages_are_different_pages() { + let short = harness::help(Ex::spec(), &[], Page::Short); + let long = harness::help(Ex::spec(), &[], Page::Long); + + assert!(short.contains("A tool that does things"), "{short}"); + assert!(long.contains("Longer prose"), "{long}"); + assert!(!short.contains("Longer prose"), "{short}"); +} + +#[test] +#[should_panic(expected = "names no subcommand")] +fn a_path_that_names_nothing_says_so() { + // A command that was renamed should fail the test that asks about it, rather than quietly + // asserting about some other page. + harness::help(Ex::spec(), &["biuld"], Page::Long); +} + +#[test] +fn the_whole_tree_is_one_snapshot() { + let tree = harness::help_tree(Ex::spec(), Page::Long); + + // One entry per command, in declaration order, with the path a user types as its header. + let headers: Vec<&str> = tree + .lines() + .filter(|line| line.starts_with("=== ")) + .collect(); + assert_eq!( + headers, + [ + "=== ex ===", + "=== ex build ===", + "=== ex secret (hidden) ===" + ] + ); + + // And the pages themselves, so a flag's help changing anywhere in the tree is a diff here. + assert!(tree.contains("Where to write it"), "{tree}"); +} + +#[test] +fn a_recursive_page_covers_the_visible_tree() { + let all = harness::help(Ex::spec(), &[], Page::All); + + assert!(all.contains("Build the thing"), "{all}"); + assert!(all.contains("Where to write it"), "{all}"); + // `secret` is hidden, and a recursive page is still help. + assert!(!all.contains("Not for you"), "{all}"); +} + +#[cfg(feature = "completions")] +#[test] +fn a_half_typed_command_is_offered_the_commands_that_match() { + assert_eq!(harness::candidates(Ex::spec(), "ex bui"), ["build"]); +} + +#[cfg(feature = "completions")] +#[test] +fn a_flag_is_offered_with_the_help_a_shell_shows_beside_it() { + let offered = harness::described(Ex::spec(), "ex build --o"); + + assert_eq!( + offered, + [("--out".to_string(), Some("Where to write it".to_string()))] + ); +} + +#[cfg(feature = "completions")] +#[test] +fn a_cursor_in_the_middle_of_a_line_completes_the_word_it_sits_in() { + let line = "ex bui release"; + let candidates = harness::completion_at( + Ex::spec(), + line, + "ex bui".len(), + usage_rs::test::Shell::Bash, + ); + + let values: Vec<&str> = candidates + .candidates + .iter() + .map(|candidate| candidate.value.as_str()) + .collect(); + assert_eq!(values, ["build"]); +} + +#[cfg(feature = "completions")] +#[test] +fn a_value_position_says_whether_it_admits_paths() { + // The other half of a completion answer: a shell asks what a word could be *and* whether to + // add filenames to whatever it is told. + let value = harness::completion(Ex::spec(), "ex build --out "); + assert_eq!(value.files, Some(usage_rs::test::Files::Any)); + + // A flag name is not a path, however many files sit in the directory. + let flag = harness::completion(Ex::spec(), "ex build --"); + assert_eq!(flag.files, None); +} + +#[cfg(feature = "completions")] +#[test] +fn a_hidden_command_is_not_offered() { + let offered = harness::candidates(Ex::spec(), "ex "); + + assert!(offered.contains(&"build".to_string()), "{offered:?}"); + assert!(!offered.contains(&"secret".to_string()), "{offered:?}"); +}