Skip to main content

rsomics_help/
cli.rs

1use std::ffi::OsString;
2
3use clap::builder::styling::{AnsiColor, Styles};
4use clap::{ColorChoice, Command, CommandFactory, Error, FromArgMatches};
5
6const FAMILY_STYLES: Styles = Styles::styled()
7    .header(AnsiColor::Cyan.on_default().bold())
8    .usage(AnsiColor::Green.on_default().bold())
9    .literal(AnsiColor::Cyan.on_default().bold())
10    .placeholder(AnsiColor::Yellow.on_default())
11    .error(AnsiColor::Red.on_default().bold())
12    .valid(AnsiColor::Green.on_default())
13    .invalid(AnsiColor::Yellow.on_default().bold())
14    .context(AnsiColor::BrightBlack.on_default())
15    .context_value(AnsiColor::Yellow.on_default());
16
17const MAX_HELP_WIDTH: usize = 100;
18
19/// Builds the product command tree with the family UX applied recursively.
20#[must_use]
21pub fn command<P>() -> Command
22where
23    P: CommandFactory,
24{
25    configure(P::command(), color_choice())
26}
27
28/// Parses process arguments, exiting with Clap's standard help and error codes.
29pub fn parse<P>() -> P
30where
31    P: CommandFactory + FromArgMatches,
32{
33    try_parse().unwrap_or_else(|error| error.exit())
34}
35
36/// Parses process arguments without exiting.
37pub fn try_parse<P>() -> Result<P, Error>
38where
39    P: CommandFactory + FromArgMatches,
40{
41    try_parse_from(std::env::args_os())
42}
43
44/// Parses an explicit argument iterator without exiting.
45pub fn try_parse_from<P, I, T>(arguments: I) -> Result<P, Error>
46where
47    P: CommandFactory + FromArgMatches,
48    I: IntoIterator<Item = T>,
49    T: Into<OsString> + Clone,
50{
51    let mut command = command::<P>();
52    let mut matches = command.try_get_matches_from_mut(arguments)?;
53    P::from_arg_matches_mut(&mut matches).map_err(|error| error.format(&mut command))
54}
55
56fn color_choice() -> ColorChoice {
57    if std::env::var_os("NO_COLOR").is_some() {
58        ColorChoice::Never
59    } else {
60        ColorChoice::Auto
61    }
62}
63
64fn configure(command: Command, color: ColorChoice) -> Command {
65    command
66        .styles(FAMILY_STYLES)
67        .color(color)
68        .max_term_width(MAX_HELP_WIDTH)
69        .disable_help_subcommand(false)
70        .mut_subcommands(|subcommand| configure(subcommand, color))
71}
72
73#[cfg(test)]
74mod tests {
75    use clap::{Args, Parser, Subcommand, error::ErrorKind};
76
77    use super::*;
78
79    #[derive(Debug, Parser)]
80    #[command(
81        name = "rsomics-example",
82        version = "1.2.3",
83        about = "Example product",
84        subcommand_required = true
85    )]
86    struct ExampleCli {
87        #[command(subcommand)]
88        command: ExampleCommand,
89    }
90
91    #[derive(Debug, Subcommand)]
92    enum ExampleCommand {
93        /// Inspect an input.
94        Inspect(InspectArgs),
95    }
96
97    #[derive(Debug, Args)]
98    struct InspectArgs {
99        /// Input sequence file.
100        #[arg(value_name = "FASTA")]
101        input: String,
102
103        /// Emit all records.
104        #[arg(short, long)]
105        all: bool,
106    }
107
108    #[derive(Debug, Parser)]
109    #[command(name = "value-example")]
110    struct ValueCli {
111        value: String,
112    }
113
114    #[test]
115    fn decorated_command_tree_is_valid() {
116        command::<ExampleCli>().debug_assert();
117    }
118
119    #[test]
120    fn nested_help_is_derived_from_the_real_command_tree() {
121        let error = try_parse_from::<ExampleCli, _, _>(["rsomics-example", "inspect", "--help"])
122            .unwrap_err();
123        assert_eq!(error.kind(), ErrorKind::DisplayHelp);
124        let help = error.to_string();
125        assert!(help.contains("Inspect an input"), "{help}");
126        assert!(help.contains("<FASTA>"), "{help}");
127        assert!(help.contains("--all"), "{help}");
128    }
129
130    #[test]
131    fn clap_help_subcommand_navigates_the_same_tree() {
132        let error =
133            try_parse_from::<ExampleCli, _, _>(["rsomics-example", "help", "inspect"]).unwrap_err();
134        assert_eq!(error.kind(), ErrorKind::DisplayHelp);
135        assert!(error.to_string().contains("--all"));
136    }
137
138    #[test]
139    fn parse_errors_keep_clap_context_and_suggestions() {
140        let error =
141            try_parse_from::<ExampleCli, _, _>(["rsomics-example", "inspect", "--al", "input.fa"])
142                .unwrap_err();
143        assert_eq!(error.kind(), ErrorKind::UnknownArgument);
144        let message = error.to_string();
145        assert!(message.contains("--all"), "{message}");
146        assert!(message.contains("Usage:"), "{message}");
147    }
148
149    #[test]
150    fn ordinary_help_value_is_not_intercepted() {
151        let parsed = try_parse_from::<ValueCli, _, _>(["value-example", "help"]).unwrap();
152        assert_eq!(parsed.value, "help");
153    }
154
155    #[test]
156    fn explicit_iterator_builds_the_derived_type() {
157        let parsed =
158            try_parse_from::<ExampleCli, _, _>(["rsomics-example", "inspect", "--all", "reads.fa"])
159                .unwrap();
160        let ExampleCommand::Inspect(args) = parsed.command;
161        assert_eq!(args.input, "reads.fa");
162        assert!(args.all);
163    }
164}