Skip to main content

usage_test/
lib.rs

1//! Test helpers for a CLI built with usage.
2//!
3//! The parser's observable surface is what a command line parses to, what a user is shown when
4//! it does not, and what a shell offers while one is being typed. All three are testable from
5//! the static tables the derive already emits. [`command!`] covers the other half of a CLI test:
6//! run the compiled binary and capture what its command handler writes.
7//!
8//! What it is *not* is a second implementation of any of them. Every page here comes from
9//! [`usage_argv::help::page`], the same function `parse()` renders a help request with, and
10//! every failure from [`usage_argv::render_failure_plain`], the same renderer it prints with —
11//! asked for plain text, so an assertion does not turn on whether stderr was a terminal. A
12//! harness that renders its own approximation of a help page is a harness whose passing tests
13//! mean nothing, so the rule is that nothing in this crate formats a page.
14//!
15//! ```
16//! # use usage_argv::spec::Spec;
17//! # fn ex(spec: &'static Spec<'static>) {
18//! use usage_test as test;
19//!
20//! // What the tree's help looks like, all of it, in one snapshot.
21//! let pages = test::help_tree(spec, test::Page::Long);
22//!
23//! // What a user sees when a command line does not parse.
24//! let words = test::argv(["--nope"]);
25//! let outcome = test::outcome(spec, &words.words(), some_cli_parse_from);
26//! # fn some_cli_parse_from<'v>(
27//! #     argv: &[&'v std::ffi::OsStr],
28//! # ) -> Result<(), usage_argv::Error<'static, 'v>> {
29//! #     Ok(())
30//! # }
31//! # let _ = (pages, outcome);
32//! # }
33//! ```
34//!
35//! Enable it as a dev-dependency feature of the facade:
36//!
37//! ```toml
38//! [dev-dependencies]
39//! usage = { package = "usage-rs", version = "7", features = ["test"] }
40//! ```
41
42#![forbid(unsafe_code)]
43
44use std::ffi::{OsStr, OsString};
45use std::process::{Command as ProcessCommand, ExitStatus};
46
47use usage_argv::Command;
48use usage_argv::Error;
49use usage_argv::help::Style;
50use usage_argv::spec::{CommandMeta, Spec};
51
52pub use usage_argv::help::Page;
53
54#[cfg(feature = "completions")]
55pub use usage_argv::complete::{Completions, Files, Shell};
56
57/// Words a parse entry point can borrow.
58///
59/// [`parse_from`](usage_argv) takes `&[&OsStr]`, which a test cannot write as a literal: the
60/// `OsString`s have to outlive the slice pointing at them. This owns them, and [`Argv::words`]
61/// is the slice.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct Argv {
64    words: Vec<OsString>,
65}
66
67impl Argv {
68    /// The words, from anything that can become an `OsString` — including a `&str` literal and
69    /// a byte sequence no `str` can hold, which is exactly the case worth testing.
70    pub fn new<I, S>(words: I) -> Self
71    where
72        I: IntoIterator<Item = S>,
73        S: Into<OsString>,
74    {
75        Self {
76            words: words.into_iter().map(Into::into).collect(),
77        }
78    }
79
80    /// The slice a parse entry point takes.
81    pub fn words(&self) -> Vec<&OsStr> {
82        self.words.iter().map(OsString::as_os_str).collect()
83    }
84}
85
86/// [`Argv::new`], for a call site that reads better without the type.
87pub fn argv<I, S>(words: I) -> Argv
88where
89    I: IntoIterator<Item = S>,
90    S: Into<OsString>,
91{
92    Argv::new(words)
93}
94
95/// Output captured from a CLI integration test.
96///
97/// [`command!`] runs the package's compiled binary, so this includes the same stdout, stderr,
98/// and exit status a user receives. Keep these tests under `tests/`: Cargo defines the binary
99/// path for integration tests, not for a crate's unit tests.
100#[derive(Debug)]
101pub struct CommandOutput {
102    /// The process exit status.
103    pub status: ExitStatus,
104    /// Stdout exactly as the process wrote it.
105    pub stdout: Vec<u8>,
106    /// Stderr exactly as the process wrote it.
107    pub stderr: Vec<u8>,
108}
109
110impl CommandOutput {
111    /// Assert that the command succeeded, printing stderr when it did not.
112    #[track_caller]
113    pub fn assert_success(self) -> Self {
114        assert!(
115            self.status.success(),
116            "command exited with {}:\n{}",
117            self.status,
118            String::from_utf8_lossy(&self.stderr)
119        );
120        self
121    }
122
123    /// Stdout as UTF-8.
124    ///
125    /// Use the public [`Self::stdout`] bytes directly when non-UTF-8 output is intentional.
126    #[track_caller]
127    pub fn stdout_text(&self) -> &str {
128        std::str::from_utf8(&self.stdout).expect("command stdout was not UTF-8")
129    }
130
131    /// Stderr as UTF-8.
132    ///
133    /// Use the public [`Self::stderr`] bytes directly when non-UTF-8 output is intentional.
134    #[track_caller]
135    pub fn stderr_text(&self) -> &str {
136        std::str::from_utf8(&self.stderr).expect("command stderr was not UTF-8")
137    }
138}
139
140/// Run one of the current package's binaries and capture its output.
141///
142/// This is the implementation behind [`command!`]. The public macro supplies `program` from
143/// Cargo's `CARGO_BIN_EXE_<name>` integration-test variable.
144#[doc(hidden)]
145pub fn __command<I, S>(program: &str, args: I) -> CommandOutput
146where
147    I: IntoIterator<Item = S>,
148    S: AsRef<OsStr>,
149{
150    let output = ProcessCommand::new(program)
151        .args(args)
152        .output()
153        .unwrap_or_else(|error| panic!("could not run {program:?}: {error}"));
154    CommandOutput {
155        status: output.status,
156        stdout: output.stdout,
157        stderr: output.stderr,
158    }
159}
160
161/// Run a binary target from an integration test and capture stdout, stderr, and its exit status.
162///
163/// The first argument is the binary target's Cargo name. Remaining arguments are passed to the
164/// process:
165///
166/// ```ignore
167/// let output = usage::test::command!("greet", "hello", "Jeff").assert_success();
168/// assert_eq!(output.stdout_text(), "hello, Jeff\n");
169/// ```
170///
171/// Put the test under `tests/`, where Cargo provides `CARGO_BIN_EXE_<name>` for each binary
172/// target in the package.
173#[macro_export]
174macro_rules! command {
175    ($bin:literal $(,)?) => {
176        $crate::__command(
177            env!(
178                concat!("CARGO_BIN_EXE_", $bin),
179                concat!(
180                    "`",
181                    $bin,
182                    "` is not available; use `command!` from an integration test under `tests/`"
183                )
184            ),
185            ::std::iter::empty::<&::std::ffi::OsStr>(),
186        )
187    };
188    ($bin:literal, $($arg:expr),+ $(,)?) => {
189        $crate::__command(
190            env!(
191                concat!("CARGO_BIN_EXE_", $bin),
192                concat!(
193                    "`",
194                    $bin,
195                    "` is not available; use `command!` from an integration test under `tests/`"
196                )
197            ),
198            [$($arg),+],
199        )
200    };
201}
202
203/// What a program would have written, where, and what it would have exited with.
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct Printed {
206    /// The text, exactly as the process would have written it — plain, never coloured, because
207    /// a snapshot with escape sequences in it is a snapshot nobody can read.
208    pub text: String,
209    /// Whether it goes to stderr. Help asked for goes to stdout; help shown because a command
210    /// line was wrong does not.
211    pub stderr: bool,
212    /// The exit status that would have followed.
213    pub code: i32,
214}
215
216/// What one command line does to a CLI.
217///
218/// The four things `parse()` can do, as a value instead of a side effect: a struct, a page, a
219/// version, or a failure. A test asserting that `ex` with no arguments prints help is asserting
220/// on [`Outcome::Help`], which is not something a `Result` can say.
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub enum Outcome<T> {
223    /// It parsed.
224    Parsed(T),
225    /// Help was asked for — or shown because nothing was asked for at all, which is the case
226    /// with `stderr` set and a non-zero code.
227    Help(Printed),
228    /// A version was asked for.
229    Version(Printed),
230    /// The command line did not parse, and this is what the user reads.
231    Failed(Printed),
232}
233
234impl<T> Outcome<T> {
235    /// The parsed struct, panicking with what the CLI would have printed instead.
236    pub fn parsed(self) -> T {
237        match self {
238            Outcome::Parsed(parsed) => parsed,
239            other => panic!(
240                "the command line did not parse; the CLI would have printed:\n{}",
241                other.text().unwrap_or_default()
242            ),
243        }
244    }
245
246    /// What would have been printed, for anything that is not a parse.
247    pub fn printed(&self) -> Option<&Printed> {
248        match self {
249            Outcome::Parsed(_) => None,
250            Outcome::Help(printed) | Outcome::Version(printed) | Outcome::Failed(printed) => {
251                Some(printed)
252            }
253        }
254    }
255
256    /// The text of [`Outcome::printed`], for an assertion that only cares what it says.
257    pub fn text(&self) -> Option<&str> {
258        self.printed().map(|printed| printed.text.as_str())
259    }
260}
261
262/// What a CLI would do with one command line.
263///
264/// Pass the CLI's own `parse_from` — `outcome(Ex::spec(), &words.words(), Ex::parse_from)` —
265/// which is the function item, not a call. Nothing is printed and nothing exits; the page or
266/// message the process would have produced comes back in the [`Outcome`].
267///
268/// A CLI whose identity is computed at run time (`#[usage(version = …)]` and its neighbours)
269/// reports its declared spec values here, since the harness has the spec and not the program.
270/// Executable views are the same: this is the plain entry point, and a view is selected by
271/// argv0 in `parse_from_argv`.
272///
273/// A failure's text is whatever the build being tested would print: the clap-shaped message
274/// where `diagnostics` is on, as the facade's defaults have it, and the compact error where a
275/// parser-only build turned the renderer off.
276pub fn outcome<'v, T>(
277    spec: &Spec<'_>,
278    argv: &[&'v OsStr],
279    parse_from: impl FnOnce(&[&'v OsStr]) -> Result<T, Error<'static, 'v>>,
280) -> Outcome<T> {
281    match parse_from(argv) {
282        Ok(parsed) => Outcome::Parsed(parsed),
283        Err(Error::Version { long }) => {
284            let bin = spec.bin.unwrap_or(spec.name);
285            let version = if long {
286                spec.long_version.or(spec.version)
287            } else {
288                spec.version
289            };
290            Outcome::Version(Printed {
291                text: format!("{bin} {}\n", version.unwrap_or_default()),
292                stderr: false,
293                code: 0,
294            })
295        }
296        Err(Error::Help { cmd, long }) => Outcome::Help(page(
297            spec,
298            argv,
299            cmd,
300            if long { Page::Long } else { Page::Short },
301            false,
302            0,
303        )),
304        // Help nobody asked for, because the command line was empty: stderr, and clap's status.
305        Err(Error::MissingArgsHelp { cmd }) => {
306            Outcome::Help(page(spec, argv, cmd, Page::Short, true, 2))
307        }
308        Err(Error::HelpAll { cmd }) => Outcome::Help(page(spec, argv, cmd, Page::All, false, 0)),
309        Err(error) => Outcome::Failed(Printed {
310            // The plain renderer, not the process one: `render_failure` asks the environment
311            // whether to colour, and a test whose expected string depends on whether stderr is
312            // a terminal is a test that passes in one place and fails in the other.
313            text: usage_argv::render_failure_plain(spec, argv, &error),
314            stderr: true,
315            code: 2,
316        }),
317    }
318}
319
320/// The parsed struct, or the text a user would have read instead.
321///
322/// [`outcome`] without the four-way distinction, for the common test: parse this, and if it
323/// does not parse, show me what the user gets. A help or version request is text here too,
324/// since neither is a parsed struct.
325pub fn parse<'v, T>(
326    spec: &Spec<'_>,
327    argv: &[&'v OsStr],
328    parse_from: impl FnOnce(&[&'v OsStr]) -> Result<T, Error<'static, 'v>>,
329) -> Result<T, String> {
330    match outcome(spec, argv, parse_from) {
331        Outcome::Parsed(parsed) => Ok(parsed),
332        other => Err(other.text().unwrap_or_default().to_string()),
333    }
334}
335
336/// One command's help page, found by the words a user would type.
337///
338/// `path` names the command without the binary: `&[]` is the root, `&["config", "ls"]` is a
339/// nested one. Aliases work, because a test should be able to ask the way a user would.
340///
341/// Panics when the path names no command, listing what the parent does have — a test naming a
342/// command that was renamed should say so, not quietly assert about the wrong page.
343pub fn help(spec: &Spec<'_>, path: &[&str], want: Page) -> String {
344    let route = route(spec, path);
345    render(spec, &route, want).expect("a route built from the spec's own tables should render")
346}
347
348/// Every command's help page, depth-first, in one string.
349///
350/// The drift test: snapshot this, and any change to any page of any command — a flag's help,
351/// a new subcommand, a heading that moved — shows up as a diff in one place. Hidden commands
352/// are included and marked, since a hidden command still has a page that can regress.
353pub fn help_tree(spec: &Spec<'_>, want: Page) -> String {
354    let mut out = String::new();
355    let bin = spec.bin.unwrap_or(spec.name);
356    let mut stack = vec![(vec![bin], vec![spec.root])];
357    while let Some((path, chain)) = stack.pop() {
358        let meta = *chain
359            .last()
360            .expect("a chain always holds the command it describes");
361        let route: Vec<&Command<'_>> = chain.iter().map(|meta| meta.cmd).collect();
362        if !out.is_empty() {
363            out.push('\n');
364        }
365        out.push_str("=== ");
366        out.push_str(&path.join(" "));
367        if meta.hide {
368            out.push_str(" (hidden)");
369        }
370        out.push_str(" ===\n");
371        if let Some(page) = render(spec, &route, want) {
372            out.push_str(&page);
373        }
374
375        // Reversed, because the stack pops backwards and a reader expects declaration order.
376        for sub in meta.subcommands.iter().rev() {
377            let mut path = path.clone();
378            path.push(sub.cmd.name);
379            let mut chain = chain.clone();
380            chain.push(sub);
381            stack.push((path, chain));
382        }
383    }
384    out
385}
386
387/// What a shell would be offered for a half-typed line, cursor at the end.
388///
389/// The line includes the program name, exactly as a shell passes it: `"ex config --for"`.
390#[cfg(feature = "completions")]
391pub fn candidates(spec: &Spec<'_>, line: &str) -> Vec<String> {
392    described(spec, line)
393        .into_iter()
394        .map(|(value, _)| value)
395        .collect()
396}
397
398/// [`candidates`], with the description a shell shows beside each one.
399#[cfg(feature = "completions")]
400pub fn described(spec: &Spec<'_>, line: &str) -> Vec<(String, Option<String>)> {
401    let split = usage_argv::complete::split(line, line.len(), Shell::Bash);
402    usage_argv::complete::candidates(spec, &split)
403        .into_iter()
404        .map(|candidate| {
405            (
406                candidate.value,
407                candidate.description.map(|text| text.into_owned()),
408            )
409        })
410        .collect()
411}
412
413/// The whole completion answer, including whether the position admits paths.
414///
415/// [`candidates`] is the common question; this is for a test about the other half — that a
416/// `<PATH>` argument offers files and a `--jobs` value does not.
417#[cfg(feature = "completions")]
418pub fn completion<'a>(spec: &'a Spec<'a>, line: &str) -> Completions<'a> {
419    completion_at(spec, line, line.len(), Shell::Bash)
420}
421
422/// [`completion`] with the cursor and the shell spelled out.
423///
424/// `cursor` is a byte offset into `line`, which is how a shell reports it — and what makes a
425/// test about completing in the *middle* of a command line possible at all.
426#[cfg(feature = "completions")]
427pub fn completion_at<'a>(
428    spec: &'a Spec<'a>,
429    line: &str,
430    cursor: usize,
431    shell: Shell,
432) -> Completions<'a> {
433    let split = usage_argv::complete::split(line, cursor, shell);
434    usage_argv::complete::complete(spec, &split)
435}
436
437/// The page a help request becomes, as a [`Printed`].
438fn page(
439    spec: &Spec<'_>,
440    argv: &[&OsStr],
441    cmd: &Command<'_>,
442    want: Page,
443    stderr: bool,
444    code: i32,
445) -> Printed {
446    // The command a request arrived at is not a route: the same `Subcommands` type mounted
447    // twice is one address. `help::page` rebuilds the route from the words, which is what the
448    // program does with the same argv.
449    let text = usage_argv::help::page(spec, spec.root.cmd, argv, cmd, want, Style::PLAIN)
450        .unwrap_or_default();
451    Printed { text, stderr, code }
452}
453
454fn render(spec: &Spec<'_>, route: &[&Command<'_>], want: Page) -> Option<String> {
455    match want {
456        Page::Short => usage_argv::help::render_at_styled(spec, route, false, Style::PLAIN),
457        Page::Long => usage_argv::help::render_at_styled(spec, route, true, Style::PLAIN),
458        Page::All => usage_argv::help::render_all_at_styled(spec, route, Style::PLAIN),
459    }
460}
461
462/// The route a path names, by name and then by alias, one level at a time.
463fn route<'a>(spec: &Spec<'a>, path: &[&str]) -> Vec<&'a Command<'a>> {
464    let mut here: &'a CommandMeta<'a> = spec.root;
465    let mut route = vec![here.cmd];
466    for name in path {
467        let next = here
468            .subcommands
469            .iter()
470            .find(|sub| sub.cmd.name == *name)
471            .or_else(|| {
472                here.subcommands
473                    .iter()
474                    .find(|sub| sub.cmd.aliases.contains(name))
475            });
476        let Some(next) = next else {
477            panic!(
478                "{:?} names no subcommand of {:?}; it has {:?}",
479                name,
480                here.cmd.name,
481                here.subcommands
482                    .iter()
483                    .map(|sub| sub.cmd.name)
484                    .collect::<Vec<_>>()
485            )
486        };
487        here = next;
488        route.push(here.cmd);
489    }
490    route
491}