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}