Skip to main content

mkit_cli/
clap_shim.rs

1//! Bridge between clap-derive command structs and mkit's sysexits
2//! contract.
3//!
4//! mkit's top-level dispatcher in `lib.rs` hands each subcommand a
5//! `&[String]` of remaining argv and expects a `u8` exit code back.
6//! When a subcommand opts into clap-derive parsing, it gets two
7//! choices about how to handle parse errors:
8//!
9//! 1. Map the error kind to the correct sysexit (USAGE for missing
10//!    args, DATAERR for invalid values, etc.) so shell scripts that
11//!    do `mkit foo … || handle` see the right code.
12//! 2. Print the error to stderr in clap's standard format so users
13//!    see consistent diagnostics across commands.
14//!
15//! This module does both. The typical migrated command looks like:
16//!
17//! ```ignore
18//! use crate::clap_shim;
19//! use clap::Parser;
20//!
21//! #[derive(Parser, Debug)]
22//! struct Opts {
23//!     #[arg(short, long)]
24//!     verbose: bool,
25//! }
26//!
27//! pub fn run(args: &[String]) -> u8 {
28//!     let opts = match clap_shim::parse::<Opts>("mkit my-cmd", args) {
29//!         Ok(o) => o,
30//!         Err(code) => return code,
31//!     };
32//!     // ...real work using `opts`
33//! }
34//! ```
35//!
36//! ## Exit-code mapping
37//!
38//! | clap `ErrorKind`             | mkit exit code |
39//! |------------------------------|----------------|
40//! | `InvalidValue`               | `DATAERR` (65) |
41//! | `ValueValidation`            | `DATAERR` (65) |
42//! | `Io`                         | `NOINPUT` (66) |
43//! | `DisplayHelp`                | `OK` (0)       |
44//! | `DisplayVersion`             | `OK` (0)       |
45//! | everything else (missing arg, unknown flag, …) | `USAGE` (64) |
46//!
47//! Help / version requests are NOT treated as errors — clap prints
48//! them and we exit 0.
49
50use std::io::Write;
51
52use clap::Parser;
53use clap::error::ErrorKind;
54
55use crate::exit;
56
57/// Parse `args` (everything after `argv[1]` from the dispatcher) into
58/// `P`. On success, returns the parsed struct. On error, prints
59/// clap's formatted diagnostic to stdout (for help/version) or
60/// stderr (for usage / value errors) and returns the matching
61/// sysexits code so the caller can `return` it.
62///
63/// `bin_name` is what clap prefixes errors with — usually
64/// `"mkit <subcommand>"` so a user typo like
65/// `mkit commit --bogus` reads cleanly:
66///
67/// ```text
68/// error: unexpected argument '--bogus' found
69///   tip: a similar argument exists: '--all'
70/// usage: mkit commit [OPTIONS]
71/// ```
72pub fn parse<P: Parser>(bin_name: &str, args: &[String]) -> Result<P, u8> {
73    // Prepend the bin name so clap's diagnostics show the right
74    // prefix. Clap expects argv[0] to be the program name.
75    let mut full: Vec<String> = Vec::with_capacity(args.len() + 1);
76    full.push(bin_name.to_owned());
77    full.extend(args.iter().cloned());
78
79    match P::try_parse_from(full) {
80        Ok(p) => Ok(p),
81        Err(e) => Err(report_clap_error(&e)),
82    }
83}
84
85/// Write a clap error to the appropriate stream and return the
86/// matching sysexits code. Public so commands that hand-roll their
87/// own `clap::Command` (rather than using the derive form) can reuse
88/// the mapping.
89#[must_use]
90pub fn report_clap_error(e: &clap::Error) -> u8 {
91    // Help and version are not actually errors — clap models them
92    // this way so callers can hook into the formatting. Print to
93    // stdout (that's the conventional location for `--help`) and
94    // exit OK.
95    match e.kind() {
96        ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
97            let mut stdout = std::io::stdout().lock();
98            let _ = stdout.write_all(e.render().to_string().as_bytes());
99            return exit::OK;
100        }
101        _ => {}
102    }
103    // Everything else is a real diagnostic — render to stderr.
104    let mut stderr = std::io::stderr().lock();
105    let _ = stderr.write_all(e.render().to_string().as_bytes());
106    map_clap_error_kind(e.kind())
107}
108
109/// Map a `clap::ErrorKind` to a mkit sysexit. Kept as a small
110/// freestanding function so unit tests can pin the mapping without
111/// running clap.
112#[must_use]
113pub fn map_clap_error_kind(kind: ErrorKind) -> u8 {
114    match kind {
115        // "Value present but wrong shape" — `--commit not-a-hash`,
116        // `--limit not-a-number`. The argument was structurally
117        // there, the data was malformed.
118        ErrorKind::InvalidValue | ErrorKind::ValueValidation => exit::DATAERR,
119
120        // I/O error while parsing (e.g. failed to read stdin for a
121        // value).
122        ErrorKind::Io => exit::NOINPUT,
123
124        // Format error during error rendering — should not happen on
125        // any user-reachable path.
126        ErrorKind::Format => exit::GENERAL_ERROR,
127
128        // Everything else is "argument-shape problem" — wrong
129        // subcommand, missing required arg, unknown flag, too many
130        // values, etc. Help/version are special-cased in
131        // [`report_clap_error`]; if they reach this mapper they
132        // weren't intercepted, so route to USAGE defensively.
133        _ => exit::USAGE,
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use clap::Parser;
141
142    #[derive(Parser, Debug)]
143    #[command(no_binary_name = false)]
144    struct DummyOpts {
145        #[arg(short, long)]
146        flag: bool,
147        #[arg(short = 'n', long, value_parser = clap::value_parser!(u32))]
148        count: Option<u32>,
149    }
150
151    #[test]
152    fn unknown_flag_is_usage() {
153        let res: Result<DummyOpts, _> = parse("mkit test", &["--bogus".to_string()]);
154        assert_eq!(res.unwrap_err(), exit::USAGE);
155    }
156
157    #[test]
158    fn invalid_value_is_dataerr() {
159        let res: Result<DummyOpts, _> = parse(
160            "mkit test",
161            &["--count".to_string(), "not-a-number".to_string()],
162        );
163        assert_eq!(res.unwrap_err(), exit::DATAERR);
164    }
165
166    #[test]
167    fn valid_parse_succeeds() {
168        let res: Result<DummyOpts, _> = parse(
169            "mkit test",
170            &["--flag".to_string(), "-n".to_string(), "42".to_string()],
171        );
172        let opts = res.expect("should parse");
173        assert!(opts.flag);
174        assert_eq!(opts.count, Some(42));
175    }
176
177    #[test]
178    fn no_args_parses_with_defaults() {
179        let res: Result<DummyOpts, _> = parse("mkit test", &[]);
180        let opts = res.expect("should parse with defaults");
181        assert!(!opts.flag);
182        assert_eq!(opts.count, None);
183    }
184
185    #[test]
186    fn mapping_table() {
187        assert_eq!(map_clap_error_kind(ErrorKind::InvalidValue), exit::DATAERR);
188        assert_eq!(
189            map_clap_error_kind(ErrorKind::ValueValidation),
190            exit::DATAERR
191        );
192        assert_eq!(map_clap_error_kind(ErrorKind::Io), exit::NOINPUT);
193        assert_eq!(
194            map_clap_error_kind(ErrorKind::MissingRequiredArgument),
195            exit::USAGE
196        );
197        assert_eq!(map_clap_error_kind(ErrorKind::UnknownArgument), exit::USAGE);
198        assert_eq!(
199            map_clap_error_kind(ErrorKind::InvalidSubcommand),
200            exit::USAGE
201        );
202    }
203}