Skip to main content

ready_set/
cli.rs

1//! Argument parsing for the dispatcher's meta-command surface only.
2//!
3//! Plugin args are passed through verbatim; clap is **not** used to validate
4//! plugin args. The dispatcher recognizes:
5//!
6//! * `--help`, `-h`
7//! * `--version`, `-V`
8//! * `--list` (with optional `--all`)
9//! * Global flags that adjust the env contract: `--quiet`, `--verbose`,
10//!   `--json`, `--color <auto|always|never>`. These are consumed by the
11//!   dispatcher *only* if they appear before the subcommand name; once a
12//!   subcommand is identified, all subsequent args go to the plugin.
13//!
14//! This is similar to how `cargo` and `git` parse their meta flags.
15
16use std::ffi::{OsStr, OsString};
17
18use ready_set_sdk::OutputMode;
19use ready_set_sdk::context::{ColorMode, LogLevel};
20
21/// Result of parsing the dispatcher's meta command line.
22#[derive(Debug)]
23pub enum ParsedArgs {
24    /// Show meta help.
25    Help,
26    /// Show dispatcher version.
27    Version,
28    /// List built-ins and discovered plugins.
29    List {
30        /// Whether to include plugins whose `platforms` exclude the current OS.
31        all: bool,
32        /// Globals collected before/around `--list`.
33        globals: GlobalFlags,
34    },
35    /// Resolve a subcommand (built-in or plugin) and pass through remaining args.
36    Subcommand {
37        /// Subcommand name without the `ready-set-` prefix.
38        name: String,
39        /// Args to forward to the subcommand.
40        args: Vec<OsString>,
41        /// Global flags that affect the env contract.
42        globals: GlobalFlags,
43    },
44    /// No subcommand was provided.
45    Empty {
46        /// Globals collected before the empty boundary.
47        globals: GlobalFlags,
48    },
49}
50
51/// Global flags consumed by the dispatcher.
52#[derive(Debug, Clone, Default)]
53pub struct GlobalFlags {
54    /// Output mode override.
55    pub output: Option<OutputMode>,
56    /// Log level override.
57    pub log: Option<LogLevel>,
58    /// Color mode override.
59    pub color: Option<ColorMode>,
60}
61
62/// Parse the dispatcher's meta-command surface from `args`.
63///
64/// `args` should start with the program name (`argv\[0\]`) for parity with
65/// `std::env::args_os()`.
66#[must_use]
67pub fn parse(mut args: impl Iterator<Item = OsString>) -> ParsedArgs {
68    drop(args.next()); // skip program name
69
70    let mut globals = GlobalFlags::default();
71    let mut list_seen = false;
72    let mut list_all = false;
73
74    while let Some(arg) = args.next() {
75        if arg == OsStr::new("--help") || arg == OsStr::new("-h") {
76            return ParsedArgs::Help;
77        }
78        if arg == OsStr::new("--version") || arg == OsStr::new("-V") {
79            return ParsedArgs::Version;
80        }
81        if arg == OsStr::new("--list") {
82            list_seen = true;
83            continue;
84        }
85        if list_seen && arg == OsStr::new("--all") {
86            list_all = true;
87            continue;
88        }
89        if arg == OsStr::new("--quiet") {
90            globals.log = Some(LogLevel::Quiet);
91            continue;
92        }
93        if arg == OsStr::new("--verbose") {
94            globals.log = Some(LogLevel::Verbose);
95            continue;
96        }
97        if arg == OsStr::new("--json") {
98            globals.output = Some(OutputMode::Json);
99            continue;
100        }
101        if arg == OsStr::new("--color") {
102            if let Some(value) = args.next() {
103                globals.color = Some(match value.to_string_lossy().as_ref() {
104                    "always" => ColorMode::Always,
105                    "never" => ColorMode::Never,
106                    _ => ColorMode::Auto,
107                });
108            }
109            continue;
110        }
111
112        // Anything else: treat as the subcommand name.
113        if list_seen {
114            // `--list <subcommand>` is unusual; fall through to subcommand.
115        }
116        let name = arg.to_string_lossy().into_owned();
117        let rest: Vec<OsString> = args.collect();
118        return ParsedArgs::Subcommand {
119            name,
120            args: rest,
121            globals,
122        };
123    }
124
125    if list_seen {
126        return ParsedArgs::List {
127            all: list_all,
128            globals,
129        };
130    }
131    ParsedArgs::Empty { globals }
132}
133
134#[cfg(test)]
135#[allow(clippy::panic, clippy::missing_panics_doc)]
136mod tests {
137    use super::*;
138
139    fn parse_str(args: &[&str]) -> ParsedArgs {
140        parse(args.iter().map(OsString::from))
141    }
142
143    #[test]
144    fn parses_help() {
145        assert!(matches!(
146            parse_str(&["ready-set", "--help"]),
147            ParsedArgs::Help
148        ));
149        assert!(matches!(parse_str(&["ready-set", "-h"]), ParsedArgs::Help));
150    }
151
152    #[test]
153    fn parses_version() {
154        assert!(matches!(
155            parse_str(&["ready-set", "--version"]),
156            ParsedArgs::Version
157        ));
158        assert!(matches!(
159            parse_str(&["ready-set", "-V"]),
160            ParsedArgs::Version
161        ));
162    }
163
164    #[test]
165    fn parses_list_with_all() {
166        let ParsedArgs::List { all, .. } = parse_str(&["ready-set", "--list", "--all"]) else {
167            panic!("expected List variant");
168        };
169        assert!(all);
170    }
171
172    #[test]
173    fn list_carries_global_flags() {
174        let ParsedArgs::List { globals, .. } = parse_str(&["ready-set", "--json", "--list"]) else {
175            panic!("expected List variant");
176        };
177        assert_eq!(globals.output, Some(OutputMode::Json));
178    }
179
180    #[test]
181    fn parses_subcommand_with_passthrough() {
182        let ParsedArgs::Subcommand { name, args, .. } =
183            parse_str(&["ready-set", "scan", "--json", "--path", "/tmp"])
184        else {
185            panic!("expected Subcommand variant");
186        };
187        assert_eq!(name, "scan");
188        assert_eq!(
189            args,
190            vec![
191                OsString::from("--json"),
192                OsString::from("--path"),
193                OsString::from("/tmp"),
194            ]
195        );
196    }
197
198    #[test]
199    fn captures_global_flags_before_subcommand() {
200        let ParsedArgs::Subcommand { name, globals, .. } =
201            parse_str(&["ready-set", "--json", "--verbose", "go"])
202        else {
203            panic!("expected Subcommand variant");
204        };
205        assert_eq!(name, "go");
206        assert_eq!(globals.output, Some(OutputMode::Json));
207        assert_eq!(globals.log, Some(LogLevel::Verbose));
208    }
209}