dynamic_cli/error/types.rs
1//! Error types for dynamic-cli
2//!
3//! Defines all possible error types with context and clear messages.
4//!
5//! Each error variant carries an optional `suggestion` field that surfaces
6//! an actionable hint to the end user. Suggestions are rendered by
7//! [`crate::error::display::format_error`] and are never part of the
8//! `Display` string itself, keeping machine-readable messages stable.
9
10use std::path::PathBuf;
11use thiserror::Error;
12
13/// Main error for the dynamic-cli framework
14///
15/// Encompasses all possible error categories. Uses `thiserror`
16/// to automatically generate `Display` and `Error` implementations.
17#[derive(Debug, Error)]
18pub enum DynamicCliError {
19 /// Errors related to the configuration file
20 #[error(transparent)]
21 Config(#[from] ConfigError),
22
23 /// Command parsing errors
24 #[error(transparent)]
25 Parse(#[from] ParseError),
26
27 /// Validation errors
28 #[error(transparent)]
29 Validation(#[from] ValidationError),
30
31 /// Execution errors
32 #[error(transparent)]
33 Execution(#[from] ExecutionError),
34
35 /// Registry errors
36 #[error(transparent)]
37 Registry(#[from] RegistryError),
38
39 /// I/O errors
40 #[error("I/O error: {0}")]
41 Io(#[from] std::io::Error),
42}
43
44// ═══════════════════════════════════════════════════════════
45// CONFIGURATION ERRORS
46// ═══════════════════════════════════════════════════════════
47
48/// Errors related to loading and parsing the configuration file
49///
50/// These errors occur when loading the `commands.yaml` or `commands.json`
51/// file and its structural validation.
52#[derive(Debug, Error)]
53pub enum ConfigError {
54 /// Configuration file not found
55 ///
56 /// # Example
57 ///
58 /// ```
59 /// use dynamic_cli::error::ConfigError;
60 /// use std::path::PathBuf;
61 ///
62 /// let error = ConfigError::FileNotFound {
63 /// path: PathBuf::from("missing.yaml"),
64 /// suggestion: Some("Verify the path and file permissions.".to_string()),
65 /// };
66 /// let msg = format!("{}", error);
67 /// assert!(msg.contains("missing.yaml"));
68 /// ```
69 #[error("Configuration file not found: {path:?}")]
70 FileNotFound {
71 path: PathBuf,
72 /// Actionable hint surfaced to the user (not part of the Display string)
73 suggestion: Option<String>,
74 },
75
76 /// Unsupported file extension
77 ///
78 /// Only `.yaml`, `.yml` and `.json` are supported.
79 ///
80 /// # Example
81 ///
82 /// ```
83 /// use dynamic_cli::error::ConfigError;
84 ///
85 /// let error = ConfigError::UnsupportedFormat {
86 /// extension: ".toml".to_string(),
87 /// suggestion: Some("Rename the file with a .yaml, .yml or .json extension.".to_string()),
88 /// };
89 /// let msg = format!("{}", error);
90 /// assert!(msg.contains(".toml"));
91 /// ```
92 #[error("Unsupported file format: '{extension}'. Supported: .yaml, .yml, .json")]
93 UnsupportedFormat {
94 extension: String,
95 /// Actionable hint surfaced to the user (not part of the Display string)
96 suggestion: Option<String>,
97 },
98
99 /// YAML parsing error
100 #[error("Failed to parse YAML configuration at line {line:?}, column {column:?}: {source}")]
101 YamlParse {
102 #[source]
103 source: serde_yaml::Error,
104 /// Position in the file (if available)
105 line: Option<usize>,
106 column: Option<usize>,
107 },
108
109 /// JSON parsing error
110 #[error("Failed to parse JSON configuration at line {line}, column {column}: {source}")]
111 JsonParse {
112 #[source]
113 source: serde_json::Error,
114 /// Position in the file
115 line: usize,
116 column: usize,
117 },
118
119 /// Invalid configuration schema
120 ///
121 /// The file structure doesn't match the expected format.
122 ///
123 /// # Example
124 ///
125 /// ```
126 /// use dynamic_cli::error::ConfigError;
127 ///
128 /// let error = ConfigError::InvalidSchema {
129 /// reason: "Missing required field 'name'".to_string(),
130 /// path: Some("commands[0]".to_string()),
131 /// suggestion: Some("Add a 'name' field to each command entry.".to_string()),
132 /// };
133 /// let msg = format!("{}", error);
134 /// assert!(msg.contains("Missing required field"));
135 /// ```
136 #[error("Invalid configuration schema: {reason} (at {path:?})")]
137 InvalidSchema {
138 reason: String,
139 /// Path in the config (e.g., "commands[0].options[2].type")
140 path: Option<String>,
141 /// Actionable hint surfaced to the user (not part of the Display string)
142 suggestion: Option<String>,
143 },
144
145 /// Duplicate command (same name or alias)
146 ///
147 /// # Example
148 ///
149 /// ```
150 /// use dynamic_cli::error::ConfigError;
151 ///
152 /// let error = ConfigError::DuplicateCommand {
153 /// name: "run".to_string(),
154 /// suggestion: Some("Rename one of the conflicting commands or aliases.".to_string()),
155 /// };
156 /// let msg = format!("{}", error);
157 /// assert!(msg.contains("run"));
158 /// ```
159 #[error("Duplicate command name or alias: '{name}'")]
160 DuplicateCommand {
161 name: String,
162 /// Actionable hint surfaced to the user (not part of the Display string)
163 suggestion: Option<String>,
164 },
165
166 /// Unknown argument type
167 ///
168 /// # Example
169 ///
170 /// ```
171 /// use dynamic_cli::error::ConfigError;
172 ///
173 /// let error = ConfigError::UnknownType {
174 /// type_name: "datetime".to_string(),
175 /// context: "commands[1].options[0]".to_string(),
176 /// suggestion: Some(
177 /// "Supported types: string, integer, float, boolean, file.".to_string()
178 /// ),
179 /// };
180 /// let msg = format!("{}", error);
181 /// assert!(msg.contains("datetime"));
182 /// ```
183 #[error("Unknown argument type: '{type_name}' in {context}")]
184 UnknownType {
185 type_name: String,
186 context: String,
187 /// Actionable hint surfaced to the user (not part of the Display string)
188 suggestion: Option<String>,
189 },
190
191 /// Inconsistent configuration
192 ///
193 /// For example, a default value that's not in the allowed choices.
194 ///
195 /// # Example
196 ///
197 /// ```
198 /// use dynamic_cli::error::ConfigError;
199 ///
200 /// let error = ConfigError::Inconsistency {
201 /// details: "Default value 'fast' is not in choices: slow, medium".to_string(),
202 /// suggestion: Some("Ensure the default value matches one of the allowed choices.".to_string()),
203 /// };
204 /// let msg = format!("{}", error);
205 /// assert!(msg.contains("Default value"));
206 /// ```
207 #[error("Configuration inconsistency: {details}")]
208 Inconsistency {
209 details: String,
210 /// Actionable hint surfaced to the user (not part of the Display string)
211 suggestion: Option<String>,
212 },
213}
214
215// ═══════════════════════════════════════════════════════════
216// PARSING ERRORS
217// ═══════════════════════════════════════════════════════════
218
219/// Errors when parsing user commands
220///
221/// These errors occur when analyzing arguments provided
222/// by the user in CLI or REPL mode.
223#[derive(Debug, Error)]
224pub enum ParseError {
225 /// Unknown command
226 ///
227 /// The user typed a command that doesn't exist.
228 /// Includes suggestions based on Levenshtein distance.
229 #[error("Unknown command: '{command}'. Type 'help' for available commands.")]
230 UnknownCommand {
231 command: String,
232 /// Similar command suggestions (from Levenshtein distance)
233 suggestions: Vec<String>,
234 },
235
236 /// Missing required positional argument
237 ///
238 /// # Example
239 ///
240 /// ```
241 /// use dynamic_cli::error::ParseError;
242 ///
243 /// let error = ParseError::MissingArgument {
244 /// argument: "filename".to_string(),
245 /// command: "process".to_string(),
246 /// suggestion: Some("Run --help process to see required arguments.".to_string()),
247 /// };
248 /// let msg = format!("{}", error);
249 /// assert!(msg.contains("filename"));
250 /// ```
251 #[error("Missing required argument: {argument} for command '{command}'")]
252 MissingArgument {
253 argument: String,
254 command: String,
255 /// Actionable hint surfaced to the user (not part of the Display string)
256 suggestion: Option<String>,
257 },
258
259 /// Missing required option
260 ///
261 /// # Example
262 ///
263 /// ```
264 /// use dynamic_cli::error::ParseError;
265 ///
266 /// let error = ParseError::MissingOption {
267 /// option: "output".to_string(),
268 /// command: "export".to_string(),
269 /// suggestion: Some("Run --help export to see required options.".to_string()),
270 /// };
271 /// let msg = format!("{}", error);
272 /// assert!(msg.contains("output"));
273 /// ```
274 #[error("Missing required option: --{option} for command '{command}'")]
275 MissingOption {
276 option: String,
277 command: String,
278 /// Actionable hint surfaced to the user (not part of the Display string)
279 suggestion: Option<String>,
280 },
281
282 /// Too many positional arguments
283 ///
284 /// # Example
285 ///
286 /// ```
287 /// use dynamic_cli::error::ParseError;
288 ///
289 /// let error = ParseError::TooManyArguments {
290 /// command: "run".to_string(),
291 /// expected: 1,
292 /// got: 3,
293 /// suggestion: Some("Run --help run for the expected usage.".to_string()),
294 /// };
295 /// let msg = format!("{}", error);
296 /// assert!(msg.contains("run"));
297 /// ```
298 #[error("Too many arguments for command '{command}'. Expected {expected}, got {got}")]
299 TooManyArguments {
300 command: String,
301 expected: usize,
302 got: usize,
303 /// Actionable hint surfaced to the user (not part of the Display string)
304 suggestion: Option<String>,
305 },
306
307 /// Unknown option
308 ///
309 /// Includes similar option suggestions.
310 #[error("Unknown option: {flag} for command '{command}'")]
311 UnknownOption {
312 flag: String,
313 command: String,
314 /// Similar option suggestions (from Levenshtein distance)
315 suggestions: Vec<String>,
316 },
317
318 /// Type parsing error
319 ///
320 /// The user provided a value that can't be converted
321 /// to the expected type (e.g., "abc" for an integer).
322 #[error("Failed to parse {arg_name} as {expected_type}: '{value}'{}",
323 .details.as_ref().map(|d| format!(" ({})", d)).unwrap_or_default())]
324 TypeParseError {
325 arg_name: String,
326 expected_type: String,
327 value: String,
328 /// Error details (e.g., "not a valid integer")
329 details: Option<String>,
330 },
331
332 /// Value not in allowed choices
333 #[error("Invalid value for {arg_name}: '{value}'. Must be one of: {}",
334 .choices.join(", "))]
335 InvalidChoice {
336 arg_name: String,
337 value: String,
338 choices: Vec<String>,
339 },
340
341 /// Invalid command syntax
342 #[error("Invalid command syntax: {details}{}",
343 .hint.as_ref().map(|h| format!("\nHint: {}", h)).unwrap_or_default())]
344 InvalidSyntax {
345 details: String,
346 /// Example of correct syntax
347 hint: Option<String>,
348 },
349}
350
351// ═══════════════════════════════════════════════════════════
352// VALIDATION ERRORS
353// ═══════════════════════════════════════════════════════════
354
355/// Errors during argument validation
356///
357/// These errors occur after parsing, during validation
358/// of constraints defined in the configuration.
359#[derive(Debug, Error)]
360pub enum ValidationError {
361 /// Required file doesn't exist
362 ///
363 /// # Example
364 ///
365 /// ```
366 /// use dynamic_cli::error::ValidationError;
367 /// use std::path::PathBuf;
368 ///
369 /// let error = ValidationError::FileNotFound {
370 /// path: PathBuf::from("data.csv"),
371 /// arg_name: "input".to_string(),
372 /// suggestion: Some("Check that the file exists and the path is correct.".to_string()),
373 /// };
374 /// let msg = format!("{}", error);
375 /// assert!(msg.contains("data.csv"));
376 /// ```
377 #[error("File not found for argument '{arg_name}': {path:?}")]
378 FileNotFound {
379 path: PathBuf,
380 arg_name: String,
381 /// Actionable hint surfaced to the user (not part of the Display string)
382 suggestion: Option<String>,
383 },
384
385 /// Invalid file extension
386 #[error("Invalid file extension for {arg_name}: {path:?}. Expected: {}",
387 .expected.join(", "))]
388 InvalidExtension {
389 arg_name: String,
390 path: PathBuf,
391 expected: Vec<String>,
392 },
393
394 /// Value out of allowed range
395 ///
396 /// # Example
397 ///
398 /// ```
399 /// use dynamic_cli::error::ValidationError;
400 ///
401 /// let error = ValidationError::OutOfRange {
402 /// arg_name: "percentage".to_string(),
403 /// value: 150.0,
404 /// min: 0.0,
405 /// max: 100.0,
406 /// suggestion: Some("Value must be between 0 and 100.".to_string()),
407 /// };
408 /// let msg = format!("{}", error);
409 /// assert!(msg.contains("150"));
410 /// ```
411 #[error("{arg_name} must be between {min} and {max}, got {value}")]
412 OutOfRange {
413 arg_name: String,
414 value: f64,
415 min: f64,
416 max: f64,
417 /// Actionable hint surfaced to the user (not part of the Display string)
418 suggestion: Option<String>,
419 },
420
421 /// Custom constraint not met
422 ///
423 /// # Example
424 ///
425 /// ```
426 /// use dynamic_cli::error::ValidationError;
427 ///
428 /// let error = ValidationError::CustomConstraint {
429 /// arg_name: "email".to_string(),
430 /// reason: "not a valid email address".to_string(),
431 /// suggestion: Some("Provide a valid email address (e.g. user@example.com).".to_string()),
432 /// };
433 /// let msg = format!("{}", error);
434 /// assert!(msg.contains("email"));
435 /// ```
436 #[error("Validation failed for {arg_name}: {reason}")]
437 CustomConstraint {
438 arg_name: String,
439 reason: String,
440 /// Actionable hint surfaced to the user (not part of the Display string)
441 suggestion: Option<String>,
442 },
443
444 /// Dependency between arguments not satisfied
445 ///
446 /// Some arguments require the presence of other arguments.
447 ///
448 /// # Example
449 ///
450 /// ```
451 /// use dynamic_cli::error::ValidationError;
452 ///
453 /// let error = ValidationError::MissingDependency {
454 /// arg_name: "output-format".to_string(),
455 /// required_arg: "output".to_string(),
456 /// suggestion: Some("Add --output to your command.".to_string()),
457 /// };
458 /// let msg = format!("{}", error);
459 /// assert!(msg.contains("output-format"));
460 /// ```
461 #[error("{arg_name} requires {required_arg} to be specified")]
462 MissingDependency {
463 arg_name: String,
464 required_arg: String,
465 /// Actionable hint surfaced to the user (not part of the Display string)
466 suggestion: Option<String>,
467 },
468
469 /// Mutually exclusive arguments
470 ///
471 /// Some arguments cannot be used together.
472 ///
473 /// # Example
474 ///
475 /// ```
476 /// use dynamic_cli::error::ValidationError;
477 ///
478 /// let error = ValidationError::MutuallyExclusive {
479 /// arg1: "--verbose".to_string(),
480 /// arg2: "--quiet".to_string(),
481 /// suggestion: Some("Remove one of the two conflicting options.".to_string()),
482 /// };
483 /// let msg = format!("{}", error);
484 /// assert!(msg.contains("--verbose"));
485 /// ```
486 #[error("Options {arg1} and {arg2} cannot be used together")]
487 MutuallyExclusive {
488 arg1: String,
489 arg2: String,
490 /// Actionable hint surfaced to the user (not part of the Display string)
491 suggestion: Option<String>,
492 },
493}
494
495// ═══════════════════════════════════════════════════════════
496// EXECUTION ERRORS
497// ═══════════════════════════════════════════════════════════
498
499/// Errors during command execution
500///
501/// These errors occur during user code execution.
502#[derive(Debug, Error)]
503pub enum ExecutionError {
504 /// Command handler not found
505 ///
506 /// The implementation name in the config doesn't match any
507 /// registered handler.
508 ///
509 /// # Example
510 ///
511 /// ```
512 /// use dynamic_cli::error::ExecutionError;
513 ///
514 /// let error = ExecutionError::HandlerNotFound {
515 /// command: "run".to_string(),
516 /// implementation: "run_handler".to_string(),
517 /// suggestion: Some(
518 /// "Ensure .register_handler(\"run_handler\", ...) was called before running."
519 /// .to_string()
520 /// ),
521 /// };
522 /// let msg = format!("{}", error);
523 /// assert!(msg.contains("run"));
524 /// ```
525 #[error("No handler registered for command '{command}' (implementation: '{implementation}')")]
526 HandlerNotFound {
527 command: String,
528 implementation: String,
529 /// Actionable hint surfaced to the user (not part of the Display string)
530 suggestion: Option<String>,
531 },
532
533 /// Error during context downcasting
534 ///
535 /// The handler tried to downcast the context to an incorrect type.
536 ///
537 /// # Example
538 ///
539 /// ```
540 /// use dynamic_cli::error::ExecutionError;
541 ///
542 /// let error = ExecutionError::ContextDowncastFailed {
543 /// expected_type: "MyAppContext".to_string(),
544 /// suggestion: Some(
545 /// "Check that the context type passed to the handler matches the expected type."
546 /// .to_string()
547 /// ),
548 /// };
549 /// let msg = format!("{}", error);
550 /// assert!(msg.contains("MyAppContext"));
551 /// ```
552 #[error("Failed to downcast execution context to expected type: {expected_type}")]
553 ContextDowncastFailed {
554 expected_type: String,
555 /// Actionable hint surfaced to the user (not part of the Display string)
556 suggestion: Option<String>,
557 },
558
559 /// Invalid context state for this operation
560 ///
561 /// # Example
562 ///
563 /// ```
564 /// use dynamic_cli::error::ExecutionError;
565 ///
566 /// let error = ExecutionError::InvalidContextState {
567 /// reason: "connection pool not initialised".to_string(),
568 /// suggestion: Some("Ensure the context is fully initialised before running commands.".to_string()),
569 /// };
570 /// let msg = format!("{}", error);
571 /// assert!(msg.contains("connection pool"));
572 /// ```
573 #[error("Invalid context state: {reason}")]
574 InvalidContextState {
575 reason: String,
576 /// Actionable hint surfaced to the user (not part of the Display string)
577 suggestion: Option<String>,
578 },
579
580 /// Error in command implementation
581 ///
582 /// Wraps errors from user code.
583 #[error("Command execution failed: {0}")]
584 CommandFailed(#[source] anyhow::Error),
585
586 /// Command interrupted by user
587 ///
588 /// User pressed Ctrl+C during execution.
589 #[error("Command interrupted by user")]
590 Interrupted,
591}
592
593// ═══════════════════════════════════════════════════════════
594// REGISTRY ERRORS
595// ═══════════════════════════════════════════════════════════
596
597/// Errors related to the command registry
598///
599/// These errors occur when registering commands
600/// and handlers in the registry.
601#[derive(Debug, Error)]
602pub enum RegistryError {
603 /// Attempt to register an already existing command
604 ///
605 /// # Example
606 ///
607 /// ```
608 /// use dynamic_cli::error::RegistryError;
609 ///
610 /// let error = RegistryError::DuplicateRegistration {
611 /// name: "run".to_string(),
612 /// suggestion: Some("Command names must be unique across the registry.".to_string()),
613 /// };
614 /// let msg = format!("{}", error);
615 /// assert!(msg.contains("run"));
616 /// ```
617 #[error("Command '{name}' is already registered")]
618 DuplicateRegistration {
619 name: String,
620 /// Actionable hint surfaced to the user (not part of the Display string)
621 suggestion: Option<String>,
622 },
623
624 /// Alias already used by another command
625 ///
626 /// # Example
627 ///
628 /// ```
629 /// use dynamic_cli::error::RegistryError;
630 ///
631 /// let error = RegistryError::DuplicateAlias {
632 /// alias: "r".to_string(),
633 /// existing_command: "run".to_string(),
634 /// suggestion: Some("Choose a different alias for one of the commands.".to_string()),
635 /// };
636 /// let msg = format!("{}", error);
637 /// assert!(msg.contains("run"));
638 /// ```
639 #[error("Alias '{alias}' is already used by command '{existing_command}'")]
640 DuplicateAlias {
641 alias: String,
642 existing_command: String,
643 /// Actionable hint surfaced to the user (not part of the Display string)
644 suggestion: Option<String>,
645 },
646
647 /// Missing handler for a definition
648 ///
649 /// A command is defined in the config but no handler
650 /// has been registered for it.
651 ///
652 /// # Example
653 ///
654 /// ```
655 /// use dynamic_cli::error::RegistryError;
656 ///
657 /// let error = RegistryError::MissingHandler {
658 /// command: "export".to_string(),
659 /// suggestion: Some(
660 /// "Call .register_handler(\"export\", ...) before running.".to_string()
661 /// ),
662 /// };
663 /// let msg = format!("{}", error);
664 /// assert!(msg.contains("export"));
665 /// ```
666 #[error("No handler provided for command '{command}'")]
667 MissingHandler {
668 command: String,
669 /// Actionable hint surfaced to the user (not part of the Display string)
670 suggestion: Option<String>,
671 },
672}
673
674// ═══════════════════════════════════════════════════════════
675// HELPERS FOR CREATING CONTEXTUAL ERRORS
676// ═══════════════════════════════════════════════════════════
677
678impl ParseError {
679 /// Create an unknown command error with Levenshtein suggestions
680 ///
681 /// Automatically computes similar command names from the available list.
682 ///
683 /// # Arguments
684 ///
685 /// * `command` - The command typed by the user
686 /// * `available` - List of available commands
687 ///
688 /// # Example
689 ///
690 /// ```
691 /// use dynamic_cli::error::ParseError;
692 ///
693 /// let available = vec!["simulate".to_string(), "validate".to_string()];
694 /// let error = ParseError::unknown_command_with_suggestions("simulat", &available);
695 /// match error {
696 /// ParseError::UnknownCommand { suggestions, .. } => {
697 /// assert!(suggestions.contains(&"simulate".to_string()));
698 /// }
699 /// _ => panic!("wrong variant"),
700 /// }
701 /// ```
702 pub fn unknown_command_with_suggestions(command: &str, available: &[String]) -> Self {
703 let suggestions = crate::error::find_similar_strings(command, available, 3);
704 Self::UnknownCommand {
705 command: command.to_string(),
706 suggestions,
707 }
708 }
709
710 /// Create an unknown option error with Levenshtein suggestions
711 ///
712 /// # Example
713 ///
714 /// ```
715 /// use dynamic_cli::error::ParseError;
716 ///
717 /// let available = vec!["--verbose".to_string(), "--output".to_string()];
718 /// let error = ParseError::unknown_option_with_suggestions("--verbos", "run", &available);
719 /// match error {
720 /// ParseError::UnknownOption { suggestions, .. } => {
721 /// assert!(suggestions.contains(&"--verbose".to_string()));
722 /// }
723 /// _ => panic!("wrong variant"),
724 /// }
725 /// ```
726 pub fn unknown_option_with_suggestions(
727 flag: &str,
728 command: &str,
729 available: &[String],
730 ) -> Self {
731 let suggestions = crate::error::find_similar_strings(flag, available, 2);
732 Self::UnknownOption {
733 flag: flag.to_string(),
734 command: command.to_string(),
735 suggestions,
736 }
737 }
738
739 /// Create a missing argument error with a help hint
740 ///
741 /// The suggestion automatically refers the user to `--help <command>`.
742 ///
743 /// # Example
744 ///
745 /// ```
746 /// use dynamic_cli::error::ParseError;
747 ///
748 /// let error = ParseError::missing_argument("filename", "process");
749 /// match error {
750 /// ParseError::MissingArgument { suggestion, .. } => {
751 /// assert!(suggestion.is_some());
752 /// }
753 /// _ => panic!("wrong variant"),
754 /// }
755 /// ```
756 pub fn missing_argument(argument: &str, command: &str) -> Self {
757 Self::MissingArgument {
758 argument: argument.to_string(),
759 command: command.to_string(),
760 suggestion: Some(format!("Run --help {command} to see required arguments.")),
761 }
762 }
763
764 /// Create a missing option error with a help hint
765 ///
766 /// The suggestion automatically refers the user to `--help <command>`.
767 ///
768 /// # Example
769 ///
770 /// ```
771 /// use dynamic_cli::error::ParseError;
772 ///
773 /// let error = ParseError::missing_option("output", "export");
774 /// match error {
775 /// ParseError::MissingOption { suggestion, .. } => {
776 /// assert!(suggestion.is_some());
777 /// }
778 /// _ => panic!("wrong variant"),
779 /// }
780 /// ```
781 pub fn missing_option(option: &str, command: &str) -> Self {
782 Self::MissingOption {
783 option: option.to_string(),
784 command: command.to_string(),
785 suggestion: Some(format!("Run --help {command} to see required options.")),
786 }
787 }
788
789 /// Create a too-many-arguments error with a help hint
790 ///
791 /// # Example
792 ///
793 /// ```
794 /// use dynamic_cli::error::ParseError;
795 ///
796 /// let error = ParseError::too_many_arguments("run", 1, 3);
797 /// match error {
798 /// ParseError::TooManyArguments { suggestion, .. } => {
799 /// assert!(suggestion.is_some());
800 /// }
801 /// _ => panic!("wrong variant"),
802 /// }
803 /// ```
804 pub fn too_many_arguments(command: &str, expected: usize, got: usize) -> Self {
805 Self::TooManyArguments {
806 command: command.to_string(),
807 expected,
808 got,
809 suggestion: Some(format!("Run --help {command} for the expected usage.")),
810 }
811 }
812}
813
814impl ConfigError {
815 /// Create a file-not-found error with a standard suggestion
816 ///
817 /// # Example
818 ///
819 /// ```
820 /// use dynamic_cli::error::ConfigError;
821 /// use std::path::PathBuf;
822 ///
823 /// let error = ConfigError::file_not_found(PathBuf::from("commands.yaml"));
824 /// match error {
825 /// ConfigError::FileNotFound { suggestion, .. } => {
826 /// assert!(suggestion.is_some());
827 /// }
828 /// _ => panic!("wrong variant"),
829 /// }
830 /// ```
831 pub fn file_not_found(path: PathBuf) -> Self {
832 Self::FileNotFound {
833 path,
834 suggestion: Some("Verify the path and file permissions.".to_string()),
835 }
836 }
837
838 /// Create an unsupported-format error with a standard suggestion
839 ///
840 /// # Example
841 ///
842 /// ```
843 /// use dynamic_cli::error::ConfigError;
844 ///
845 /// let error = ConfigError::unsupported_format(".toml");
846 /// match error {
847 /// ConfigError::UnsupportedFormat { suggestion, .. } => {
848 /// assert!(suggestion.is_some());
849 /// }
850 /// _ => panic!("wrong variant"),
851 /// }
852 /// ```
853 pub fn unsupported_format(extension: &str) -> Self {
854 Self::UnsupportedFormat {
855 extension: extension.to_string(),
856 suggestion: Some("Rename the file with a .yaml, .yml or .json extension.".to_string()),
857 }
858 }
859
860 /// Create a YAML parse error with position extracted from the serde error
861 pub fn yaml_parse_with_location(source: serde_yaml::Error) -> Self {
862 let location = source.location();
863 Self::YamlParse {
864 source,
865 line: location.as_ref().map(|l| l.line()),
866 column: location.map(|l| l.column()),
867 }
868 }
869
870 /// Create a JSON parse error with position extracted from the serde error
871 pub fn json_parse_with_location(source: serde_json::Error) -> Self {
872 Self::JsonParse {
873 line: source.line(),
874 column: source.column(),
875 source,
876 }
877 }
878}
879
880impl ExecutionError {
881 /// Create a handler-not-found error with an actionable suggestion
882 ///
883 /// The suggestion interpolates the implementation name so the user
884 /// knows exactly which `.register_handler()` call is missing.
885 ///
886 /// # Example
887 ///
888 /// ```
889 /// use dynamic_cli::error::ExecutionError;
890 ///
891 /// let error = ExecutionError::handler_not_found("run", "run_handler");
892 /// match error {
893 /// ExecutionError::HandlerNotFound { suggestion, .. } => {
894 /// assert!(suggestion.as_deref().unwrap_or("").contains("run_handler"));
895 /// }
896 /// _ => panic!("wrong variant"),
897 /// }
898 /// ```
899 pub fn handler_not_found(command: &str, implementation: &str) -> Self {
900 Self::HandlerNotFound {
901 command: command.to_string(),
902 implementation: implementation.to_string(),
903 suggestion: Some(format!(
904 "Ensure .register_handler(\"{implementation}\", ...) was called before running."
905 )),
906 }
907 }
908}
909
910impl RegistryError {
911 /// Create a missing-handler error with an actionable suggestion
912 ///
913 /// The suggestion interpolates the command name so the user
914 /// knows exactly which `.register_handler()` call is missing.
915 ///
916 /// # Example
917 ///
918 /// ```
919 /// use dynamic_cli::error::RegistryError;
920 ///
921 /// let error = RegistryError::missing_handler("export");
922 /// match error {
923 /// RegistryError::MissingHandler { suggestion, .. } => {
924 /// assert!(suggestion.as_deref().unwrap_or("").contains("export"));
925 /// }
926 /// _ => panic!("wrong variant"),
927 /// }
928 /// ```
929 pub fn missing_handler(command: &str) -> Self {
930 Self::MissingHandler {
931 command: command.to_string(),
932 suggestion: Some(format!(
933 "Call .register_handler(\"{command}\", ...) before running."
934 )),
935 }
936 }
937}
938
939#[cfg(test)]
940mod tests {
941 use super::*;
942
943 // ── ConfigError ──────────────────────────────────────────
944
945 #[test]
946 fn test_config_file_not_found_display() {
947 let error = ConfigError::FileNotFound {
948 path: PathBuf::from("/path/to/config.yaml"),
949 suggestion: None,
950 };
951 let msg = format!("{}", error);
952 assert!(msg.contains("not found"));
953 assert!(msg.contains("config.yaml"));
954 }
955
956 #[test]
957 fn test_config_file_not_found_helper_has_suggestion() {
958 let error = ConfigError::file_not_found(PathBuf::from("commands.yaml"));
959 match error {
960 ConfigError::FileNotFound { suggestion, .. } => {
961 assert!(suggestion.is_some(), "helper must populate suggestion");
962 }
963 _ => panic!("wrong variant"),
964 }
965 }
966
967 #[test]
968 fn test_config_unsupported_format_helper_has_suggestion() {
969 let error = ConfigError::unsupported_format(".toml");
970 match error {
971 ConfigError::UnsupportedFormat {
972 suggestion,
973 extension,
974 ..
975 } => {
976 assert_eq!(extension, ".toml");
977 assert!(suggestion.is_some());
978 }
979 _ => panic!("wrong variant"),
980 }
981 }
982
983 #[test]
984 fn test_config_duplicate_command_display() {
985 let error = ConfigError::DuplicateCommand {
986 name: "run".to_string(),
987 suggestion: Some("Rename one of the conflicting commands.".to_string()),
988 };
989 let msg = format!("{}", error);
990 assert!(msg.contains("run"));
991 // suggestion must NOT appear in Display (it's rendered separately)
992 assert!(!msg.contains("Rename"));
993 }
994
995 #[test]
996 fn test_config_unknown_type_display() {
997 let error = ConfigError::UnknownType {
998 type_name: "datetime".to_string(),
999 context: "commands[0]".to_string(),
1000 suggestion: None,
1001 };
1002 let msg = format!("{}", error);
1003 assert!(msg.contains("datetime"));
1004 }
1005
1006 #[test]
1007 fn test_config_inconsistency_display() {
1008 let error = ConfigError::Inconsistency {
1009 details: "default not in choices".to_string(),
1010 suggestion: Some("hint".to_string()),
1011 };
1012 let msg = format!("{}", error);
1013 assert!(msg.contains("default not in choices"));
1014 assert!(!msg.contains("hint")); // suggestion separate from Display
1015 }
1016
1017 #[test]
1018 fn test_config_invalid_schema_display() {
1019 let error = ConfigError::InvalidSchema {
1020 reason: "missing field".to_string(),
1021 path: Some("commands[0]".to_string()),
1022 suggestion: None,
1023 };
1024 let msg = format!("{}", error);
1025 assert!(msg.contains("missing field"));
1026 }
1027
1028 // ── ParseError ───────────────────────────────────────────
1029
1030 #[test]
1031 fn test_parse_unknown_command_with_suggestions() {
1032 let available = vec!["simulate".to_string(), "validate".to_string()];
1033 let error = ParseError::unknown_command_with_suggestions("simulat", &available);
1034 match error {
1035 ParseError::UnknownCommand {
1036 command,
1037 suggestions,
1038 } => {
1039 assert_eq!(command, "simulat");
1040 assert!(suggestions.contains(&"simulate".to_string()));
1041 }
1042 _ => panic!("wrong variant"),
1043 }
1044 }
1045
1046 #[test]
1047 fn test_parse_missing_argument_helper_has_suggestion() {
1048 let error = ParseError::missing_argument("filename", "process");
1049 match error {
1050 ParseError::MissingArgument {
1051 suggestion,
1052 command,
1053 ..
1054 } => {
1055 assert_eq!(command, "process");
1056 let s = suggestion.unwrap();
1057 assert!(s.contains("process"));
1058 assert!(s.contains("--help"));
1059 }
1060 _ => panic!("wrong variant"),
1061 }
1062 }
1063
1064 #[test]
1065 fn test_parse_missing_option_helper_has_suggestion() {
1066 let error = ParseError::missing_option("output", "export");
1067 match error {
1068 ParseError::MissingOption {
1069 suggestion, option, ..
1070 } => {
1071 assert_eq!(option, "output");
1072 let s = suggestion.unwrap();
1073 assert!(s.contains("export"));
1074 }
1075 _ => panic!("wrong variant"),
1076 }
1077 }
1078
1079 #[test]
1080 fn test_parse_too_many_arguments_helper_has_suggestion() {
1081 let error = ParseError::too_many_arguments("run", 1, 3);
1082 match error {
1083 ParseError::TooManyArguments {
1084 suggestion,
1085 expected,
1086 got,
1087 ..
1088 } => {
1089 assert_eq!(expected, 1);
1090 assert_eq!(got, 3);
1091 assert!(suggestion.is_some());
1092 }
1093 _ => panic!("wrong variant"),
1094 }
1095 }
1096
1097 #[test]
1098 fn test_parse_missing_argument_suggestion_none_by_default() {
1099 // Direct construction without helper: suggestion is caller's responsibility
1100 let error = ParseError::MissingArgument {
1101 argument: "file".to_string(),
1102 command: "run".to_string(),
1103 suggestion: None,
1104 };
1105 match error {
1106 ParseError::MissingArgument { suggestion, .. } => assert!(suggestion.is_none()),
1107 _ => panic!("wrong variant"),
1108 }
1109 }
1110
1111 // ── ValidationError ──────────────────────────────────────
1112
1113 #[test]
1114 fn test_validation_out_of_range_display() {
1115 let error = ValidationError::OutOfRange {
1116 arg_name: "percentage".to_string(),
1117 value: 150.0,
1118 min: 0.0,
1119 max: 100.0,
1120 suggestion: None,
1121 };
1122 let msg = format!("{}", error);
1123 assert!(msg.contains("percentage"));
1124 assert!(msg.contains("150"));
1125 assert!(msg.contains("0"));
1126 assert!(msg.contains("100"));
1127 }
1128
1129 #[test]
1130 fn test_validation_out_of_range_suggestion_not_in_display() {
1131 let error = ValidationError::OutOfRange {
1132 arg_name: "percentage".to_string(),
1133 value: 150.0,
1134 min: 0.0,
1135 max: 100.0,
1136 suggestion: Some("Value must be between 0 and 100.".to_string()),
1137 };
1138 let msg = format!("{}", error);
1139 assert!(!msg.contains("Value must be between")); // suggestion is separate
1140 }
1141
1142 #[test]
1143 fn test_validation_file_not_found_suggestion() {
1144 let error = ValidationError::FileNotFound {
1145 path: PathBuf::from("data.csv"),
1146 arg_name: "input".to_string(),
1147 suggestion: Some("Check that the file exists.".to_string()),
1148 };
1149 match error {
1150 ValidationError::FileNotFound { suggestion, .. } => {
1151 assert!(suggestion.is_some());
1152 }
1153 _ => panic!("wrong variant"),
1154 }
1155 }
1156
1157 #[test]
1158 fn test_validation_missing_dependency_suggestion() {
1159 let error = ValidationError::MissingDependency {
1160 arg_name: "format".to_string(),
1161 required_arg: "output".to_string(),
1162 suggestion: Some("Add --output to your command.".to_string()),
1163 };
1164 let msg = format!("{}", error);
1165 assert!(msg.contains("format"));
1166 assert!(msg.contains("output"));
1167 }
1168
1169 #[test]
1170 fn test_validation_mutually_exclusive_suggestion() {
1171 let error = ValidationError::MutuallyExclusive {
1172 arg1: "--verbose".to_string(),
1173 arg2: "--quiet".to_string(),
1174 suggestion: Some("Remove one of the two conflicting options.".to_string()),
1175 };
1176 let msg = format!("{}", error);
1177 assert!(msg.contains("--verbose"));
1178 assert!(msg.contains("--quiet"));
1179 }
1180
1181 // ── ExecutionError ───────────────────────────────────────
1182
1183 #[test]
1184 fn test_execution_handler_not_found_helper_interpolates_impl() {
1185 let error = ExecutionError::handler_not_found("run", "run_handler");
1186 match error {
1187 ExecutionError::HandlerNotFound {
1188 suggestion,
1189 implementation,
1190 ..
1191 } => {
1192 assert_eq!(implementation, "run_handler");
1193 let s = suggestion.unwrap();
1194 assert!(s.contains("run_handler"));
1195 assert!(s.contains("register_handler"));
1196 }
1197 _ => panic!("wrong variant"),
1198 }
1199 }
1200
1201 #[test]
1202 fn test_execution_context_downcast_failed_display() {
1203 let error = ExecutionError::ContextDowncastFailed {
1204 expected_type: "MyAppContext".to_string(),
1205 suggestion: None,
1206 };
1207 let msg = format!("{}", error);
1208 assert!(msg.contains("MyAppContext"));
1209 }
1210
1211 #[test]
1212 fn test_execution_invalid_context_state_suggestion() {
1213 let error = ExecutionError::InvalidContextState {
1214 reason: "pool not ready".to_string(),
1215 suggestion: Some("Ensure context is initialised.".to_string()),
1216 };
1217 let msg = format!("{}", error);
1218 assert!(msg.contains("pool not ready"));
1219 }
1220
1221 // ── RegistryError ────────────────────────────────────────
1222
1223 #[test]
1224 fn test_registry_missing_handler_helper_interpolates_command() {
1225 let error = RegistryError::missing_handler("export");
1226 match error {
1227 RegistryError::MissingHandler {
1228 suggestion,
1229 command,
1230 } => {
1231 assert_eq!(command, "export");
1232 let s = suggestion.unwrap();
1233 assert!(s.contains("export"));
1234 assert!(s.contains("register_handler"));
1235 }
1236 _ => panic!("wrong variant"),
1237 }
1238 }
1239
1240 #[test]
1241 fn test_registry_duplicate_registration_display() {
1242 let error = RegistryError::DuplicateRegistration {
1243 name: "run".to_string(),
1244 suggestion: None,
1245 };
1246 let msg = format!("{}", error);
1247 assert!(msg.contains("run"));
1248 }
1249
1250 #[test]
1251 fn test_registry_duplicate_alias_display() {
1252 let error = RegistryError::DuplicateAlias {
1253 alias: "r".to_string(),
1254 existing_command: "run".to_string(),
1255 suggestion: Some("Choose a different alias.".to_string()),
1256 };
1257 let msg = format!("{}", error);
1258 assert!(msg.contains("run"));
1259 assert!(!msg.contains("Choose")); // suggestion separate from Display
1260 }
1261}