1use std::fmt;
7use std::path::PathBuf;
8use thiserror::Error;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ParamType {
13 Argument,
15 Option,
17 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#[derive(Debug, Clone, Default)]
36pub struct ErrorContext {
37 pub command_path: Option<String>,
39 pub usage: Option<String>,
41 pub help_option_names: Vec<String>,
43 pub color: Option<bool>,
45}
46
47impl ErrorContext {
48 pub fn new() -> Self {
50 Self::default()
51 }
52
53 pub fn with_command_path(mut self, path: impl Into<String>) -> Self {
55 self.command_path = Some(path.into());
56 self
57 }
58
59 pub fn with_usage(mut self, usage: impl Into<String>) -> Self {
61 self.usage = Some(usage.into());
62 self
63 }
64
65 pub fn with_help_options(mut self, options: Vec<String>) -> Self {
67 self.help_option_names = options;
68 self
69 }
70
71 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
83fn 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#[non_exhaustive]
104#[derive(Error, Debug)]
105pub enum ClickError {
106 #[error("{message}")]
111 UsageError {
112 message: String,
114 ctx: Option<Box<ErrorContext>>,
116 },
117
118 #[error("{message}")]
123 BadParameter {
124 message: String,
126 param_name: Option<String>,
128 param_hint: Option<Vec<String>>,
130 ctx: Option<Box<ErrorContext>>,
132 },
133
134 #[error("{}", format_missing_param_message(.param_type, .param_name.as_deref(), .param_hint.as_deref(), .message.as_deref()))]
139 MissingParameter {
140 message: Option<String>,
142 param_name: Option<String>,
144 param_hint: Option<Vec<String>>,
146 param_type: ParamType,
148 ctx: Option<Box<ErrorContext>>,
150 },
151
152 #[error("{}", format_no_such_option(.option_name, .possibilities.as_deref()))]
157 NoSuchOption {
158 option_name: String,
160 possibilities: Option<Vec<String>>,
162 ctx: Option<Box<ErrorContext>>,
164 },
165
166 #[error("{message}")]
171 BadOptionUsage {
172 option_name: String,
174 message: String,
176 ctx: Option<Box<ErrorContext>>,
178 },
179
180 #[error("{message}")]
185 BadArgumentUsage {
186 message: String,
188 ctx: Option<Box<ErrorContext>>,
190 },
191
192 #[error("Could not open file '{filename}': {hint}")]
196 FileError {
197 filename: PathBuf,
199 hint: String,
201 },
202
203 #[error("Aborted!")]
209 Abort,
210
211 #[error("Exit with code {code}")]
216 Exit {
217 code: i32,
219 },
220}
221
222fn 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 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
253fn 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 format!("{} Did you mean {}?", base, opts[0])
261 }
262 Some(opts) if !opts.is_empty() => {
263 format!("{} (Possible options: {})", base, opts.join(", "))
265 }
266 _ => base,
267 }
268}
269
270impl ClickError {
271 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 pub fn format_message(&self) -> String {
295 match self {
296 ClickError::BadParameter {
297 message,
298 param_name,
299 param_hint,
300 ..
301 } => {
302 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 pub fn format_full(&self) -> String {
323 let ctx = self.context();
324 let mut parts = Vec::new();
325
326 if let Some(ctx) = ctx {
328 if let Some(usage) = &ctx.usage {
329 parts.push(usage.clone());
330 }
331
332 if self.is_usage_error() {
334 if let Some(hint) = ctx.help_hint() {
335 parts.push(hint);
336 }
337 }
338 }
339
340 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 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 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 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 other => other,
435 }
436 }
437}
438
439impl ClickError {
441 pub fn usage(message: impl Into<String>) -> Self {
443 ClickError::UsageError {
444 message: message.into(),
445 ctx: None,
446 }
447 }
448
449 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 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 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 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 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 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 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 pub fn bad_argument_usage(message: impl Into<String>) -> Self {
523 ClickError::BadArgumentUsage {
524 message: message.into(),
525 ctx: None,
526 }
527 }
528
529 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 pub fn abort() -> Self {
544 ClickError::Abort
545 }
546
547 pub fn exit(code: i32) -> Self {
549 ClickError::Exit { code }
550 }
551}
552
553pub 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 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 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 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 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}