Skip to main content

lux_cli/utils/
error.rs

1//! Utility module for converting clap parsing errors into good-looking [`miette`] errors
2use std::{error::Error, ffi::OsStr, path::Path};
3
4use clap::error::{ContextKind, ContextValue, ErrorKind};
5use miette::{LabeledSpan, MietteDiagnostic, NamedSource, SourceSpan};
6
7/// All the [`ContextKind`] data exposed by a [`clap::Error`], extracted into a
8/// struct so we can pattern-match on it freely.
9enum 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        // Capture all the possibilities upfront so we can package it in structs later
69        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
369/// Strips value from key-value pair: `--lua-version <VER>` -> `--lua-version`
370fn flag_of(s: &str) -> String {
371    match s.find(' ') {
372        Some(idx) => s[..idx].to_string(),
373        None => s.to_string(),
374    }
375}
376
377/// Reconstruct the user's command-line invocation as a single string suitable
378/// for use as a miette source. Example: `lx --lua-version 5.4 build`. This lets
379/// us generate beautiful diagnostics.
380fn 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        // pass through the error so it gets rendered normally
416        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        // The value_parser's custom error becomes a help note.
511        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}