Skip to main content

brb_cli/
cli.rs

1use clap::{ArgAction, Command, CommandFactory, FromArgMatches, Parser, Subcommand};
2use thiserror::Error;
3
4/// High-level action parsed from CLI arguments.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum Action {
7    /// Initialise global config.
8    Init,
9
10    /// Run a channels management subcommand.
11    Channels(ChannelsAction),
12
13    /// Run a config management subcommand.
14    Config(ConfigAction),
15
16    /// Run a wrapped command.
17    Run(RunArgs),
18
19    /// Print help text.
20    Help,
21
22    /// Print version text.
23    Version,
24}
25
26/// Command execution arguments.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct RunArgs {
29    /// Explicit channel IDs requested by repeated `--channel` flags.
30    pub channels: Vec<String>,
31
32    /// Command and arguments to execute.
33    pub command: Vec<String>,
34}
35
36/// `brb channels` subcommands.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ChannelsAction {
39    /// List configured channels.
40    List,
41
42    /// Validate config.
43    Validate,
44
45    /// Send a test notification to one channel.
46    Test { channel_id: String },
47}
48
49/// `brb config` subcommands.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum ConfigAction {
52    /// Print config file path.
53    Path,
54}
55
56/// CLI parsing errors for invalid user input.
57#[derive(Debug, Error)]
58pub enum CliError {
59    #[error("`brb channels test` requires a <channel-id>")]
60    MissingChannelId,
61    #[error("`--channel` requires a value")]
62    MissingChannelFlagValue,
63    #[error("no command provided")]
64    MissingCommand,
65    #[error("{0}")]
66    Clap(String),
67}
68
69#[derive(Debug, Parser)]
70#[command(
71    name = "brb",
72    about = "run a command and notify when it completes",
73    long_about = None,
74    disable_help_subcommand = true
75)]
76struct CliArgs {
77    /// Repeated channel override for wrapped command execution.
78    #[arg(long = "channel", value_name = "channel-id", action = ArgAction::Append)]
79    channels: Vec<String>,
80
81    /// Built-in management subcommands.
82    #[command(subcommand)]
83    subcommand: Option<CliCommand>,
84
85    /// Wrapped command and args.
86    #[arg(
87        value_name = "command",
88        trailing_var_arg = true,
89        allow_hyphen_values = true
90    )]
91    command: Vec<String>,
92}
93
94#[derive(Debug, Subcommand)]
95enum CliCommand {
96    /// Initialise global config.
97    Init,
98
99    /// Run channels management commands.
100    Channels {
101        #[command(subcommand)]
102        action: Option<CliChannelsAction>,
103    },
104
105    /// Run config management commands.
106    Config {
107        #[command(subcommand)]
108        action: Option<CliConfigAction>,
109    },
110}
111
112#[derive(Debug, Subcommand)]
113enum CliChannelsAction {
114    /// List configured channels.
115    List,
116
117    /// Validate config.
118    Validate,
119
120    /// Send a test notification to one channel.
121    Test {
122        /// Channel identifier.
123        #[arg(value_name = "channel-id")]
124        channel_id: String,
125    },
126}
127
128#[derive(Debug, Subcommand)]
129enum CliConfigAction {
130    /// Print config file path.
131    Path,
132}
133
134/// Returns clap-generated help text.
135pub fn usage() -> String {
136    cli_command().render_long_help().to_string()
137}
138
139/// Parses CLI args into a structured action.
140pub fn parse_args(args: Vec<String>) -> Result<Action, CliError> {
141    if args.is_empty() {
142        return Ok(Action::Help);
143    }
144
145    let first = args[0].as_str();
146    if matches!(first, "-h" | "--help") {
147        return Ok(Action::Help);
148    }
149
150    if matches!(first, "-V" | "--version") {
151        return Ok(Action::Version);
152    }
153
154    if first == "channels"
155        && args.get(1).map(String::as_str) == Some("test")
156        && args.get(2).is_none()
157    {
158        return Err(CliError::MissingChannelId);
159    }
160
161    if args.last().map(String::as_str) == Some("--channel") {
162        return Err(CliError::MissingChannelFlagValue);
163    }
164
165    let mut argv = Vec::with_capacity(args.len() + 1);
166    argv.push("brb".to_string());
167    argv.extend(args);
168
169    let matches = cli_command()
170        .try_get_matches_from(argv)
171        .map_err(|error| CliError::Clap(error.to_string()))?;
172    let parsed =
173        CliArgs::from_arg_matches(&matches).map_err(|error| CliError::Clap(error.to_string()))?;
174
175    if let Some(subcommand) = parsed.subcommand {
176        return match subcommand {
177            CliCommand::Init => Ok(Action::Init),
178            CliCommand::Channels { action } => {
179                let action = match action {
180                    Some(CliChannelsAction::List) | None => ChannelsAction::List,
181                    Some(CliChannelsAction::Validate) => ChannelsAction::Validate,
182                    Some(CliChannelsAction::Test { channel_id }) => {
183                        ChannelsAction::Test { channel_id }
184                    }
185                };
186                Ok(Action::Channels(action))
187            }
188            CliCommand::Config { action } => {
189                let action = match action {
190                    Some(CliConfigAction::Path) | None => ConfigAction::Path,
191                };
192                Ok(Action::Config(action))
193            }
194        };
195    }
196
197    if parsed.command.is_empty() {
198        return Err(CliError::MissingCommand);
199    }
200
201    Ok(Action::Run(RunArgs {
202        channels: parsed.channels,
203        command: parsed.command,
204    }))
205}
206
207fn cli_command() -> Command {
208    CliArgs::command().override_usage(include_str!("../assets/usage.txt").trim_end())
209}