Skip to main content

click/
error.rs

1//! Error types for click-rs.
2//!
3//! This module provides a comprehensive error type hierarchy that mirrors Python Click's
4//! exception system. All errors can be displayed to users with helpful context and hints.
5
6use std::fmt;
7use std::path::PathBuf;
8use thiserror::Error;
9
10/// The type of parameter that caused an error.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ParamType {
13    /// A positional argument
14    Argument,
15    /// A command-line option (flag)
16    Option,
17    /// A generic parameter (unspecified type)
18    Parameter,
19}
20
21impl fmt::Display for ParamType {
22    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23        match self {
24            ParamType::Argument => write!(f, "argument"),
25            ParamType::Option => write!(f, "option"),
26            ParamType::Parameter => write!(f, "parameter"),
27        }
28    }
29}
30
31/// Context information for error formatting.
32///
33/// This struct holds contextual information that can be attached to errors
34/// to provide better error messages and help hints.
35#[derive(Debug, Clone, Default)]
36pub struct ErrorContext {
37    /// The command path (e.g., "myapp subcommand")
38    pub command_path: Option<String>,
39    /// The usage string for the command
40    pub usage: Option<String>,
41    /// Available help option names (e.g., ["--help", "-h"])
42    pub help_option_names: Vec<String>,
43    /// Whether color output is enabled
44    pub color: Option<bool>,
45}
46
47impl ErrorContext {
48    /// Create a new empty error context.
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Set the command path.
54    pub fn with_command_path(mut self, path: impl Into<String>) -> Self {
55        self.command_path = Some(path.into());
56        self
57    }
58
59    /// Set the usage string.
60    pub fn with_usage(mut self, usage: impl Into<String>) -> Self {
61        self.usage = Some(usage.into());
62        self
63    }
64
65    /// Set the help option names.
66    pub fn with_help_options(mut self, options: Vec<String>) -> Self {
67        self.help_option_names = options;
68        self
69    }
70
71    /// Generate the "Try 'COMMAND --help' for help" hint.
72    pub fn help_hint(&self) -> Option<String> {
73        if let (Some(cmd_path), Some(help_opt)) =
74            (&self.command_path, self.help_option_names.first())
75        {
76            Some(format!("Try '{} {}' for help.", cmd_path, help_opt))
77        } else {
78            None
79        }
80    }
81}
82
83/// Join parameter hints into a display string.
84///
85/// Single hints are unquoted; multiple hints are quoted and joined with " / ".
86fn join_param_hints(hints: &[String]) -> String {
87    if hints.len() == 1 {
88        hints[0].clone()
89    } else {
90        hints
91            .iter()
92            .map(|h| format!("'{}'", h))
93            .collect::<Vec<_>>()
94            .join(" / ")
95    }
96}
97
98/// The main error type for click-rs.
99///
100/// This enum represents all possible errors that can occur during CLI parsing
101/// and execution. It is marked `#[non_exhaustive]` to allow adding new variants
102/// in future versions without breaking compatibility.
103#[non_exhaustive]
104#[derive(Error, Debug)]
105pub enum ClickError {
106    /// A general usage error with the command.
107    ///
108    /// This is the base error type for command usage problems.
109    /// Exit code: 2
110    #[error("{message}")]
111    UsageError {
112        /// The error message
113        message: String,
114        /// Optional context for formatting
115        ctx: Option<Box<ErrorContext>>,
116    },
117
118    /// A parameter received an invalid value.
119    ///
120    /// This error is raised when a callback or type conversion fails.
121    /// Exit code: 2
122    #[error("{message}")]
123    BadParameter {
124        /// The error message describing what went wrong
125        message: String,
126        /// The name of the parameter (e.g., "--count" or "FILENAME")
127        param_name: Option<String>,
128        /// A hint to display instead of param_name (can be multiple values)
129        param_hint: Option<Vec<String>>,
130        /// Optional context for formatting
131        ctx: Option<Box<ErrorContext>>,
132    },
133
134    /// A required parameter was not provided.
135    ///
136    /// This error is raised when a required option or argument is missing.
137    /// Exit code: 2
138    #[error("{}", format_missing_param_message(.param_type, .param_name.as_deref(), .param_hint.as_deref(), .message.as_deref()))]
139    MissingParameter {
140        /// Optional additional message
141        message: Option<String>,
142        /// The name of the missing parameter
143        param_name: Option<String>,
144        /// A hint to display for the parameter
145        param_hint: Option<Vec<String>>,
146        /// The type of parameter (argument, option, or generic parameter)
147        param_type: ParamType,
148        /// Optional context for formatting
149        ctx: Option<Box<ErrorContext>>,
150    },
151
152    /// An unknown option was provided.
153    ///
154    /// This error includes possible corrections if similar options exist.
155    /// Exit code: 2
156    #[error("{}", format_no_such_option(.option_name, .possibilities.as_deref()))]
157    NoSuchOption {
158        /// The option name that was not recognized
159        option_name: String,
160        /// Similar option names that might be what the user meant
161        possibilities: Option<Vec<String>>,
162        /// Optional context for formatting
163        ctx: Option<Box<ErrorContext>>,
164    },
165
166    /// An option was used incorrectly.
167    ///
168    /// For example, wrong number of arguments for an option.
169    /// Exit code: 2
170    #[error("{message}")]
171    BadOptionUsage {
172        /// The option that was used incorrectly
173        option_name: String,
174        /// The error message
175        message: String,
176        /// Optional context for formatting
177        ctx: Option<Box<ErrorContext>>,
178    },
179
180    /// An argument was used incorrectly.
181    ///
182    /// For example, wrong number of values for an argument.
183    /// Exit code: 2
184    #[error("{message}")]
185    BadArgumentUsage {
186        /// The error message
187        message: String,
188        /// Optional context for formatting
189        ctx: Option<Box<ErrorContext>>,
190    },
191
192    /// A file operation failed.
193    ///
194    /// Exit code: 1
195    #[error("Could not open file '{filename}': {hint}")]
196    FileError {
197        /// The path to the file that caused the error
198        filename: PathBuf,
199        /// A description of what went wrong
200        hint: String,
201    },
202
203    /// The user aborted the operation.
204    ///
205    /// This is typically raised when the user presses Ctrl+C or answers "no"
206    /// to a confirmation prompt.
207    /// Exit code: 1
208    #[error("Aborted!")]
209    Abort,
210
211    /// Exit with a specific code.
212    ///
213    /// This is used to signal that the application should exit with the given
214    /// status code. A code of 0 indicates success.
215    #[error("Exit with code {code}")]
216    Exit {
217        /// The exit code
218        code: i32,
219    },
220}
221
222/// Format the message for a missing parameter error.
223fn format_missing_param_message(
224    param_type: &ParamType,
225    param_name: Option<&str>,
226    param_hint: Option<&[String]>,
227    message: Option<&str>,
228) -> String {
229    let type_str = match param_type {
230        ParamType::Argument => "Missing argument",
231        ParamType::Option => "Missing option",
232        ParamType::Parameter => "Missing parameter",
233    };
234
235    // Single hint/name is unquoted; multiple hints are quoted
236    let hint_str = if let Some(hints) = param_hint {
237        format!(" {}", join_param_hints(hints))
238    } else if let Some(name) = param_name {
239        format!(" {}", name)
240    } else {
241        String::new()
242    };
243
244    let msg_str = if let Some(msg) = message {
245        format!(" {}", msg)
246    } else {
247        String::new()
248    };
249
250    format!("{}{}.{}", type_str, hint_str, msg_str)
251}
252
253/// Format the message for a "no such option" error.
254fn format_no_such_option(option_name: &str, possibilities: Option<&[String]>) -> String {
255    let base = format!("No such option: {}", option_name);
256
257    match possibilities {
258        Some(opts) if opts.len() == 1 => {
259            // Single suggestion: unquoted
260            format!("{} Did you mean {}?", base, opts[0])
261        }
262        Some(opts) if !opts.is_empty() => {
263            // Multiple suggestions: unquoted, comma-separated
264            format!("{} (Possible options: {})", base, opts.join(", "))
265        }
266        _ => base,
267    }
268}
269
270impl ClickError {
271    /// Get the exit code for this error.
272    ///
273    /// Returns the appropriate exit code based on the error type:
274    /// - `Exit`: returns the specified code
275    /// - `UsageError` variants: returns 2
276    /// - Other errors: returns 1
277    pub fn exit_code(&self) -> i32 {
278        match self {
279            ClickError::Exit { code } => *code,
280            ClickError::UsageError { .. }
281            | ClickError::BadParameter { .. }
282            | ClickError::MissingParameter { .. }
283            | ClickError::NoSuchOption { .. }
284            | ClickError::BadOptionUsage { .. }
285            | ClickError::BadArgumentUsage { .. } => 2,
286            ClickError::FileError { .. } | ClickError::Abort => 1,
287        }
288    }
289
290    /// Format the error message for display to the user.
291    ///
292    /// This method returns a user-friendly error message, potentially including
293    /// usage information and help hints based on the error context.
294    pub fn format_message(&self) -> String {
295        match self {
296            ClickError::BadParameter {
297                message,
298                param_name,
299                param_hint,
300                ..
301            } => {
302                // Single hint/name is unquoted; multiple hints are quoted
303                let hint_str = if let Some(hints) = param_hint {
304                    Some(join_param_hints(hints))
305                } else {
306                    param_name.clone()
307                };
308
309                match hint_str {
310                    Some(h) => format!("Invalid value for {}: {}", h, message),
311                    None => format!("Invalid value: {}", message),
312                }
313            }
314            _ => self.to_string(),
315        }
316    }
317
318    /// Format the complete error output including usage and help hints.
319    ///
320    /// This method returns the full error output that should be shown to the user,
321    /// including any usage information and "Try --help" hints.
322    pub fn format_full(&self) -> String {
323        let ctx = self.context();
324        let mut parts = Vec::new();
325
326        // Add usage information if available (for usage errors)
327        if let Some(ctx) = ctx {
328            if let Some(usage) = &ctx.usage {
329                parts.push(usage.clone());
330            }
331
332            // Add help hint for usage errors
333            if self.is_usage_error() {
334                if let Some(hint) = ctx.help_hint() {
335                    parts.push(hint);
336                }
337            }
338        }
339
340        // Add the error message (no trailing space if message is empty)
341        let msg = self.format_message();
342        if msg.is_empty() {
343            parts.push("Error:".to_string());
344        } else {
345            parts.push(format!("Error: {}", msg));
346        }
347
348        parts.join("\n")
349    }
350
351    /// Check if this is a usage error (exit code 2).
352    pub fn is_usage_error(&self) -> bool {
353        matches!(
354            self,
355            ClickError::UsageError { .. }
356                | ClickError::BadParameter { .. }
357                | ClickError::MissingParameter { .. }
358                | ClickError::NoSuchOption { .. }
359                | ClickError::BadOptionUsage { .. }
360                | ClickError::BadArgumentUsage { .. }
361        )
362    }
363
364    /// Get the error context, if any.
365    pub fn context(&self) -> Option<&ErrorContext> {
366        match self {
367            ClickError::UsageError { ctx, .. } => ctx.as_ref().map(|b| b.as_ref()),
368            ClickError::BadParameter { ctx, .. } => ctx.as_ref().map(|b| b.as_ref()),
369            ClickError::MissingParameter { ctx, .. } => ctx.as_ref().map(|b| b.as_ref()),
370            ClickError::NoSuchOption { ctx, .. } => ctx.as_ref().map(|b| b.as_ref()),
371            ClickError::BadOptionUsage { ctx, .. } => ctx.as_ref().map(|b| b.as_ref()),
372            ClickError::BadArgumentUsage { ctx, .. } => ctx.as_ref().map(|b| b.as_ref()),
373            _ => None,
374        }
375    }
376
377    /// Attach context to this error.
378    ///
379    /// This method consumes the error and returns a new error with the given context.
380    pub fn with_context(self, ctx: ErrorContext) -> Self {
381        let boxed = Some(Box::new(ctx));
382        match self {
383            ClickError::UsageError { message, .. } => ClickError::UsageError {
384                message,
385                ctx: boxed,
386            },
387            ClickError::BadParameter {
388                message,
389                param_name,
390                param_hint,
391                ..
392            } => ClickError::BadParameter {
393                message,
394                param_name,
395                param_hint,
396                ctx: boxed,
397            },
398            ClickError::MissingParameter {
399                message,
400                param_name,
401                param_hint,
402                param_type,
403                ..
404            } => ClickError::MissingParameter {
405                message,
406                param_name,
407                param_hint,
408                param_type,
409                ctx: boxed,
410            },
411            ClickError::NoSuchOption {
412                option_name,
413                possibilities,
414                ..
415            } => ClickError::NoSuchOption {
416                option_name,
417                possibilities,
418                ctx: boxed,
419            },
420            ClickError::BadOptionUsage {
421                option_name,
422                message,
423                ..
424            } => ClickError::BadOptionUsage {
425                option_name,
426                message,
427                ctx: boxed,
428            },
429            ClickError::BadArgumentUsage { message, .. } => ClickError::BadArgumentUsage {
430                message,
431                ctx: boxed,
432            },
433            // These errors don't have context
434            other => other,
435        }
436    }
437}
438
439// Convenience constructors
440impl ClickError {
441    /// Create a new usage error.
442    pub fn usage(message: impl Into<String>) -> Self {
443        ClickError::UsageError {
444            message: message.into(),
445            ctx: None,
446        }
447    }
448
449    /// Create a new bad parameter error.
450    pub fn bad_parameter(message: impl Into<String>) -> Self {
451        ClickError::BadParameter {
452            message: message.into(),
453            param_name: None,
454            param_hint: None,
455            ctx: None,
456        }
457    }
458
459    /// Create a new bad parameter error with a parameter name.
460    pub fn bad_parameter_named(message: impl Into<String>, param_name: impl Into<String>) -> Self {
461        ClickError::BadParameter {
462            message: message.into(),
463            param_name: Some(param_name.into()),
464            param_hint: None,
465            ctx: None,
466        }
467    }
468
469    /// Create a new missing parameter error.
470    pub fn missing_option(name: impl Into<String>) -> Self {
471        ClickError::MissingParameter {
472            message: None,
473            param_name: Some(name.into()),
474            param_hint: None,
475            param_type: ParamType::Option,
476            ctx: None,
477        }
478    }
479
480    /// Create a new missing argument error.
481    pub fn missing_argument(name: impl Into<String>) -> Self {
482        ClickError::MissingParameter {
483            message: None,
484            param_name: Some(name.into()),
485            param_hint: None,
486            param_type: ParamType::Argument,
487            ctx: None,
488        }
489    }
490
491    /// Create a new "no such option" error.
492    pub fn no_such_option(option_name: impl Into<String>) -> Self {
493        ClickError::NoSuchOption {
494            option_name: option_name.into(),
495            possibilities: None,
496            ctx: None,
497        }
498    }
499
500    /// Create a new "no such option" error with suggestions.
501    pub fn no_such_option_with_suggestions(
502        option_name: impl Into<String>,
503        possibilities: Vec<String>,
504    ) -> Self {
505        ClickError::NoSuchOption {
506            option_name: option_name.into(),
507            possibilities: Some(possibilities),
508            ctx: None,
509        }
510    }
511
512    /// Create a new bad option usage error.
513    pub fn bad_option_usage(option_name: impl Into<String>, message: impl Into<String>) -> Self {
514        ClickError::BadOptionUsage {
515            option_name: option_name.into(),
516            message: message.into(),
517            ctx: None,
518        }
519    }
520
521    /// Create a new bad argument usage error.
522    pub fn bad_argument_usage(message: impl Into<String>) -> Self {
523        ClickError::BadArgumentUsage {
524            message: message.into(),
525            ctx: None,
526        }
527    }
528
529    /// Create a new file error.
530    pub fn file_error(filename: impl Into<PathBuf>, hint: impl Into<String>) -> Self {
531        let hint_str = hint.into();
532        ClickError::FileError {
533            filename: filename.into(),
534            hint: if hint_str.is_empty() {
535                "unknown error".to_string()
536            } else {
537                hint_str
538            },
539        }
540    }
541
542    /// Create an abort error.
543    pub fn abort() -> Self {
544        ClickError::Abort
545    }
546
547    /// Create an exit error with the given code.
548    pub fn exit(code: i32) -> Self {
549        ClickError::Exit { code }
550    }
551}
552
553/// A specialized Result type for click-rs operations.
554pub type Result<T> = std::result::Result<T, ClickError>;
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn test_exit_codes() {
562        assert_eq!(ClickError::usage("test").exit_code(), 2);
563        assert_eq!(ClickError::bad_parameter("test").exit_code(), 2);
564        assert_eq!(ClickError::missing_option("--foo").exit_code(), 2);
565        assert_eq!(ClickError::missing_argument("FILE").exit_code(), 2);
566        assert_eq!(ClickError::no_such_option("--bar").exit_code(), 2);
567        assert_eq!(ClickError::bad_option_usage("--x", "msg").exit_code(), 2);
568        assert_eq!(ClickError::bad_argument_usage("msg").exit_code(), 2);
569        assert_eq!(
570            ClickError::file_error("test.txt", "not found").exit_code(),
571            1
572        );
573        assert_eq!(ClickError::abort().exit_code(), 1);
574        assert_eq!(ClickError::exit(0).exit_code(), 0);
575        assert_eq!(ClickError::exit(42).exit_code(), 42);
576    }
577
578    #[test]
579    fn test_usage_error_display() {
580        let err = ClickError::usage("invalid command");
581        assert_eq!(err.to_string(), "invalid command");
582    }
583
584    #[test]
585    fn test_bad_parameter_format() {
586        // Single param hint is unquoted
587        let err = ClickError::bad_parameter_named("must be positive", "--count");
588        assert_eq!(
589            err.format_message(),
590            "Invalid value for --count: must be positive"
591        );
592
593        let err = ClickError::bad_parameter("must be positive");
594        assert_eq!(err.format_message(), "Invalid value: must be positive");
595    }
596
597    #[test]
598    fn test_missing_parameter_display() {
599        // Single param is unquoted
600        let err = ClickError::missing_option("--name");
601        assert_eq!(err.to_string(), "Missing option --name.");
602
603        let err = ClickError::missing_argument("FILE");
604        assert_eq!(err.to_string(), "Missing argument FILE.");
605    }
606
607    #[test]
608    fn test_no_such_option_display() {
609        let err = ClickError::no_such_option("--hlep");
610        assert_eq!(err.to_string(), "No such option: --hlep");
611
612        // Single suggestion: unquoted
613        let err = ClickError::no_such_option_with_suggestions("--hlep", vec!["--help".to_string()]);
614        assert_eq!(
615            err.to_string(),
616            "No such option: --hlep Did you mean --help?"
617        );
618
619        // Multiple suggestions: unquoted, comma-separated
620        let err = ClickError::no_such_option_with_suggestions(
621            "--hlep",
622            vec!["--help".to_string(), "--hello".to_string()],
623        );
624        assert_eq!(
625            err.to_string(),
626            "No such option: --hlep (Possible options: --help, --hello)"
627        );
628    }
629
630    #[test]
631    fn test_file_error_display() {
632        let err = ClickError::file_error("/path/to/file.txt", "permission denied");
633        assert_eq!(
634            err.to_string(),
635            "Could not open file '/path/to/file.txt': permission denied"
636        );
637    }
638
639    #[test]
640    fn test_abort_display() {
641        let err = ClickError::abort();
642        assert_eq!(err.to_string(), "Aborted!");
643    }
644
645    #[test]
646    fn test_exit_display() {
647        let err = ClickError::exit(0);
648        assert_eq!(err.to_string(), "Exit with code 0");
649
650        let err = ClickError::exit(1);
651        assert_eq!(err.to_string(), "Exit with code 1");
652    }
653
654    #[test]
655    fn test_context_help_hint() {
656        let ctx = ErrorContext::new()
657            .with_command_path("myapp")
658            .with_help_options(vec!["--help".to_string(), "-h".to_string()]);
659
660        assert_eq!(
661            ctx.help_hint(),
662            Some("Try 'myapp --help' for help.".to_string())
663        );
664    }
665
666    #[test]
667    fn test_format_full_with_context() {
668        let ctx = ErrorContext::new()
669            .with_command_path("myapp")
670            .with_usage("Usage: myapp [OPTIONS] FILE")
671            .with_help_options(vec!["--help".to_string()]);
672
673        let err = ClickError::missing_argument("FILE").with_context(ctx);
674        let output = err.format_full();
675
676        assert!(output.contains("Usage: myapp [OPTIONS] FILE"));
677        assert!(output.contains("Try 'myapp --help' for help."));
678        assert!(output.contains("Error: Missing argument FILE."));
679    }
680
681    #[test]
682    fn test_is_usage_error() {
683        assert!(ClickError::usage("test").is_usage_error());
684        assert!(ClickError::bad_parameter("test").is_usage_error());
685        assert!(ClickError::missing_option("--foo").is_usage_error());
686        assert!(ClickError::no_such_option("--bar").is_usage_error());
687        assert!(ClickError::bad_option_usage("--x", "msg").is_usage_error());
688        assert!(ClickError::bad_argument_usage("msg").is_usage_error());
689
690        assert!(!ClickError::file_error("f", "h").is_usage_error());
691        assert!(!ClickError::abort().is_usage_error());
692        assert!(!ClickError::exit(0).is_usage_error());
693    }
694
695    #[test]
696    fn test_param_type_display() {
697        assert_eq!(ParamType::Argument.to_string(), "argument");
698        assert_eq!(ParamType::Option.to_string(), "option");
699        assert_eq!(ParamType::Parameter.to_string(), "parameter");
700    }
701}