Skip to main content

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}