1use std::{error::Error, ffi::OsStr, path::Path};
3
4use clap::error::{ContextKind, ContextValue, ErrorKind};
5use miette::{LabeledSpan, MietteDiagnostic, NamedSource, SourceSpan};
6
7enum ErrorContext {
10 InvalidValue {
11 arg: Option<String>,
12 value: Option<String>,
13 valid_values: Vec<String>,
14 custom: Option<String>,
15 },
16 ValueValidation {
17 arg: Option<String>,
18 value: Option<String>,
19 custom: Option<String>,
20 },
21 UnknownArgument {
22 arg: Option<String>,
23 suggested_arg: Option<String>,
24 },
25 InvalidSubcommand {
26 subcommand: Option<String>,
27 suggested_subcommand: Option<String>,
28 valid_subcommands: Vec<String>,
29 },
30 MissingRequiredArgument {
31 arg: Option<String>,
32 },
33 MissingSubcommand,
34 NoEquals {
35 arg: Option<String>,
36 },
37 TooManyValues {
38 arg: Option<String>,
39 actual: Option<isize>,
40 expected: Option<isize>,
41 custom: Option<String>,
42 },
43 TooFewValues {
44 arg: Option<String>,
45 actual: Option<isize>,
46 expected: Option<isize>,
47 custom: Option<String>,
48 },
49 WrongNumberOfValues {
50 arg: Option<String>,
51 actual: Option<isize>,
52 expected: Option<isize>,
53 custom: Option<String>,
54 },
55 ArgumentConflict {
56 arg: Option<String>,
57 prior: Vec<String>,
58 },
59 InvalidUtf8,
60 Other(String),
61}
62
63impl ErrorContext {
64 fn extract(error: &clap::Error) -> Self {
65 let kind = error.kind();
66 let mut custom = error.source().map(|s| s.to_string());
67
68 let mut invalid_arg: Option<String> = None;
70 let mut invalid_value: Option<String> = None;
71 let mut invalid_subcommand: Option<String> = None;
72 let mut suggested_arg: Option<String> = None;
73 let mut suggested_subcommand: Option<String> = None;
74 let mut valid_values: Vec<String> = Vec::new();
75 let mut valid_subcommands: Vec<String> = Vec::new();
76 let mut prior_arg: Vec<String> = Vec::new();
77 let mut actual_num: Option<isize> = None;
78 let mut expected_num: Option<isize> = None;
79 let mut min_values: Option<isize> = None;
80
81 for (ctx_kind, value) in error.context() {
82 match (ctx_kind, value) {
83 (ContextKind::InvalidArg, ContextValue::String(s)) => {
84 invalid_arg = Some(flag_of(s));
85 }
86 (ContextKind::InvalidArg, ContextValue::Strings(ss)) => {
87 if let Some(s) = ss.first() {
88 invalid_arg = Some(flag_of(s));
89 }
90 }
91 (ContextKind::InvalidValue, ContextValue::String(s)) => {
92 invalid_value = Some(s.clone());
93 }
94 (ContextKind::InvalidSubcommand, ContextValue::String(s)) => {
95 invalid_subcommand = Some(s.clone());
96 }
97 (ContextKind::SuggestedArg, ContextValue::String(s)) => {
98 suggested_arg = Some(s.clone());
99 }
100 (ContextKind::SuggestedSubcommand, ContextValue::Strings(ss)) => {
101 if let Some(first) = ss.first() {
102 suggested_subcommand = Some(first.clone());
103 }
104 }
105 (ContextKind::ValidValue, ContextValue::Strings(ss)) => {
106 valid_values = ss.clone();
107 }
108 (ContextKind::ValidSubcommand, ContextValue::Strings(ss)) => {
109 valid_subcommands = ss.clone();
110 }
111 (ContextKind::PriorArg, ContextValue::String(s)) => prior_arg.push(s.clone()),
112 (ContextKind::PriorArg, ContextValue::Strings(ss)) => {
113 prior_arg.extend(ss.iter().cloned());
114 }
115 (ContextKind::ActualNumValues, ContextValue::Number(n)) => {
116 actual_num = Some(*n);
117 }
118 (ContextKind::ExpectedNumValues, ContextValue::Number(n)) => {
119 expected_num = Some(*n);
120 }
121 (ContextKind::MinValues, ContextValue::Number(n)) => min_values = Some(*n),
122 (ContextKind::Custom, ContextValue::String(s)) => {
123 custom.get_or_insert_with(|| s.clone());
124 }
125 _ => {}
126 }
127 }
128
129 match kind {
130 ErrorKind::InvalidValue => Self::InvalidValue {
131 arg: invalid_arg,
132 value: invalid_value,
133 valid_values,
134 custom,
135 },
136 ErrorKind::ValueValidation => Self::ValueValidation {
137 arg: invalid_arg,
138 value: invalid_value,
139 custom,
140 },
141 ErrorKind::UnknownArgument => Self::UnknownArgument {
142 arg: invalid_arg,
143 suggested_arg,
144 },
145 ErrorKind::InvalidSubcommand => Self::InvalidSubcommand {
146 subcommand: invalid_subcommand,
147 suggested_subcommand,
148 valid_subcommands,
149 },
150 ErrorKind::MissingRequiredArgument => {
151 Self::MissingRequiredArgument { arg: invalid_arg }
152 }
153 ErrorKind::MissingSubcommand => Self::MissingSubcommand,
154 ErrorKind::NoEquals => Self::NoEquals { arg: invalid_arg },
155 ErrorKind::TooManyValues => Self::TooManyValues {
156 arg: invalid_arg,
157 actual: actual_num,
158 expected: expected_num,
159 custom,
160 },
161 ErrorKind::TooFewValues => Self::TooFewValues {
162 arg: invalid_arg,
163 actual: actual_num,
164 expected: expected_num.or(min_values),
165 custom,
166 },
167 ErrorKind::WrongNumberOfValues => Self::WrongNumberOfValues {
168 arg: invalid_arg,
169 actual: actual_num,
170 expected: expected_num,
171 custom,
172 },
173 ErrorKind::ArgumentConflict => Self::ArgumentConflict {
174 arg: invalid_arg,
175 prior: prior_arg,
176 },
177 ErrorKind::InvalidUtf8 => Self::InvalidUtf8,
178 _ => Self::Other(error.to_string()),
179 }
180 }
181
182 fn arg_extract(&self) -> Option<&str> {
183 match self {
184 Self::InvalidValue { arg, .. }
185 | Self::ValueValidation { arg, .. }
186 | Self::UnknownArgument { arg, .. }
187 | Self::MissingRequiredArgument { arg }
188 | Self::NoEquals { arg }
189 | Self::TooManyValues { arg, .. }
190 | Self::TooFewValues { arg, .. }
191 | Self::WrongNumberOfValues { arg, .. }
192 | Self::ArgumentConflict { arg, .. } => arg.as_deref(),
193 _ => None,
194 }
195 }
196
197 fn message(&self) -> String {
198 let a = || self.arg_extract().unwrap_or("?");
199 match self {
200 Self::InvalidValue { value, .. } | Self::ValueValidation { value, .. } => {
201 format!(
202 "invalid value `{}` for `{}`",
203 value.as_deref().unwrap_or("?"),
204 a()
205 )
206 }
207 Self::UnknownArgument { .. } => format!("unexpected argument `{}`", a()),
208 Self::InvalidSubcommand { subcommand, .. } => {
209 format!(
210 "unknown subcommand `{}`",
211 subcommand.as_deref().unwrap_or("?")
212 )
213 }
214 Self::MissingRequiredArgument { .. } => format!("missing required argument `{}`", a()),
215 Self::MissingSubcommand => "missing required subcommand".to_string(),
216 Self::NoEquals { .. } => format!("argument `{}` requires a value with `=`", a()),
217 Self::TooManyValues { .. } => format!("too many values for `{}`", a()),
218 Self::TooFewValues { .. } => format!("too few values for `{}`", a()),
219 Self::WrongNumberOfValues { .. } => format!("wrong number of values for `{}`", a()),
220 Self::ArgumentConflict { .. } => {
221 format!("argument `{}` conflicts with a previous argument", a())
222 }
223 Self::InvalidUtf8 => "invalid UTF-8 in command-line arguments".to_string(),
224 Self::Other(msg) => msg.clone(),
225 }
226 }
227
228 fn help(&self) -> Option<String> {
229 let hint = match self {
230 Self::InvalidValue { valid_values, .. } if !valid_values.is_empty() => {
231 Some(format!("possible values: {}", valid_values.join(", ")))
232 }
233 Self::InvalidSubcommand {
234 suggested_subcommand,
235 valid_subcommands,
236 ..
237 } => suggested_subcommand
238 .as_ref()
239 .map(|sug| format!("did you mean `{sug}`?"))
240 .or_else(|| {
241 (!valid_subcommands.is_empty())
242 .then(|| format!("available subcommands: {}", valid_subcommands.join(", ")))
243 }),
244 Self::UnknownArgument { suggested_arg, .. } => suggested_arg
245 .as_ref()
246 .map(|sug| format!("did you mean `{sug}`?")),
247 Self::ArgumentConflict { prior, .. } if !prior.is_empty() => {
248 Some(format!("cannot be used with `{}`", prior.join("`, `")))
249 }
250 Self::TooManyValues {
251 actual, expected, ..
252 }
253 | Self::TooFewValues {
254 actual, expected, ..
255 }
256 | Self::WrongNumberOfValues {
257 actual, expected, ..
258 } => expected.and_then(|e| {
259 let value_str = if e == 1 { "value" } else { "values" };
260 actual.map(|a| format!("expected {e} {}, got {a}", value_str))
261 }),
262 Self::NoEquals { arg } => Some(format!(
263 "use `{}=<value>` syntax",
264 arg.as_deref().unwrap_or("...")
265 )),
266 _ => None,
267 };
268 let parts: Vec<_> = [hint, self.custom()].into_iter().flatten().collect();
269 if parts.is_empty() {
270 None
271 } else {
272 Some(parts.join("\n"))
273 }
274 }
275
276 fn custom(&self) -> Option<String> {
277 match self {
278 Self::InvalidValue { custom, .. }
279 | Self::ValueValidation { custom, .. }
280 | Self::TooManyValues { custom, .. }
281 | Self::TooFewValues { custom, .. }
282 | Self::WrongNumberOfValues { custom, .. } => custom.clone(),
283 _ => None,
284 }
285 }
286
287 fn primary_label(&self, source: &str) -> Option<LabeledSpan> {
288 match self {
289 Self::InvalidValue { value, arg, .. } | Self::ValueValidation { value, arg, .. } => {
290 value.as_deref().and_then(|value| {
291 let start = arg
292 .as_deref()
293 .and_then(|anchor| source.find(anchor).map(|p| p + anchor.len()))
294 .unwrap_or(0);
295 source[start..].find(value).map(|offset| {
296 LabeledSpan::new_primary_with_span(
297 Some("this value is invalid".into()),
298 SourceSpan::from((start + offset, value.len())),
299 )
300 })
301 })
302 }
303 Self::UnknownArgument { .. } => {
304 arg_span(source, self.arg_extract(), "unknown argument")
305 }
306 Self::InvalidSubcommand { subcommand, .. } => span_of(source, subcommand.as_deref()?)
307 .map(|span| {
308 LabeledSpan::new_primary_with_span(Some("unknown subcommand".into()), span)
309 }),
310 Self::MissingRequiredArgument { .. }
311 | Self::TooManyValues { .. }
312 | Self::TooFewValues { .. }
313 | Self::WrongNumberOfValues { .. } => {
314 arg_span(source, self.arg_extract(), "this argument")
315 }
316 Self::MissingSubcommand => Some(LabeledSpan::new_primary_with_span(
317 Some("expected a subcommand here".into()),
318 SourceSpan::from((source.len(), 0)),
319 )),
320 Self::NoEquals { .. } => arg_span(source, self.arg_extract(), "missing `=`"),
321 Self::ArgumentConflict { .. } => {
322 arg_span(source, self.arg_extract(), "conflicting argument")
323 }
324 _ => None,
325 }
326 }
327
328 fn secondary_label(&self, source: &str) -> Option<LabeledSpan> {
329 match self {
330 Self::InvalidValue { .. } | Self::ValueValidation { .. } => {
331 self.arg_extract().and_then(|arg| {
332 span_of(source, arg).map(|span| {
333 LabeledSpan::new_with_span(Some("for this argument".into()), span)
334 })
335 })
336 }
337 Self::UnknownArgument { suggested_arg, .. } => {
338 suggested_arg.as_deref().and_then(|sug| {
339 span_of(source, sug).map(|span| {
340 LabeledSpan::new_with_span(Some(format!("did you mean `{sug}`?")), span)
341 })
342 })
343 }
344 Self::InvalidSubcommand {
345 suggested_subcommand,
346 ..
347 } => suggested_subcommand.as_deref().and_then(|sug| {
348 span_of(source, sug).map(|span| {
349 LabeledSpan::new_with_span(Some(format!("did you mean `{sug}`?")), span)
350 })
351 }),
352 _ => None,
353 }
354 }
355}
356
357fn span_of(source: &str, needle: &str) -> Option<SourceSpan> {
358 source
359 .find(needle)
360 .map(|start| SourceSpan::from((start, needle.len())))
361}
362
363fn arg_span(source: &str, arg: Option<&str>, label: &str) -> Option<LabeledSpan> {
364 arg.and_then(|a| {
365 span_of(source, a).map(|span| LabeledSpan::new_primary_with_span(Some(label.into()), span))
366 })
367}
368
369fn flag_of(s: &str) -> String {
371 match s.find(' ') {
372 Some(idx) => s[..idx].to_string(),
373 None => s.to_string(),
374 }
375}
376
377fn build_source() -> String {
381 let mut args = std::env::args_os();
382
383 args.next()
384 .map(|exe_path| {
385 Path::new(&exe_path)
386 .file_name()
387 .map(|n| n.to_os_string())
388 .unwrap_or(exe_path)
389 })
390 .into_iter()
391 .chain(args)
392 .map(|a| shell_escape(&a))
393 .collect::<Vec<_>>()
394 .join(" ")
395}
396
397fn shell_escape(arg: &OsStr) -> String {
398 shlex::try_quote(&arg.to_string_lossy())
399 .unwrap_or_else(|err| {
400 unreachable!(
401 "Empty value during quoting. This is a bug. Error: {:?}",
402 err
403 )
404 })
405 .into_owned()
406}
407
408pub fn clap_to_miette(error: clap::Error) -> Result<miette::Report, String> {
409 if matches!(
410 error.kind(),
411 ErrorKind::DisplayHelp
412 | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
413 | ErrorKind::DisplayVersion
414 ) {
415 return Err(error.to_string());
417 }
418 Ok(clap_to_miette_with_source(error, &build_source()))
419}
420
421fn clap_to_miette_with_source(error: clap::Error, source: &str) -> miette::Report {
422 let source = source.to_string();
423 let ctx = ErrorContext::extract(&error);
424 let message = ctx.message();
425 let labels: Vec<_> = [ctx.primary_label(&source), ctx.secondary_label(&source)]
426 .into_iter()
427 .flatten()
428 .collect();
429 let mut diag = MietteDiagnostic::new(message);
430 if let Some(h) = ctx.help() {
431 diag = diag.with_help(h);
432 }
433 if !labels.is_empty() {
434 diag = diag.with_labels(labels);
435 }
436 miette::Report::new(diag).with_source_code(NamedSource::new("command", source))
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use clap::{Parser, Subcommand};
443 use miette::SourceSpan;
444
445 #[derive(Parser, Debug)]
446 #[allow(dead_code)]
447 struct TestCli {
448 #[arg(long, value_parser = ["text", "json"])]
449 format: Option<String>,
450 #[arg(long, value_parser = parse_int)]
451 timeout: Option<u32>,
452 #[arg(long, num_args = 2..)]
453 names: Vec<String>,
454 #[arg(long, conflicts_with = "format")]
455 server: Option<String>,
456 }
457
458 fn parse_int(s: &str) -> Result<u32, String> {
459 s.parse().map_err(|_| format!("`{s}` is not a number"))
460 }
461
462 fn convert(args: &[&str], source: &str) -> miette::Report {
463 let err = TestCli::try_parse_from(args).expect_err("expected parse error");
464 clap_to_miette_with_source(err, source)
465 }
466
467 fn get_label<'a>(span: &SourceSpan, source: &'a str) -> &'a str {
468 &source[span.offset()..span.offset() + span.len()]
469 }
470
471 #[test]
472 fn unknown_argument_with_suggestion() {
473 let report = convert(&["prog", "--forma"], "lx build --forma");
474 let s = report.to_string();
475 assert!(s.contains("unexpected argument"), "got: {s}");
476 assert!(s.contains("--forma"), "got: {s}");
477 let labels: Vec<_> = report.labels().unwrap().collect();
478 assert!(labels[0].primary(), "first label should be primary");
479 assert_eq!(get_label(labels[0].inner(), "lx build --forma"), "--forma");
480 assert_eq!(
481 report.help().map(|h| h.to_string()),
482 Some("did you mean `--format`?".into())
483 );
484 }
485
486 #[test]
487 fn invalid_value_with_possible_values() {
488 let report = convert(&["prog", "--format", "yaml"], "lx --format yaml");
489 let s = report.to_string();
490 assert!(s.contains("invalid value `yaml`"));
491 assert!(s.contains("--format"));
492 let labels: Vec<_> = report.labels().unwrap().collect();
493 assert!(labels[0].primary());
494 assert_eq!(get_label(labels[0].inner(), "lx --format yaml"), "yaml");
495 assert_eq!(get_label(labels[1].inner(), "lx --format yaml"), "--format");
496 assert_eq!(
497 report.help().map(|h| h.to_string()),
498 Some("possible values: text, json".into())
499 );
500 }
501
502 #[test]
503 fn invalid_value_with_custom_validation() {
504 let report = convert(&["prog", "--timeout", "abc"], "lx --timeout abc");
505 let s = report.to_string();
506 assert!(s.contains("invalid value `abc`"));
507 assert!(s.contains("--timeout"));
508 let labels: Vec<_> = report.labels().unwrap().collect();
509 assert_eq!(get_label(labels[0].inner(), "lx --timeout abc"), "abc");
510 assert!(report
512 .help()
513 .unwrap()
514 .to_string()
515 .contains("`abc` is not a number"));
516 }
517
518 #[test]
519 fn invalid_subcommand_with_suggestion() {
520 #[derive(Parser, Debug)]
521 struct SubCli {
522 #[command(subcommand)]
523 cmd: SubCmd,
524 }
525 #[derive(Subcommand, Debug)]
526 enum SubCmd {
527 Install,
528 Build,
529 }
530
531 let err =
532 SubCli::try_parse_from(["prog", "instlal"]).expect_err("expected invalid subcommand");
533 let report = clap_to_miette_with_source(err, "lx instlal");
534 let s = report.to_string();
535 assert!(s.contains("unknown subcommand `instlal`"), "got: {s}");
536 let labels: Vec<_> = report.labels().unwrap().collect();
537 assert!(labels[0].primary());
538 assert_eq!(get_label(labels[0].inner(), "lx instlal"), "instlal");
539 assert_eq!(
540 report.help().map(|h| h.to_string()),
541 Some("did you mean `install`?".into())
542 );
543 }
544
545 #[test]
546 fn too_few_values() {
547 let report = convert(&["prog", "--names", "only-one"], "lx --names only-one");
548 let s = report.to_string();
549 assert!(s.contains("too few values for `--names`"));
550 let labels: Vec<_> = report.labels().unwrap().collect();
551 assert!(labels[0].primary());
552 assert_eq!(
553 get_label(labels[0].inner(), "lx --names only-one"),
554 "--names"
555 );
556 assert!(report
557 .help()
558 .unwrap()
559 .to_string()
560 .contains("expected 2 values"));
561 }
562
563 #[test]
564 fn missing_required_argument() {
565 #[derive(Parser, Debug)]
566 struct NeedArg {
567 #[command(subcommand)]
568 cmd: NeedArgCmd,
569 }
570 #[derive(Subcommand, Debug)]
571 enum NeedArgCmd {
572 Run {
573 #[arg(long, required = true)]
574 target: String,
575 },
576 }
577
578 let err = NeedArg::try_parse_from(["prog", "run"]).expect_err("expected missing arg error");
579 let report = clap_to_miette_with_source(err, "lx run");
580 let s = report.to_string();
581 assert!(s.contains("missing required argument"));
582 assert!(s.contains("--target"));
583 }
584
585 #[test]
586 fn argument_conflict() {
587 let report = convert(
588 &["prog", "--format", "text", "--server", "x"],
589 "lx --format text --server x",
590 );
591 let s = report.to_string();
592 assert!(s.contains("conflicts with"));
593 let labels: Vec<_> = report.labels().unwrap().collect();
594 assert!(labels[0].primary());
595 assert_eq!(
596 get_label(labels[0].inner(), "lx --format text --server x"),
597 "--format"
598 );
599 }
600
601 #[test]
602 fn display_help_passes_through() {
603 for kind in [
604 ErrorKind::DisplayHelp,
605 ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand,
606 ErrorKind::DisplayVersion,
607 ] {
608 let err = clap::Error::new(kind);
609 assert!(
610 clap_to_miette(err).is_err(),
611 "{kind:?} should bypass miette"
612 );
613 }
614 }
615
616 #[test]
617 fn label_finds_value_after_flag() {
618 let report = convert(&["prog", "--format", "yaml"], "yaml lx --format yaml");
619 let labels: Vec<_> = report.labels().unwrap().collect();
620 let primary_text = get_label(labels[0].inner(), "yaml lx --format yaml");
621 assert_eq!(primary_text, "yaml");
622 assert!(labels[0].offset() >= "yaml lx --format ".len());
623 }
624}