dynamic_cli/error/
types.rs

1//! Error types for dynamic-cli
2//!
3//! Defines all possible error types with context and clear messages.
4
5use std::path::PathBuf;
6use thiserror::Error;
7
8/// Main error for the dynamic-cli framework
9///
10/// Encompasses all possible error categories. Uses `thiserror`
11/// to automatically generate `Display` and `Error` implementations.
12#[derive(Debug, Error)]
13pub enum DynamicCliError {
14    /// Errors related to the configuration file
15    #[error(transparent)]
16    Config(#[from] ConfigError),
17
18    /// Command parsing errors
19    #[error(transparent)]
20    Parse(#[from] ParseError),
21
22    /// Validation errors
23    #[error(transparent)]
24    Validation(#[from] ValidationError),
25
26    /// Execution errors
27    #[error(transparent)]
28    Execution(#[from] ExecutionError),
29
30    /// Registry errors
31    #[error(transparent)]
32    Registry(#[from] RegistryError),
33
34    /// I/O errors
35    #[error("I/O error: {0}")]
36    Io(#[from] std::io::Error),
37}
38
39// ═══════════════════════════════════════════════════════════
40// CONFIGURATION ERRORS
41// ═══════════════════════════════════════════════════════════
42
43/// Errors related to loading and parsing the configuration file
44///
45/// These errors occur when loading the `commands.yaml` or `commands.json`
46/// file and its structural validation.
47#[derive(Debug, Error)]
48pub enum ConfigError {
49    /// Configuration file not found
50    ///
51    /// # Example
52    ///
53    /// ```
54    /// use dynamic_cli::error::ConfigError;
55    /// use std::path::PathBuf;
56    ///
57    /// let error = ConfigError::FileNotFound {
58    ///     path: PathBuf::from("missing.yaml"),
59    /// };
60    /// ```
61    #[error("Configuration file not found: {path:?}")]
62    FileNotFound { path: PathBuf },
63
64    /// Unsupported file extension
65    ///
66    /// Only `.yaml`, `.yml` and `.json` are supported.
67    #[error("Unsupported file format: '{extension}'. Supported: .yaml, .yml, .json")]
68    UnsupportedFormat { extension: String },
69
70    /// YAML parsing error
71    #[error("Failed to parse YAML configuration at line {line:?}, column {column:?}: {source}")]
72    YamlParse {
73        #[source]
74        source: serde_yaml::Error,
75        /// Position in the file (if available)
76        line: Option<usize>,
77        column: Option<usize>,
78    },
79
80    /// JSON parsing error
81    #[error("Failed to parse JSON configuration at line {line}, column {column}: {source}")]
82    JsonParse {
83        #[source]
84        source: serde_json::Error,
85        /// Position in the file
86        line: usize,
87        column: usize,
88    },
89
90    /// Invalid configuration schema
91    ///
92    /// The file structure doesn't match the expected format.
93    #[error("Invalid configuration schema: {reason} (at {path:?})")]
94    InvalidSchema {
95        reason: String,
96        /// Path in the config (e.g., "commands[0].options[2].type")
97        path: Option<String>,
98    },
99
100    /// Duplicate command (same name or alias)
101    #[error("Duplicate command name or alias: '{name}'")]
102    DuplicateCommand { name: String },
103
104    /// Unknown argument type
105    #[error("Unknown argument type: '{type_name}' in {context}")]
106    UnknownType { type_name: String, context: String },
107
108    /// Inconsistent configuration
109    ///
110    /// For example, a default value that's not in the allowed choices.
111    #[error("Configuration inconsistency: {details}")]
112    Inconsistency { details: String },
113}
114
115// ═══════════════════════════════════════════════════════════
116// PARSING ERRORS
117// ═══════════════════════════════════════════════════════════
118
119/// Errors when parsing user commands
120///
121/// These errors occur when analyzing arguments provided
122/// by the user in CLI or REPL mode.
123#[derive(Debug, Error)]
124pub enum ParseError {
125    /// Unknown command
126    ///
127    /// The user typed a command that doesn't exist.
128    /// Includes suggestions based on Levenshtein distance.
129    #[error("Unknown command: '{command}'. Type 'help' for available commands.")]
130    UnknownCommand {
131        command: String,
132        /// Similar command suggestions
133        suggestions: Vec<String>,
134    },
135
136    /// Missing required positional argument
137    #[error("Missing required argument: {argument} for command '{command}'")]
138    MissingArgument { argument: String, command: String },
139
140    /// Missing required option
141    #[error("Missing required option: --{option} for command '{command}'")]
142    MissingOption { option: String, command: String },
143
144    /// Too many positional arguments
145    #[error("Too many arguments for command '{command}'. Expected {expected}, got {got}")]
146    TooManyArguments {
147        command: String,
148        expected: usize,
149        got: usize,
150    },
151
152    /// Unknown option
153    ///
154    /// Includes similar option suggestions.
155    #[error("Unknown option: {flag} for command '{command}'")]
156    UnknownOption {
157        flag: String,
158        command: String,
159        /// Similar option suggestions
160        suggestions: Vec<String>,
161    },
162
163    /// Type parsing error
164    ///
165    /// The user provided a value that can't be converted
166    /// to the expected type (e.g., "abc" for an integer).
167    #[error("Failed to parse {arg_name} as {expected_type}: '{value}'{}", 
168        .details.as_ref().map(|d| format!(" ({})", d)).unwrap_or_default())]
169    TypeParseError {
170        arg_name: String,
171        expected_type: String,
172        value: String,
173        /// Error details (e.g., "not a valid integer")
174        details: Option<String>,
175    },
176
177    /// Value not in allowed choices
178    #[error("Invalid value for {arg_name}: '{value}'. Must be one of: {}", 
179        .choices.join(", "))]
180    InvalidChoice {
181        arg_name: String,
182        value: String,
183        choices: Vec<String>,
184    },
185
186    /// Invalid command syntax
187    #[error("Invalid command syntax: {details}{}", 
188        .hint.as_ref().map(|h| format!("\nHint: {}", h)).unwrap_or_default())]
189    InvalidSyntax {
190        details: String,
191        /// Example of correct syntax
192        hint: Option<String>,
193    },
194}
195
196// ═══════════════════════════════════════════════════════════
197// VALIDATION ERRORS
198// ═══════════════════════════════════════════════════════════
199
200/// Errors during argument validation
201///
202/// These errors occur after parsing, during validation
203/// of constraints defined in the configuration.
204#[derive(Debug, Error)]
205pub enum ValidationError {
206    /// Required file doesn't exist
207    #[error("File not found for argument '{arg_name}': {path:?}")]
208    FileNotFound { path: PathBuf, arg_name: String },
209
210    /// Invalid file extension
211    #[error("Invalid file extension for {arg_name}: {path:?}. Expected: {}", 
212        .expected.join(", "))]
213    InvalidExtension {
214        arg_name: String,
215        path: PathBuf,
216        expected: Vec<String>,
217    },
218
219    /// Value out of allowed range
220    #[error("{arg_name} must be between {min} and {max}, got {value}")]
221    OutOfRange {
222        arg_name: String,
223        value: f64,
224        min: f64,
225        max: f64,
226    },
227
228    /// Custom constraint not met
229    #[error("Validation failed for {arg_name}: {reason}")]
230    CustomConstraint { arg_name: String, reason: String },
231
232    /// Dependency between arguments not satisfied
233    ///
234    /// Some arguments require the presence of other arguments.
235    #[error("{arg_name} requires {required_arg} to be specified")]
236    MissingDependency {
237        arg_name: String,
238        required_arg: String,
239    },
240
241    /// Mutually exclusive arguments
242    ///
243    /// Some arguments cannot be used together.
244    #[error("Options {arg1} and {arg2} cannot be used together")]
245    MutuallyExclusive { arg1: String, arg2: String },
246}
247
248// ═══════════════════════════════════════════════════════════
249// EXECUTION ERRORS
250// ═══════════════════════════════════════════════════════════
251
252/// Errors during command execution
253///
254/// These errors occur during user code execution.
255#[derive(Debug, Error)]
256pub enum ExecutionError {
257    /// Command handler not found
258    ///
259    /// The implementation name in the config doesn't match any
260    /// registered handler.
261    #[error("No handler registered for command '{command}' (implementation: '{implementation}')")]
262    HandlerNotFound {
263        command: String,
264        implementation: String,
265    },
266
267    /// Error during context downcasting
268    ///
269    /// The handler tried to downcast the context to an incorrect type.
270    #[error("Failed to downcast execution context to expected type: {expected_type}")]
271    ContextDowncastFailed { expected_type: String },
272
273    /// Invalid context state for this operation
274    #[error("Invalid context state: {reason}")]
275    InvalidContextState { reason: String },
276
277    /// Error in command implementation
278    ///
279    /// Wraps errors from user code.
280    #[error("Command execution failed: {0}")]
281    CommandFailed(#[source] anyhow::Error),
282
283    /// Command interrupted by user
284    ///
285    /// User pressed Ctrl+C during execution.
286    #[error("Command interrupted by user")]
287    Interrupted,
288}
289
290// ═══════════════════════════════════════════════════════════
291// REGISTRY ERRORS
292// ═══════════════════════════════════════════════════════════
293
294/// Errors related to the command registry
295///
296/// These errors occur when registering commands
297/// and handlers in the registry.
298#[derive(Debug, Error)]
299pub enum RegistryError {
300    /// Attempt to register an already existing command
301    #[error("Command '{name}' is already registered")]
302    DuplicateRegistration { name: String },
303
304    /// Alias already used by another command
305    #[error("Alias '{alias}' is already used by command '{existing_command}'")]
306    DuplicateAlias {
307        alias: String,
308        existing_command: String,
309    },
310
311    /// Missing handler for a definition
312    ///
313    /// A command is defined in the config but no handler
314    /// has been registered for it.
315    #[error("No handler provided for command '{command}'")]
316    MissingHandler { command: String },
317}
318
319// ═══════════════════════════════════════════════════════════
320// HELPERS FOR CREATING CONTEXTUAL ERRORS
321// ═══════════════════════════════════════════════════════════
322
323impl ParseError {
324    /// Create an unknown command error with suggestions
325    ///
326    /// Uses Levenshtein distance to find similar commands.
327    ///
328    /// # Arguments
329    ///
330    /// * `command` - The command typed by the user
331    /// * `available` - List of available commands
332    ///
333    /// # Example
334    ///
335    /// ```
336    /// use dynamic_cli::error::ParseError;
337    ///
338    /// let available = vec!["simulate".to_string(), "validate".to_string()];
339    /// let error = ParseError::unknown_command_with_suggestions("simulat", &available);
340    /// ```
341    pub fn unknown_command_with_suggestions(command: &str, available: &[String]) -> Self {
342        let suggestions = crate::error::find_similar_strings(command, available, 3);
343        Self::UnknownCommand {
344            command: command.to_string(),
345            suggestions,
346        }
347    }
348
349    /// Create an unknown option error with suggestions
350    pub fn unknown_option_with_suggestions(
351        flag: &str,
352        command: &str,
353        available: &[String],
354    ) -> Self {
355        let suggestions = crate::error::find_similar_strings(flag, available, 2);
356        Self::UnknownOption {
357            flag: flag.to_string(),
358            command: command.to_string(),
359            suggestions,
360        }
361    }
362}
363
364impl ConfigError {
365    /// Create a YAML error with position
366    ///
367    /// Extracts position information from the serde_yaml error.
368    pub fn yaml_parse_with_location(source: serde_yaml::Error) -> Self {
369        let location = source.location();
370        Self::YamlParse {
371            source,
372            line: location.as_ref().map(|l| l.line()),
373            column: location.map(|l| l.column()),
374        }
375    }
376
377    /// Create a JSON error with position
378    ///
379    /// Extracts position information from the serde_json error.
380    pub fn json_parse_with_location(source: serde_json::Error) -> Self {
381        Self::JsonParse {
382            line: source.line(),
383            column: source.column(),
384            source,
385        }
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn test_config_error_display() {
395        let error = ConfigError::FileNotFound {
396            path: PathBuf::from("/path/to/config.yaml"),
397        };
398        let message = format!("{}", error);
399        assert!(message.contains("not found"));
400        assert!(message.contains("config.yaml"));
401    }
402
403    #[test]
404    fn test_parse_error_with_suggestions() {
405        let available = vec!["simulate".to_string(), "validate".to_string()];
406        let error = ParseError::unknown_command_with_suggestions("simulat", &available);
407
408        match error {
409            ParseError::UnknownCommand {
410                command,
411                suggestions,
412            } => {
413                assert_eq!(command, "simulat");
414                assert!(!suggestions.is_empty());
415                assert!(suggestions.contains(&"simulate".to_string()));
416            }
417            _ => panic!("Wrong error type"),
418        }
419    }
420
421    #[test]
422    fn test_validation_error_display() {
423        let error = ValidationError::OutOfRange {
424            arg_name: "percentage".to_string(),
425            value: 150.0,
426            min: 0.0,
427            max: 100.0,
428        };
429        let message = format!("{}", error);
430        assert!(message.contains("percentage"));
431        assert!(message.contains("150"));
432        assert!(message.contains("0"));
433        assert!(message.contains("100"));
434    }
435}