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}