Skip to main content

usage_test/
lib.rs

1//! Test helpers for a CLI built with usage.
2//!
3//! A CLI's observable surface is three things: what a command line parses to, what a user is
4//! shown when it does not, and what a shell offers while one is being typed. All three are
5//! testable from the static tables the derive already emits — no process to spawn, no
6//! terminal to fake — and none of them was pleasant to reach for. This crate is the reaching.
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 = "6", features = ["test"] }
40//! ```
41
42#![forbid(unsafe_code)]
43
44use std::ffi::{OsStr, OsString};
45
46use usage_argv::help::Style;
47use usage_argv::spec::{CommandMeta, Spec};
48use usage_argv::Command;
49use usage_argv::Error;
50
51pub use usage_argv::help::Page;
52
53#[cfg(feature = "completions")]
54pub use usage_argv::complete::{Completions, Files, Shell};
55
56/// Words a parse entry point can borrow.
57///
58/// [`parse_from`](usage_argv) takes `&[&OsStr]`, which a test cannot write as a literal: the
59/// `OsString`s have to outlive the slice pointing at them. This owns them, and [`Argv::words`]
60/// is the slice.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Argv {
63    words: Vec<OsString>,
64}
65
66impl Argv {
67    /// The words, from anything that can become an `OsString` — including a `&str` literal and
68    /// a byte sequence no `str` can hold, which is exactly the case worth testing.
69    pub fn new<I, S>(words: I) -> Self
70    where
71        I: IntoIterator<Item = S>,
72        S: Into<OsString>,
73    {
74        Self {
75            words: words.into_iter().map(Into::into).collect(),
76        }
77    }
78
79    /// The slice a parse entry point takes.
80    pub fn words(&self) -> Vec<&OsStr> {
81        self.words.iter().map(OsString::as_os_str).collect()
82    }
83}
84
85/// [`Argv::new`], for a call site that reads better without the type.
86pub fn argv<I, S>(words: I) -> Argv
87where
88    I: IntoIterator<Item = S>,
89    S: Into<OsString>,
90{
91    Argv::new(words)
92}
93
94/// What a program would have written, where, and what it would have exited with.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct Printed {
97    /// The text, exactly as the process would have written it — plain, never coloured, because
98    /// a snapshot with escape sequences in it is a snapshot nobody can read.
99    pub text: String,
100    /// Whether it goes to stderr. Help asked for goes to stdout; help shown because a command
101    /// line was wrong does not.
102    pub stderr: bool,
103    /// The exit status that would have followed.
104    pub code: i32,
105}
106
107/// What one command line does to a CLI.
108///
109/// The four things `parse()` can do, as a value instead of a side effect: a struct, a page, a
110/// version, or a failure. A test asserting that `ex` with no arguments prints help is asserting
111/// on [`Outcome::Help`], which is not something a `Result` can say.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum Outcome<T> {
114    /// It parsed.
115    Parsed(T),
116    /// Help was asked for — or shown because nothing was asked for at all, which is the case
117    /// with `stderr` set and a non-zero code.
118    Help(Printed),
119    /// A version was asked for.
120    Version(Printed),
121    /// The command line did not parse, and this is what the user reads.
122    Failed(Printed),
123}
124
125impl<T> Outcome<T> {
126    /// The parsed struct, panicking with what the CLI would have printed instead.
127    pub fn parsed(self) -> T {
128        match self {
129            Outcome::Parsed(parsed) => parsed,
130            other => panic!(
131                "the command line did not parse; the CLI would have printed:\n{}",
132                other.text().unwrap_or_default()
133            ),
134        }
135    }
136
137    /// What would have been printed, for anything that is not a parse.
138    pub fn printed(&self) -> Option<&Printed> {
139        match self {
140            Outcome::Parsed(_) => None,
141            Outcome::Help(printed) | Outcome::Version(printed) | Outcome::Failed(printed) => {
142                Some(printed)
143            }
144        }
145    }
146
147    /// The text of [`Outcome::printed`], for an assertion that only cares what it says.
148    pub fn text(&self) -> Option<&str> {
149        self.printed().map(|printed| printed.text.as_str())
150    }
151}
152
153/// What a CLI would do with one command line.
154///
155/// Pass the CLI's own `parse_from` — `outcome(Ex::spec(), &words.words(), Ex::parse_from)` —
156/// which is the function item, not a call. Nothing is printed and nothing exits; the page or
157/// message the process would have produced comes back in the [`Outcome`].
158///
159/// A CLI whose identity is computed at run time (`#[usage(version = …)]` and its neighbours)
160/// reports its declared spec values here, since the harness has the spec and not the program.
161/// Executable views are the same: this is the plain entry point, and a view is selected by
162/// argv0 in `parse_from_argv`.
163///
164/// A failure's text is whatever the build being tested would print: the clap-shaped message
165/// where `diagnostics` is on, as the facade's defaults have it, and the compact error where a
166/// parser-only build turned the renderer off.
167pub fn outcome<'v, T>(
168    spec: &Spec<'_>,
169    argv: &[&'v OsStr],
170    parse_from: impl FnOnce(&[&'v OsStr]) -> Result<T, Error<'static, 'v>>,
171) -> Outcome<T> {
172    match parse_from(argv) {
173        Ok(parsed) => Outcome::Parsed(parsed),
174        Err(Error::Version { long }) => {
175            let bin = spec.bin.unwrap_or(spec.name);
176            let version = if long {
177                spec.long_version.or(spec.version)
178            } else {
179                spec.version
180            };
181            Outcome::Version(Printed {
182                text: format!("{bin} {}\n", version.unwrap_or_default()),
183                stderr: false,
184                code: 0,
185            })
186        }
187        Err(Error::Help { cmd, long }) => Outcome::Help(page(
188            spec,
189            argv,
190            cmd,
191            if long { Page::Long } else { Page::Short },
192            false,
193            0,
194        )),
195        // Help nobody asked for, because the command line was empty: stderr, and clap's status.
196        Err(Error::MissingArgsHelp { cmd }) => {
197            Outcome::Help(page(spec, argv, cmd, Page::Short, true, 2))
198        }
199        Err(Error::HelpAll { cmd }) => Outcome::Help(page(spec, argv, cmd, Page::All, false, 0)),
200        Err(error) => Outcome::Failed(Printed {
201            // The plain renderer, not the process one: `render_failure` asks the environment
202            // whether to colour, and a test whose expected string depends on whether stderr is
203            // a terminal is a test that passes in one place and fails in the other.
204            text: usage_argv::render_failure_plain(spec, argv, &error),
205            stderr: true,
206            code: 2,
207        }),
208    }
209}
210
211/// The parsed struct, or the text a user would have read instead.
212///
213/// [`outcome`] without the four-way distinction, for the common test: parse this, and if it
214/// does not parse, show me what the user gets. A help or version request is text here too,
215/// since neither is a parsed struct.
216pub fn parse<'v, T>(
217    spec: &Spec<'_>,
218    argv: &[&'v OsStr],
219    parse_from: impl FnOnce(&[&'v OsStr]) -> Result<T, Error<'static, 'v>>,
220) -> Result<T, String> {
221    match outcome(spec, argv, parse_from) {
222        Outcome::Parsed(parsed) => Ok(parsed),
223        other => Err(other.text().unwrap_or_default().to_string()),
224    }
225}
226
227/// One command's help page, found by the words a user would type.
228///
229/// `path` names the command without the binary: `&[]` is the root, `&["config", "ls"]` is a
230/// nested one. Aliases work, because a test should be able to ask the way a user would.
231///
232/// Panics when the path names no command, listing what the parent does have — a test naming a
233/// command that was renamed should say so, not quietly assert about the wrong page.
234pub fn help(spec: &Spec<'_>, path: &[&str], want: Page) -> String {
235    let route = route(spec, path);
236    render(spec, &route, want).expect("a route built from the spec's own tables should render")
237}
238
239/// Every command's help page, depth-first, in one string.
240///
241/// The drift test: snapshot this, and any change to any page of any command — a flag's help,
242/// a new subcommand, a heading that moved — shows up as a diff in one place. Hidden commands
243/// are included and marked, since a hidden command still has a page that can regress.
244pub fn help_tree(spec: &Spec<'_>, want: Page) -> String {
245    let mut out = String::new();
246    let bin = spec.bin.unwrap_or(spec.name);
247    let mut stack = vec![(vec![bin], vec![spec.root])];
248    while let Some((path, chain)) = stack.pop() {
249        let meta = *chain
250            .last()
251            .expect("a chain always holds the command it describes");
252        let route: Vec<&Command<'_>> = chain.iter().map(|meta| meta.cmd).collect();
253        if !out.is_empty() {
254            out.push('\n');
255        }
256        out.push_str("=== ");
257        out.push_str(&path.join(" "));
258        if meta.hide {
259            out.push_str(" (hidden)");
260        }
261        out.push_str(" ===\n");
262        if let Some(page) = render(spec, &route, want) {
263            out.push_str(&page);
264        }
265
266        // Reversed, because the stack pops backwards and a reader expects declaration order.
267        for sub in meta.subcommands.iter().rev() {
268            let mut path = path.clone();
269            path.push(sub.cmd.name);
270            let mut chain = chain.clone();
271            chain.push(sub);
272            stack.push((path, chain));
273        }
274    }
275    out
276}
277
278/// What a shell would be offered for a half-typed line, cursor at the end.
279///
280/// The line includes the program name, exactly as a shell passes it: `"ex config --for"`.
281#[cfg(feature = "completions")]
282pub fn candidates(spec: &Spec<'_>, line: &str) -> Vec<String> {
283    described(spec, line)
284        .into_iter()
285        .map(|(value, _)| value)
286        .collect()
287}
288
289/// [`candidates`], with the description a shell shows beside each one.
290#[cfg(feature = "completions")]
291pub fn described(spec: &Spec<'_>, line: &str) -> Vec<(String, Option<String>)> {
292    let split = usage_argv::complete::split(line, line.len(), Shell::Bash);
293    usage_argv::complete::candidates(spec, &split)
294        .into_iter()
295        .map(|candidate| {
296            (
297                candidate.value,
298                candidate.description.map(|text| text.into_owned()),
299            )
300        })
301        .collect()
302}
303
304/// The whole completion answer, including whether the position admits paths.
305///
306/// [`candidates`] is the common question; this is for a test about the other half — that a
307/// `<PATH>` argument offers files and a `--jobs` value does not.
308#[cfg(feature = "completions")]
309pub fn completion<'a>(spec: &'a Spec<'a>, line: &str) -> Completions<'a> {
310    completion_at(spec, line, line.len(), Shell::Bash)
311}
312
313/// [`completion`] with the cursor and the shell spelled out.
314///
315/// `cursor` is a byte offset into `line`, which is how a shell reports it — and what makes a
316/// test about completing in the *middle* of a command line possible at all.
317#[cfg(feature = "completions")]
318pub fn completion_at<'a>(
319    spec: &'a Spec<'a>,
320    line: &str,
321    cursor: usize,
322    shell: Shell,
323) -> Completions<'a> {
324    let split = usage_argv::complete::split(line, cursor, shell);
325    usage_argv::complete::complete(spec, &split)
326}
327
328/// The page a help request becomes, as a [`Printed`].
329fn page(
330    spec: &Spec<'_>,
331    argv: &[&OsStr],
332    cmd: &Command<'_>,
333    want: Page,
334    stderr: bool,
335    code: i32,
336) -> Printed {
337    // The command a request arrived at is not a route: the same `Subcommands` type mounted
338    // twice is one address. `help::page` rebuilds the route from the words, which is what the
339    // program does with the same argv.
340    let text = usage_argv::help::page(spec, spec.root.cmd, argv, cmd, want, Style::PLAIN)
341        .unwrap_or_default();
342    Printed { text, stderr, code }
343}
344
345fn render(spec: &Spec<'_>, route: &[&Command<'_>], want: Page) -> Option<String> {
346    match want {
347        Page::Short => usage_argv::help::render_at_styled(spec, route, false, Style::PLAIN),
348        Page::Long => usage_argv::help::render_at_styled(spec, route, true, Style::PLAIN),
349        Page::All => usage_argv::help::render_all_at_styled(spec, route, Style::PLAIN),
350    }
351}
352
353/// The route a path names, by name and then by alias, one level at a time.
354fn route<'a>(spec: &Spec<'a>, path: &[&str]) -> Vec<&'a Command<'a>> {
355    let mut here: &'a CommandMeta<'a> = spec.root;
356    let mut route = vec![here.cmd];
357    for name in path {
358        let next = here
359            .subcommands
360            .iter()
361            .find(|sub| sub.cmd.name == *name)
362            .or_else(|| {
363                here.subcommands
364                    .iter()
365                    .find(|sub| sub.cmd.aliases.contains(name))
366            });
367        let Some(next) = next else {
368            panic!(
369                "{:?} names no subcommand of {:?}; it has {:?}",
370                name,
371                here.cmd.name,
372                here.subcommands
373                    .iter()
374                    .map(|sub| sub.cmd.name)
375                    .collect::<Vec<_>>()
376            )
377        };
378        here = next;
379        route.push(here.cmd);
380    }
381    route
382}