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}