Skip to main content

rich_ext/cli_doc/
spec.rs

1//! The command description model: plain data with builder methods.
2
3/// What an argument's value is, for help hints and shell completion.
4#[derive(Clone, Debug, Default, PartialEq, Eq)]
5pub enum ValueHint {
6    /// A flag: takes no value.
7    #[default]
8    None,
9    /// Any value; completion offers nothing specific.
10    Any,
11    /// A file path.
12    File,
13    /// A directory path.
14    Dir,
15    /// A file or directory path.
16    Path,
17    /// A command name.
18    Command,
19    /// A URL.
20    Url,
21    /// One of a fixed set of values.
22    Choices(Vec<Choice>),
23}
24
25/// One allowed value, with optional help.
26#[derive(Clone, Debug, Default, PartialEq, Eq)]
27pub struct Choice {
28    pub value: String,
29    /// Empty when the value needs no explanation.
30    pub help: String,
31}
32
33impl Choice {
34    pub fn new(value: impl Into<String>) -> Self {
35        Choice {
36            value: value.into(),
37            help: String::new(),
38        }
39    }
40    pub fn help(mut self, help: impl Into<String>) -> Self {
41        self.help = help.into();
42        self
43    }
44}
45
46impl From<&str> for Choice {
47    fn from(value: &str) -> Self {
48        Choice::new(value)
49    }
50}
51
52impl<V: Into<String>, H: Into<String>> From<(V, H)> for Choice {
53    fn from((value, help): (V, H)) -> Self {
54        Choice::new(value).help(help)
55    }
56}
57
58/// An option, flag or positional argument.
59///
60/// ```
61/// use rich_ext::cli_doc::{ArgSpec, ValueHint};
62///
63/// let color = ArgSpec::option("color")
64///     .value_name("WHEN")
65///     .choices(["auto", "always", "never"])
66///     .default_value("auto")
67///     .env("RICH_COLOR")
68///     .help("When to use colour");
69/// assert_eq!(color.names(), "--color <WHEN>");
70/// assert!(matches!(color.value, ValueHint::Choices(ref c) if c.len() == 3));
71/// ```
72#[derive(Clone, Debug, Default, PartialEq, Eq)]
73pub struct ArgSpec {
74    /// A stable identifier; also the default value name.
75    pub id: String,
76    /// The long name, without dashes.
77    pub long: Option<String>,
78    pub short: Option<char>,
79    /// Extra short names, without the dash (`-J` beside `-j`).
80    pub short_aliases: Vec<char>,
81    /// Extra long names, without dashes.
82    pub aliases: Vec<String>,
83    /// The metavar shown as `<VALUE>`. `None` makes the argument a flag.
84    pub value_name: Option<String>,
85    pub value: ValueHint,
86    pub help: String,
87    /// Help for `--help` (long) output; `help` is used when absent.
88    pub long_help: Option<String>,
89    pub default: Option<String>,
90    /// The environment variable that sets this argument.
91    pub env: Option<String>,
92    /// The config file key that sets this argument.
93    pub config_key: Option<String>,
94    pub required: bool,
95    /// Whether the argument may be given more than once (or takes a list).
96    pub multiple: bool,
97    /// Hidden arguments are left out of help, docs and completions.
98    pub hidden: bool,
99    /// The help group title; `None` means "Options" (or "Arguments" for a
100    /// positional).
101    pub heading: Option<String>,
102    pub positional: bool,
103    /// A global option applies to every subcommand below the command that
104    /// declares it, so completions offer it there too (clap's
105    /// `Arg::global`).
106    pub global: bool,
107}
108
109impl ArgSpec {
110    /// A bare argument with only an id; set the rest with the builders.
111    pub fn new(id: impl Into<String>) -> Self {
112        ArgSpec {
113            id: id.into(),
114            ..Default::default()
115        }
116    }
117
118    /// A `--long` flag that takes no value.
119    pub fn flag(long: impl Into<String>) -> Self {
120        let long = long.into();
121        ArgSpec {
122            id: long.clone(),
123            long: Some(long),
124            ..Default::default()
125        }
126    }
127
128    /// A `--long <LONG>` option taking any value.
129    pub fn option(long: impl Into<String>) -> Self {
130        let long = long.into();
131        ArgSpec {
132            id: long.clone(),
133            value_name: Some(long.to_uppercase().replace('-', "_")),
134            long: Some(long),
135            value: ValueHint::Any,
136            ..Default::default()
137        }
138    }
139
140    /// A positional argument shown as `[NAME]`, or `<NAME>` when required.
141    pub fn positional(name: impl Into<String>) -> Self {
142        let name = name.into();
143        ArgSpec {
144            value_name: Some(name.to_uppercase().replace('-', "_")),
145            id: name,
146            value: ValueHint::Any,
147            positional: true,
148            ..Default::default()
149        }
150    }
151
152    pub fn long(mut self, long: impl Into<String>) -> Self {
153        self.long = Some(long.into());
154        self
155    }
156    pub fn short(mut self, short: char) -> Self {
157        self.short = Some(short);
158        self
159    }
160    pub fn alias(mut self, alias: impl Into<String>) -> Self {
161        self.aliases.push(alias.into());
162        self
163    }
164    /// Another short name, listed after [`short`](Self::short).
165    pub fn short_alias(mut self, short: char) -> Self {
166        self.short_aliases.push(short);
167        self
168    }
169    /// Set the metavar; this also makes a flag take a value.
170    pub fn value_name(mut self, name: impl Into<String>) -> Self {
171        self.value_name = Some(name.into());
172        if self.value == ValueHint::None {
173            self.value = ValueHint::Any;
174        }
175        self
176    }
177    /// Set the value hint; a non-`None` hint makes a flag take a value.
178    pub fn value(mut self, hint: ValueHint) -> Self {
179        if hint != ValueHint::None && self.value_name.is_none() {
180            self.value_name = Some(self.id.to_uppercase().replace('-', "_"));
181        }
182        self.value = hint;
183        self
184    }
185    /// Restrict the value to `choices`.
186    pub fn choices<C: Into<Choice>>(self, choices: impl IntoIterator<Item = C>) -> Self {
187        self.value(ValueHint::Choices(
188            choices.into_iter().map(Into::into).collect(),
189        ))
190    }
191    /// Add one allowed value with help.
192    pub fn choice(mut self, value: impl Into<String>, help: impl Into<String>) -> Self {
193        let choice = Choice::new(value).help(help);
194        if let ValueHint::Choices(choices) = &mut self.value {
195            choices.push(choice);
196            return self;
197        }
198        self.value(ValueHint::Choices(vec![choice]))
199    }
200    pub fn help(mut self, help: impl Into<String>) -> Self {
201        self.help = help.into();
202        self
203    }
204    pub fn long_help(mut self, help: impl Into<String>) -> Self {
205        self.long_help = Some(help.into());
206        self
207    }
208    pub fn default_value(mut self, value: impl Into<String>) -> Self {
209        self.default = Some(value.into());
210        self
211    }
212    pub fn env(mut self, name: impl Into<String>) -> Self {
213        self.env = Some(name.into());
214        self
215    }
216    pub fn config_key(mut self, key: impl Into<String>) -> Self {
217        self.config_key = Some(key.into());
218        self
219    }
220    pub fn required(mut self, required: bool) -> Self {
221        self.required = required;
222        self
223    }
224    pub fn multiple(mut self, multiple: bool) -> Self {
225        self.multiple = multiple;
226        self
227    }
228    pub fn hidden(mut self, hidden: bool) -> Self {
229        self.hidden = hidden;
230        self
231    }
232    /// Offer this option in every subcommand below its command, too.
233    pub fn global(mut self, global: bool) -> Self {
234        self.global = global;
235        self
236    }
237    pub fn heading(mut self, heading: impl Into<String>) -> Self {
238        self.heading = Some(heading.into());
239        self
240    }
241
242    /// Whether the argument takes a value.
243    pub fn takes_value(&self) -> bool {
244        self.value_name.is_some()
245    }
246
247    /// The allowed values, if restricted.
248    pub fn choice_list(&self) -> &[Choice] {
249        match &self.value {
250            ValueHint::Choices(choices) => choices,
251            _ => &[],
252        }
253    }
254
255    /// The help group this argument belongs to.
256    pub fn group(&self) -> &str {
257        match (&self.heading, self.positional) {
258            (Some(heading), _) => heading,
259            (None, true) => "Arguments",
260            (None, false) => "Options",
261        }
262    }
263
264    /// The metavar as displayed: `<NAME>` (plus `...` when multiple), or the
265    /// name unchanged when it already carries brackets.
266    pub fn metavar(&self) -> Option<String> {
267        let name = self.value_name.as_deref()?;
268        let mut out = if name.starts_with(['<', '[']) {
269            name.to_string()
270        } else if self.positional && !self.required {
271            format!("[{name}]")
272        } else {
273            format!("<{name}>")
274        };
275        if self.multiple {
276            out.push_str("...");
277        }
278        Some(out)
279    }
280
281    /// Every command-line spelling: `-s` and its short aliases, `--long`,
282    /// then `--alias`es.
283    pub fn switches(&self) -> Vec<String> {
284        let mut out = Vec::new();
285        if let Some(short) = self.short {
286            out.push(format!("-{short}"));
287        }
288        out.extend(self.short_aliases.iter().map(|short| format!("-{short}")));
289        if let Some(long) = &self.long {
290            out.push(format!("--{long}"));
291        }
292        out.extend(self.aliases.iter().map(|alias| format!("--{alias}")));
293        out
294    }
295
296    /// The names as help shows them: `-w, --width <SIZE>`; a positional shows
297    /// only its metavar.
298    pub fn names(&self) -> String {
299        if self.positional {
300            return self.metavar().unwrap_or_else(|| self.id.clone());
301        }
302        let mut out = self.switches().join(", ");
303        if let Some(metavar) = self.metavar() {
304            out.push(' ');
305            out.push_str(&metavar);
306        }
307        out
308    }
309
310    /// The primary switch: `--long`, else `-s`, else the id.
311    pub fn primary(&self) -> String {
312        match (&self.long, self.short) {
313            (Some(long), _) => format!("--{long}"),
314            (None, Some(short)) => format!("-{short}"),
315            _ => self.id.clone(),
316        }
317    }
318}
319
320/// A usage example: a command line and what it does.
321#[derive(Clone, Debug, Default, PartialEq, Eq)]
322pub struct Example {
323    pub command: String,
324    pub description: String,
325}
326
327/// A free-form help section. Its body is plain text; a blank line separates
328/// paragraphs. An empty title renders the body without a heading.
329#[derive(Clone, Debug, Default, PartialEq, Eq)]
330pub struct Section {
331    pub title: String,
332    pub body: String,
333}
334
335/// A note shown under an option group's heading, such as "choose at most one".
336#[derive(Clone, Debug, Default, PartialEq, Eq)]
337pub struct HeadingNote {
338    pub heading: String,
339    pub text: String,
340}
341
342/// A command (or subcommand) and everything its documentation needs.
343///
344/// ```
345/// use rich_ext::cli_doc::{ArgSpec, CommandSpec};
346///
347/// let spec = CommandSpec::new("rich")
348///     .version("1.0.0")
349///     .about("Render files in the terminal")
350///     .arg(ArgSpec::flag("pager").help("Page the output"))
351///     .arg(ArgSpec::positional("resource"))
352///     .subcommand(CommandSpec::new("config").about("Show settings"));
353/// assert_eq!(spec.usage_lines(), vec!["rich [OPTIONS] [RESOURCE] [COMMAND]".to_string()]);
354/// ```
355#[derive(Clone, Debug, Default, PartialEq, Eq)]
356pub struct CommandSpec {
357    pub name: String,
358    /// The executable name, when it differs from `name`.
359    pub bin_name: Option<String>,
360    pub version: Option<String>,
361    pub about: String,
362    pub long_about: Option<String>,
363    /// Other names a subcommand answers to.
364    pub aliases: Vec<String>,
365    /// Explicit usage lines; one is generated from the arguments when empty.
366    pub usage: Vec<String>,
367    pub args: Vec<ArgSpec>,
368    pub subcommands: Vec<CommandSpec>,
369    pub examples: Vec<Example>,
370    pub sections: Vec<Section>,
371    pub heading_notes: Vec<HeadingNote>,
372    /// The title of the subcommand list; `None` means "Commands".
373    pub subcommand_heading: Option<String>,
374    /// Whether a subcommand must be given (`<COMMAND>` rather than
375    /// `[COMMAND]` in generated usage).
376    pub subcommand_required: bool,
377    /// Hidden subcommands are left out of help, docs and completions.
378    pub hidden: bool,
379}
380
381impl CommandSpec {
382    pub fn new(name: impl Into<String>) -> Self {
383        CommandSpec {
384            name: name.into(),
385            ..Default::default()
386        }
387    }
388    pub fn bin_name(mut self, name: impl Into<String>) -> Self {
389        self.bin_name = Some(name.into());
390        self
391    }
392    pub fn version(mut self, version: impl Into<String>) -> Self {
393        self.version = Some(version.into());
394        self
395    }
396    pub fn about(mut self, about: impl Into<String>) -> Self {
397        self.about = about.into();
398        self
399    }
400    pub fn long_about(mut self, about: impl Into<String>) -> Self {
401        self.long_about = Some(about.into());
402        self
403    }
404    pub fn alias(mut self, alias: impl Into<String>) -> Self {
405        self.aliases.push(alias.into());
406        self
407    }
408    /// Add an explicit usage line (without the `Usage:` label).
409    pub fn usage(mut self, line: impl Into<String>) -> Self {
410        self.usage.push(line.into());
411        self
412    }
413    pub fn arg(mut self, arg: ArgSpec) -> Self {
414        self.args.push(arg);
415        self
416    }
417    pub fn args(mut self, args: impl IntoIterator<Item = ArgSpec>) -> Self {
418        self.args.extend(args);
419        self
420    }
421    pub fn subcommand(mut self, command: CommandSpec) -> Self {
422        self.subcommands.push(command);
423        self
424    }
425    pub fn example(mut self, command: impl Into<String>, description: impl Into<String>) -> Self {
426        self.examples.push(Example {
427            command: command.into(),
428            description: description.into(),
429        });
430        self
431    }
432    pub fn section(mut self, title: impl Into<String>, body: impl Into<String>) -> Self {
433        self.sections.push(Section {
434            title: title.into(),
435            body: body.into(),
436        });
437        self
438    }
439    /// Add a note shown under the option group titled `heading`.
440    pub fn heading_note(mut self, heading: impl Into<String>, text: impl Into<String>) -> Self {
441        self.heading_notes.push(HeadingNote {
442            heading: heading.into(),
443            text: text.into(),
444        });
445        self
446    }
447    pub fn subcommand_heading(mut self, heading: impl Into<String>) -> Self {
448        self.subcommand_heading = Some(heading.into());
449        self
450    }
451    pub fn subcommand_required(mut self, required: bool) -> Self {
452        self.subcommand_required = required;
453        self
454    }
455    pub fn hidden(mut self, hidden: bool) -> Self {
456        self.hidden = hidden;
457        self
458    }
459
460    /// The name the command is invoked by: `bin_name`, else `name`.
461    pub fn display_name(&self) -> &str {
462        self.bin_name.as_deref().unwrap_or(&self.name)
463    }
464
465    /// The arguments that are not hidden.
466    pub fn visible_args(&self) -> impl Iterator<Item = &ArgSpec> {
467        self.args.iter().filter(|arg| !arg.hidden)
468    }
469
470    /// The subcommands that are not hidden.
471    pub fn visible_subcommands(&self) -> impl Iterator<Item = &CommandSpec> {
472        self.subcommands.iter().filter(|command| !command.hidden)
473    }
474
475    /// The subcommand called (or aliased) `name`.
476    pub fn find_subcommand(&self, name: &str) -> Option<&CommandSpec> {
477        self.subcommands
478            .iter()
479            .find(|c| c.name == name || c.aliases.iter().any(|a| a == name))
480    }
481
482    /// The visible option groups in first-seen order, each with its arguments.
483    /// Positionals without a heading form a trailing "Arguments" group.
484    pub fn groups(&self) -> Vec<(String, Vec<&ArgSpec>)> {
485        let mut groups: Vec<(String, Vec<&ArgSpec>)> = Vec::new();
486        let mut positionals = Vec::new();
487        for arg in self.visible_args() {
488            if arg.positional && arg.heading.is_none() {
489                positionals.push(arg);
490                continue;
491            }
492            match groups.iter_mut().find(|(title, _)| title == arg.group()) {
493                Some((_, members)) => members.push(arg),
494                None => groups.push((arg.group().to_string(), vec![arg])),
495            }
496        }
497        if !positionals.is_empty() {
498            groups.push(("Arguments".to_string(), positionals));
499        }
500        groups
501    }
502
503    /// The note for the group titled `heading`, if any.
504    pub fn note_for(&self, heading: &str) -> Option<&str> {
505        self.heading_notes
506            .iter()
507            .find(|note| note.heading == heading)
508            .map(|note| note.text.as_str())
509    }
510
511    /// The usage lines, generated when none were given.
512    pub fn usage_lines(&self) -> Vec<String> {
513        self.usage_lines_as(self.display_name())
514    }
515
516    /// The usage lines with `prefix` (such as `rich config`) as the command.
517    /// Explicit lines starting with this command's name get the prefix
518    /// substituted; generated lines are
519    /// `prefix [OPTIONS] <REQUIRED OPTIONS> [POSITIONALS] [COMMAND]`.
520    pub fn usage_lines_as(&self, prefix: &str) -> Vec<String> {
521        if !self.usage.is_empty() {
522            let own = self.display_name();
523            return self
524                .usage
525                .iter()
526                .map(|line| match line.strip_prefix(own) {
527                    Some(rest) if rest.is_empty() || rest.starts_with(' ') => {
528                        format!("{prefix}{rest}")
529                    }
530                    _ => line.clone(),
531                })
532                .collect();
533        }
534        let mut parts = vec![prefix.to_string()];
535        if self
536            .visible_args()
537            .any(|arg| !arg.positional && !arg.required)
538        {
539            parts.push("[OPTIONS]".into());
540        }
541        for arg in self.visible_args().filter(|a| !a.positional && a.required) {
542            match arg.metavar() {
543                Some(metavar) => parts.push(format!("{} {metavar}", arg.primary())),
544                None => parts.push(arg.primary()),
545            }
546        }
547        for arg in self.visible_args().filter(|a| a.positional) {
548            parts.push(arg.names());
549        }
550        if self.visible_subcommands().next().is_some() {
551            parts.push(
552                if self.subcommand_required {
553                    "<COMMAND>"
554                } else {
555                    "[COMMAND]"
556                }
557                .into(),
558            );
559        }
560        vec![parts.join(" ")]
561    }
562
563    /// Every visible `-s`/`--long` spelling, for suggestions.
564    pub fn switch_names(&self) -> Vec<String> {
565        self.visible_args()
566            .filter(|arg| !arg.positional)
567            .flat_map(ArgSpec::switches)
568            .collect()
569    }
570
571    /// Every visible subcommand name and alias, for suggestions.
572    pub fn subcommand_names(&self) -> Vec<String> {
573        self.visible_subcommands()
574            .flat_map(|c| std::iter::once(c.name.clone()).chain(c.aliases.iter().cloned()))
575            .collect()
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[test]
584    fn names_and_metavars() {
585        let arg = ArgSpec::option("width").short('w').value_name("SIZE");
586        assert_eq!(arg.names(), "-w, --width <SIZE>");
587        let files = ArgSpec::positional("file").multiple(true).required(true);
588        assert_eq!(files.names(), "<FILE>...");
589        assert_eq!(ArgSpec::positional("x").names(), "[X]");
590        assert_eq!(
591            ArgSpec::flag("no-color").alias("no-colour").names(),
592            "--no-color, --no-colour"
593        );
594    }
595
596    #[test]
597    fn groups_keep_first_seen_order() {
598        let spec = CommandSpec::new("x")
599            .arg(ArgSpec::flag("a").heading("B"))
600            .arg(ArgSpec::positional("p"))
601            .arg(ArgSpec::flag("b"))
602            .arg(ArgSpec::flag("c").heading("B"))
603            .arg(ArgSpec::flag("h").hidden(true));
604        let titles: Vec<_> = spec
605            .groups()
606            .into_iter()
607            .map(|(t, m)| (t, m.len()))
608            .collect();
609        assert_eq!(
610            titles,
611            vec![
612                ("B".into(), 2),
613                ("Options".into(), 1),
614                ("Arguments".into(), 1)
615            ]
616        );
617    }
618
619    #[test]
620    fn explicit_usage_takes_the_prefix() {
621        let spec = CommandSpec::new("show").usage("show [KEY]").usage("other");
622        assert_eq!(
623            spec.usage_lines_as("rich config show"),
624            vec!["rich config show [KEY]", "other"]
625        );
626    }
627
628    #[test]
629    fn generated_usage_lists_required_options() {
630        let spec = CommandSpec::new("x")
631            .arg(ArgSpec::option("out").short('o').required(true))
632            .arg(ArgSpec::positional("in").required(true));
633        assert_eq!(spec.usage_lines(), vec!["x --out <OUT> <IN>"]);
634    }
635}