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    /// WASM plugin errors (only available with the `wasm-plugins` feature)
40    #[cfg(feature = "wasm-plugins")]
41    #[error(transparent)]
42    Wasm(#[from] WasmError),
43
44    /// I/O errors
45    #[error("I/O error: {0}")]
46    Io(#[from] std::io::Error),
47}
48
49// ═══════════════════════════════════════════════════════════
50// CONFIGURATION ERRORS
51// ═══════════════════════════════════════════════════════════
52
53/// Errors related to loading and parsing the configuration file
54///
55/// These errors occur when loading the `commands.yaml` or `commands.json`
56/// file and its structural validation.
57#[derive(Debug, Error)]
58pub enum ConfigError {
59    /// Configuration file not found
60    ///
61    /// # Example
62    ///
63    /// ```
64    /// use dynamic_cli::error::ConfigError;
65    /// use std::path::PathBuf;
66    ///
67    /// let error = ConfigError::FileNotFound {
68    ///     path: PathBuf::from("missing.yaml"),
69    ///     suggestion: Some("Verify the path and file permissions.".to_string()),
70    /// };
71    /// let msg = format!("{}", error);
72    /// assert!(msg.contains("missing.yaml"));
73    /// ```
74    #[error("Configuration file not found: {path:?}")]
75    FileNotFound {
76        path: PathBuf,
77        /// Actionable hint surfaced to the user (not part of the Display string)
78        suggestion: Option<String>,
79    },
80
81    /// Unsupported file extension
82    ///
83    /// Only `.yaml`, `.yml` and `.json` are supported.
84    ///
85    /// # Example
86    ///
87    /// ```
88    /// use dynamic_cli::error::ConfigError;
89    ///
90    /// let error = ConfigError::UnsupportedFormat {
91    ///     extension: ".toml".to_string(),
92    ///     suggestion: Some("Rename the file with a .yaml, .yml or .json extension.".to_string()),
93    /// };
94    /// let msg = format!("{}", error);
95    /// assert!(msg.contains(".toml"));
96    /// ```
97    #[error("Unsupported file format: '{extension}'. Supported: .yaml, .yml, .json")]
98    UnsupportedFormat {
99        extension: String,
100        /// Actionable hint surfaced to the user (not part of the Display string)
101        suggestion: Option<String>,
102    },
103
104    /// YAML parsing error
105    #[error("Failed to parse YAML configuration at line {line:?}, column {column:?}: {source}")]
106    YamlParse {
107        #[source]
108        source: serde_yaml::Error,
109        /// Position in the file (if available)
110        line: Option<usize>,
111        column: Option<usize>,
112    },
113
114    /// JSON parsing error
115    #[error("Failed to parse JSON configuration at line {line}, column {column}: {source}")]
116    JsonParse {
117        #[source]
118        source: serde_json::Error,
119        /// Position in the file
120        line: usize,
121        column: usize,
122    },
123
124    /// Invalid configuration schema
125    ///
126    /// The file structure doesn't match the expected format.
127    ///
128    /// # Example
129    ///
130    /// ```
131    /// use dynamic_cli::error::ConfigError;
132    ///
133    /// let error = ConfigError::InvalidSchema {
134    ///     reason: "Missing required field 'name'".to_string(),
135    ///     path: Some("commands[0]".to_string()),
136    ///     suggestion: Some("Add a 'name' field to each command entry.".to_string()),
137    /// };
138    /// let msg = format!("{}", error);
139    /// assert!(msg.contains("Missing required field"));
140    /// ```
141    #[error("Invalid configuration schema: {reason} (at {path:?})")]
142    InvalidSchema {
143        reason: String,
144        /// Path in the config (e.g., `"commands[0].options[2].type"`)
145        path: Option<String>,
146        /// Actionable hint surfaced to the user (not part of the Display string)
147        suggestion: Option<String>,
148    },
149
150    /// Duplicate command (same name or alias)
151    ///
152    /// # Example
153    ///
154    /// ```
155    /// use dynamic_cli::error::ConfigError;
156    ///
157    /// let error = ConfigError::DuplicateCommand {
158    ///     name: "run".to_string(),
159    ///     suggestion: Some("Rename one of the conflicting commands or aliases.".to_string()),
160    /// };
161    /// let msg = format!("{}", error);
162    /// assert!(msg.contains("run"));
163    /// ```
164    #[error("Duplicate command name or alias: '{name}'")]
165    DuplicateCommand {
166        name: String,
167        /// Actionable hint surfaced to the user (not part of the Display string)
168        suggestion: Option<String>,
169    },
170
171    /// Unknown argument type
172    ///
173    /// # Example
174    ///
175    /// ```
176    /// use dynamic_cli::error::ConfigError;
177    ///
178    /// let error = ConfigError::UnknownType {
179    ///     type_name: "datetime".to_string(),
180    ///     context: "commands[1].options[0]".to_string(),
181    ///     suggestion: Some(
182    ///         "Supported types: string, integer, float, boolean, file.".to_string()
183    ///     ),
184    /// };
185    /// let msg = format!("{}", error);
186    /// assert!(msg.contains("datetime"));
187    /// ```
188    #[error("Unknown argument type: '{type_name}' in {context}")]
189    UnknownType {
190        type_name: String,
191        context: String,
192        /// Actionable hint surfaced to the user (not part of the Display string)
193        suggestion: Option<String>,
194    },
195
196    /// Inconsistent configuration
197    ///
198    /// For example, a default value that's not in the allowed choices.
199    ///
200    /// # Example
201    ///
202    /// ```
203    /// use dynamic_cli::error::ConfigError;
204    ///
205    /// let error = ConfigError::Inconsistency {
206    ///     details: "Default value 'fast' is not in choices: slow, medium".to_string(),
207    ///     suggestion: Some("Ensure the default value matches one of the allowed choices.".to_string()),
208    /// };
209    /// let msg = format!("{}", error);
210    /// assert!(msg.contains("Default value"));
211    /// ```
212    #[error("Configuration inconsistency: {details}")]
213    Inconsistency {
214        details: String,
215        /// Actionable hint surfaced to the user (not part of the Display string)
216        suggestion: Option<String>,
217    },
218}
219
220// ═══════════════════════════════════════════════════════════
221// PARSING ERRORS
222// ═══════════════════════════════════════════════════════════
223
224/// Errors when parsing user commands
225///
226/// These errors occur when analyzing arguments provided
227/// by the user in CLI or REPL mode.
228///
229/// Marked `#[non_exhaustive]` (#37): repeatable-option parsing
230/// added four variants in one release, and more argument-shape features
231/// are expected before v1.0.0. External `match` expressions must include
232/// a wildcard arm.
233#[derive(Debug, Error)]
234#[non_exhaustive]
235pub enum ParseError {
236    /// Unknown command
237    ///
238    /// The user typed a command that doesn't exist.
239    /// Includes suggestions based on Levenshtein distance.
240    ///
241    /// The way to list the available commands depends on the interface
242    /// (`--help` in CLI mode, the help directive in the REPL), so the
243    /// interface that reports the error fills `hint`;
244    /// [`ParseError::unknown_command_with_suggestions`] leaves it empty.
245    ///
246    /// # Example
247    ///
248    /// ```
249    /// use dynamic_cli::error::ParseError;
250    ///
251    /// let error = ParseError::UnknownCommand {
252    ///     command: "simulat".to_string(),
253    ///     suggestions: vec!["simulate".to_string()],
254    ///     hint: Some("Run with '--help' for available commands".to_string()),
255    /// };
256    /// assert_eq!(error.to_string(), "Unknown command: 'simulat'");
257    /// ```
258    #[error("Unknown command: '{command}'")]
259    UnknownCommand {
260        command: String,
261        /// Similar command suggestions (from Levenshtein distance)
262        suggestions: Vec<String>,
263        /// How to list the available commands, set by the interface
264        /// (not part of the Display string)
265        hint: Option<String>,
266    },
267
268    /// Missing required positional argument
269    ///
270    /// # Example
271    ///
272    /// ```
273    /// use dynamic_cli::error::ParseError;
274    ///
275    /// let error = ParseError::MissingArgument {
276    ///     argument: "filename".to_string(),
277    ///     command: "process".to_string(),
278    ///     suggestion: Some("Run --help process to see required arguments.".to_string()),
279    /// };
280    /// let msg = format!("{}", error);
281    /// assert!(msg.contains("filename"));
282    /// ```
283    #[error("Missing required argument: {argument} for command '{command}'")]
284    MissingArgument {
285        argument: String,
286        command: String,
287        /// Actionable hint surfaced to the user (not part of the Display string)
288        suggestion: Option<String>,
289    },
290
291    /// Missing required option
292    ///
293    /// # Example
294    ///
295    /// ```
296    /// use dynamic_cli::error::ParseError;
297    ///
298    /// let error = ParseError::MissingOption {
299    ///     option: "output".to_string(),
300    ///     command: "export".to_string(),
301    ///     suggestion: Some("Run --help export to see required options.".to_string()),
302    /// };
303    /// let msg = format!("{}", error);
304    /// assert!(msg.contains("output"));
305    /// ```
306    #[error("Missing required option: --{option} for command '{command}'")]
307    MissingOption {
308        option: String,
309        command: String,
310        /// Actionable hint surfaced to the user (not part of the Display string)
311        suggestion: Option<String>,
312    },
313
314    /// Too many positional arguments
315    ///
316    /// # Example
317    ///
318    /// ```
319    /// use dynamic_cli::error::ParseError;
320    ///
321    /// let error = ParseError::TooManyArguments {
322    ///     command: "run".to_string(),
323    ///     expected: 1,
324    ///     got: 3,
325    ///     suggestion: Some("Run --help run for the expected usage.".to_string()),
326    /// };
327    /// let msg = format!("{}", error);
328    /// assert!(msg.contains("run"));
329    /// ```
330    #[error("Too many arguments for command '{command}'. Expected {expected}, got {got}")]
331    TooManyArguments {
332        command: String,
333        expected: usize,
334        got: usize,
335        /// Actionable hint surfaced to the user (not part of the Display string)
336        suggestion: Option<String>,
337    },
338
339    /// Unknown option
340    ///
341    /// Includes similar option suggestions.
342    #[error("Unknown option: {flag} for command '{command}'")]
343    UnknownOption {
344        flag: String,
345        command: String,
346        /// Similar option suggestions (from Levenshtein distance)
347        suggestions: Vec<String>,
348    },
349
350    /// Type parsing error
351    ///
352    /// The user provided a value that can't be converted
353    /// to the expected type (e.g., "abc" for an integer).
354    #[error("Failed to parse {arg_name} as {expected_type}: '{value}'{}", 
355        .details.as_ref().map(|d| format!(" ({})", d)).unwrap_or_default())]
356    TypeParseError {
357        arg_name: String,
358        expected_type: String,
359        value: String,
360        /// Error details (e.g., "not a valid integer")
361        details: Option<String>,
362    },
363
364    /// Value not in allowed choices
365    #[error("Invalid value for {arg_name}: '{value}'. Must be one of: {}", 
366        .choices.join(", "))]
367    InvalidChoice {
368        arg_name: String,
369        value: String,
370        choices: Vec<String>,
371    },
372
373    /// Invalid command syntax
374    #[error("Invalid command syntax: {details}{}", 
375        .hint.as_ref().map(|h| format!("\nHint: {}", h)).unwrap_or_default())]
376    InvalidSyntax {
377        details: String,
378        /// Example of correct syntax
379        hint: Option<String>,
380    },
381
382    /// Unknown key inside a repeatable option's occurrence
383    ///
384    /// The discriminant itself was valid, but a `key=value` pair used a
385    /// key not declared in that discriminant's `option_parameters`.
386    ///
387    /// # Example
388    ///
389    /// ```
390    /// use dynamic_cli::error::ParseError;
391    ///
392    /// let error = ParseError::UnknownOptionParameter {
393    ///     option: "output".to_string(),
394    ///     discriminant: "csv".to_string(),
395    ///     key: "compression".to_string(),
396    ///     valid_keys: vec!["file".to_string(), "resolution".to_string()],
397    ///     suggestion: Some("Run --help export to see valid keys for --output csv.".to_string()),
398    /// };
399    /// let msg = format!("{}", error);
400    /// assert!(msg.contains("compression"));
401    /// ```
402    #[error("Unknown parameter '{key}' for option --{option} (discriminant '{discriminant}'). Valid keys: {}", 
403        .valid_keys.join(", "))]
404    UnknownOptionParameter {
405        option: String,
406        discriminant: String,
407        key: String,
408        valid_keys: Vec<String>,
409        /// Actionable hint surfaced to the user (not part of the Display string)
410        suggestion: Option<String>,
411    },
412
413    /// Required key missing from a repeatable option's occurrence
414    ///
415    /// # Example
416    ///
417    /// ```
418    /// use dynamic_cli::error::ParseError;
419    ///
420    /// let error = ParseError::MissingRequiredOptionParameter {
421    ///     option: "output".to_string(),
422    ///     discriminant: "csv".to_string(),
423    ///     key: "file".to_string(),
424    ///     suggestion: Some("Run --help export to see required keys for --output csv.".to_string()),
425    /// };
426    /// let msg = format!("{}", error);
427    /// assert!(msg.contains("file"));
428    /// ```
429    #[error(
430        "Missing required parameter '{key}' for option --{option} (discriminant '{discriminant}')"
431    )]
432    MissingRequiredOptionParameter {
433        option: String,
434        discriminant: String,
435        key: String,
436        /// Actionable hint surfaced to the user (not part of the Display string)
437        suggestion: Option<String>,
438    },
439
440    /// Unknown discriminant for a repeatable option
441    ///
442    /// The token immediately after a repeatable option's flag did not
443    /// match any entry in that option's `choices`.
444    ///
445    /// # Example
446    ///
447    /// ```
448    /// use dynamic_cli::error::ParseError;
449    ///
450    /// let error = ParseError::UnknownDiscriminant {
451    ///     option: "output".to_string(),
452    ///     value: "xml".to_string(),
453    ///     valid_choices: vec!["csv".to_string(), "plot".to_string()],
454    ///     suggestion: Some("Run --help export to see valid --output kinds.".to_string()),
455    /// };
456    /// let msg = format!("{}", error);
457    /// assert!(msg.contains("xml"));
458    /// ```
459    #[error("Unknown discriminant '{value}' for option --{option}. Valid choices: {}", 
460        .valid_choices.join(", "))]
461    UnknownDiscriminant {
462        option: String,
463        value: String,
464        valid_choices: Vec<String>,
465        /// Actionable hint surfaced to the user (not part of the Display string)
466        suggestion: Option<String>,
467    },
468
469    /// The same repeatable-option occurrence was supplied twice
470    ///
471    /// Raised only when two occurrences share both the same discriminant
472    /// and exactly the same `key=value` pairs — a pure equality check the
473    /// framework can make without domain knowledge. Partially-overlapping
474    /// occurrences (same discriminant, different values) are *not*
475    /// rejected here; that stays the handler's responsibility.
476    ///
477    /// # Example
478    ///
479    /// ```
480    /// use dynamic_cli::error::ParseError;
481    ///
482    /// let error = ParseError::DuplicateOptionOccurrence {
483    ///     option: "output".to_string(),
484    ///     discriminant: "csv".to_string(),
485    ///     params: vec![("file".to_string(), "results.csv".to_string())],
486    ///     suggestion: Some("Remove one of the two identical --output csv occurrences.".to_string()),
487    /// };
488    /// let msg = format!("{}", error);
489    /// assert!(msg.contains("results.csv"));
490    /// ```
491    #[error("Duplicate occurrence of --{option} {discriminant} with identical parameters: {}", 
492        .params.iter().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join(", "))]
493    DuplicateOptionOccurrence {
494        option: String,
495        discriminant: String,
496        params: Vec<(String, String)>,
497        /// Actionable hint surfaced to the user (not part of the Display string)
498        suggestion: Option<String>,
499    },
500}
501
502// ═══════════════════════════════════════════════════════════
503// VALIDATION ERRORS
504// ═══════════════════════════════════════════════════════════
505
506/// Errors during argument validation
507///
508/// These errors occur after parsing, during validation
509/// of constraints defined in the configuration.
510#[derive(Debug, Error)]
511pub enum ValidationError {
512    /// Required file doesn't exist
513    ///
514    /// # Example
515    ///
516    /// ```
517    /// use dynamic_cli::error::ValidationError;
518    /// use std::path::PathBuf;
519    ///
520    /// let error = ValidationError::FileNotFound {
521    ///     path: PathBuf::from("data.csv"),
522    ///     arg_name: "input".to_string(),
523    ///     suggestion: Some("Check that the file exists and the path is correct.".to_string()),
524    /// };
525    /// let msg = format!("{}", error);
526    /// assert!(msg.contains("data.csv"));
527    /// ```
528    #[error("File not found for argument '{arg_name}': {path:?}")]
529    FileNotFound {
530        path: PathBuf,
531        arg_name: String,
532        /// Actionable hint surfaced to the user (not part of the Display string)
533        suggestion: Option<String>,
534    },
535
536    /// Invalid file extension
537    #[error("Invalid file extension for {arg_name}: {path:?}. Expected: {}", 
538        .expected.join(", "))]
539    InvalidExtension {
540        arg_name: String,
541        path: PathBuf,
542        expected: Vec<String>,
543    },
544
545    /// Value out of allowed range
546    ///
547    /// # Example
548    ///
549    /// ```
550    /// use dynamic_cli::error::ValidationError;
551    ///
552    /// let error = ValidationError::OutOfRange {
553    ///     arg_name: "percentage".to_string(),
554    ///     value: 150.0,
555    ///     min: 0.0,
556    ///     max: 100.0,
557    ///     suggestion: Some("Value must be between 0 and 100.".to_string()),
558    /// };
559    /// let msg = format!("{}", error);
560    /// assert!(msg.contains("150"));
561    /// ```
562    #[error("{arg_name} must be between {min} and {max}, got {value}")]
563    OutOfRange {
564        arg_name: String,
565        value: f64,
566        min: f64,
567        max: f64,
568        /// Actionable hint surfaced to the user (not part of the Display string)
569        suggestion: Option<String>,
570    },
571
572    /// Custom constraint not met
573    ///
574    /// # Example
575    ///
576    /// ```
577    /// use dynamic_cli::error::ValidationError;
578    ///
579    /// let error = ValidationError::CustomConstraint {
580    ///     arg_name: "email".to_string(),
581    ///     reason: "not a valid email address".to_string(),
582    ///     suggestion: Some("Provide a valid email address (e.g. user@example.com).".to_string()),
583    /// };
584    /// let msg = format!("{}", error);
585    /// assert!(msg.contains("email"));
586    /// ```
587    #[error("Validation failed for {arg_name}: {reason}")]
588    CustomConstraint {
589        arg_name: String,
590        reason: String,
591        /// Actionable hint surfaced to the user (not part of the Display string)
592        suggestion: Option<String>,
593    },
594
595    /// Dependency between arguments not satisfied
596    ///
597    /// Some arguments require the presence of other arguments.
598    ///
599    /// # Example
600    ///
601    /// ```
602    /// use dynamic_cli::error::ValidationError;
603    ///
604    /// let error = ValidationError::MissingDependency {
605    ///     arg_name: "output-format".to_string(),
606    ///     required_arg: "output".to_string(),
607    ///     suggestion: Some("Add --output to your command.".to_string()),
608    /// };
609    /// let msg = format!("{}", error);
610    /// assert!(msg.contains("output-format"));
611    /// ```
612    #[error("{arg_name} requires {required_arg} to be specified")]
613    MissingDependency {
614        arg_name: String,
615        required_arg: String,
616        /// Actionable hint surfaced to the user (not part of the Display string)
617        suggestion: Option<String>,
618    },
619
620    /// Mutually exclusive arguments
621    ///
622    /// Some arguments cannot be used together.
623    ///
624    /// # Example
625    ///
626    /// ```
627    /// use dynamic_cli::error::ValidationError;
628    ///
629    /// let error = ValidationError::MutuallyExclusive {
630    ///     arg1: "--verbose".to_string(),
631    ///     arg2: "--quiet".to_string(),
632    ///     suggestion: Some("Remove one of the two conflicting options.".to_string()),
633    /// };
634    /// let msg = format!("{}", error);
635    /// assert!(msg.contains("--verbose"));
636    /// ```
637    #[error("Options {arg1} and {arg2} cannot be used together")]
638    MutuallyExclusive {
639        arg1: String,
640        arg2: String,
641        /// Actionable hint surfaced to the user (not part of the Display string)
642        suggestion: Option<String>,
643    },
644}
645
646// ═══════════════════════════════════════════════════════════
647// EXECUTION ERRORS
648// ═══════════════════════════════════════════════════════════
649
650/// Errors during command execution
651///
652/// These errors occur during user code execution.
653#[derive(Debug, Error)]
654pub enum ExecutionError {
655    /// Command handler not found
656    ///
657    /// The implementation name in the config doesn't match any
658    /// registered handler.
659    ///
660    /// # Example
661    ///
662    /// ```
663    /// use dynamic_cli::error::ExecutionError;
664    ///
665    /// let error = ExecutionError::HandlerNotFound {
666    ///     command: "run".to_string(),
667    ///     implementation: "run_handler".to_string(),
668    ///     suggestion: Some(
669    ///         "Ensure .register_sync_handler(\"run_handler\", ...) was called before running."
670    ///             .to_string()
671    ///     ),
672    /// };
673    /// let msg = format!("{}", error);
674    /// assert!(msg.contains("run"));
675    /// ```
676    #[error("No handler registered for command '{command}' (implementation: '{implementation}')")]
677    HandlerNotFound {
678        command: String,
679        implementation: String,
680        /// Actionable hint surfaced to the user (not part of the Display string)
681        suggestion: Option<String>,
682    },
683
684    /// Error during context downcasting
685    ///
686    /// The handler tried to downcast the context to an incorrect type.
687    ///
688    /// # Example
689    ///
690    /// ```
691    /// use dynamic_cli::error::ExecutionError;
692    ///
693    /// let error = ExecutionError::ContextDowncastFailed {
694    ///     expected_type: "MyAppContext".to_string(),
695    ///     suggestion: Some(
696    ///         "Check that the context type passed to the handler matches the expected type."
697    ///             .to_string()
698    ///     ),
699    /// };
700    /// let msg = format!("{}", error);
701    /// assert!(msg.contains("MyAppContext"));
702    /// ```
703    #[error("Failed to downcast execution context to expected type: {expected_type}")]
704    ContextDowncastFailed {
705        expected_type: String,
706        /// Actionable hint surfaced to the user (not part of the Display string)
707        suggestion: Option<String>,
708    },
709
710    /// Invalid context state for this operation
711    ///
712    /// # Example
713    ///
714    /// ```
715    /// use dynamic_cli::error::ExecutionError;
716    ///
717    /// let error = ExecutionError::InvalidContextState {
718    ///     reason: "connection pool not initialised".to_string(),
719    ///     suggestion: Some("Ensure the context is fully initialised before running commands.".to_string()),
720    /// };
721    /// let msg = format!("{}", error);
722    /// assert!(msg.contains("connection pool"));
723    /// ```
724    #[error("Invalid context state: {reason}")]
725    InvalidContextState {
726        reason: String,
727        /// Actionable hint surfaced to the user (not part of the Display string)
728        suggestion: Option<String>,
729    },
730
731    /// Error in command implementation
732    ///
733    /// Wraps errors from user code.
734    #[error("Command execution failed: {0}")]
735    CommandFailed(#[source] anyhow::Error),
736
737    /// Command interrupted by user
738    ///
739    /// User pressed Ctrl+C during execution.
740    #[error("Command interrupted by user")]
741    Interrupted,
742}
743
744// ═══════════════════════════════════════════════════════════
745// REGISTRY ERRORS
746// ═══════════════════════════════════════════════════════════
747
748/// Errors related to the command registry
749///
750/// These errors occur when registering commands
751/// and handlers in the registry.
752#[derive(Debug, Error)]
753pub enum RegistryError {
754    /// Attempt to register an already existing command
755    ///
756    /// # Example
757    ///
758    /// ```
759    /// use dynamic_cli::error::RegistryError;
760    ///
761    /// let error = RegistryError::DuplicateRegistration {
762    ///     name: "run".to_string(),
763    ///     suggestion: Some("Command names must be unique across the registry.".to_string()),
764    /// };
765    /// let msg = format!("{}", error);
766    /// assert!(msg.contains("run"));
767    /// ```
768    #[error("Command '{name}' is already registered")]
769    DuplicateRegistration {
770        name: String,
771        /// Actionable hint surfaced to the user (not part of the Display string)
772        suggestion: Option<String>,
773    },
774
775    /// Alias already used by another command
776    ///
777    /// # Example
778    ///
779    /// ```
780    /// use dynamic_cli::error::RegistryError;
781    ///
782    /// let error = RegistryError::DuplicateAlias {
783    ///     alias: "r".to_string(),
784    ///     existing_command: "run".to_string(),
785    ///     suggestion: Some("Choose a different alias for one of the commands.".to_string()),
786    /// };
787    /// let msg = format!("{}", error);
788    /// assert!(msg.contains("run"));
789    /// ```
790    #[error("Alias '{alias}' is already used by command '{existing_command}'")]
791    DuplicateAlias {
792        alias: String,
793        existing_command: String,
794        /// Actionable hint surfaced to the user (not part of the Display string)
795        suggestion: Option<String>,
796    },
797
798    /// Missing handler for a definition
799    ///
800    /// A command is defined in the config but no handler
801    /// has been registered for it.
802    ///
803    /// # Example
804    ///
805    /// ```
806    /// use dynamic_cli::error::RegistryError;
807    ///
808    /// let error = RegistryError::MissingHandler {
809    ///     command: "export".to_string(),
810    ///     suggestion: Some(
811    ///         "Call .register_sync_handler(\"export\", ...) before running.".to_string()
812    ///     ),
813    /// };
814    /// let msg = format!("{}", error);
815    /// assert!(msg.contains("export"));
816    /// ```
817    #[error("No handler provided for command '{command}'")]
818    MissingHandler {
819        command: String,
820        /// Actionable hint surfaced to the user (not part of the Display string)
821        suggestion: Option<String>,
822    },
823
824    /// A handler's declared `expected_fault_tolerance()` contradicts the
825    /// command's configured `continue_on_failure`
826    ///
827    /// Raised by `CommandRegistry::register_sync`/`register_async` when the
828    /// handler expresses an opinion (`Some(bool)`) that disagrees with the
829    /// YAML value — catching a misconfiguration at startup rather than at
830    /// first use in a chain. Never raised when the handler expresses no
831    /// opinion (`None`, the default).
832    ///
833    /// # Example
834    ///
835    /// ```
836    /// use dynamic_cli::error::RegistryError;
837    ///
838    /// let error = RegistryError::FaultToleranceMismatch {
839    ///     command: "solve".to_string(),
840    ///     expected: false,
841    ///     configured: true,
842    ///     suggestion: Some(
843    ///         "set `continue_on_failure: false` for command 'solve' in the \
844    ///          YAML config, matching what its handler expects".to_string()
845    ///     ),
846    /// };
847    /// let msg = format!("{}", error);
848    /// assert!(msg.contains("solve"));
849    /// ```
850    #[error(
851        "Command '{command}' declares expected_fault_tolerance() = {expected}, \
852         but is configured with continue_on_failure: {configured}"
853    )]
854    FaultToleranceMismatch {
855        command: String,
856        /// What the handler's `expected_fault_tolerance()` returned (`Some` side)
857        expected: bool,
858        /// What `CommandDefinition::continue_on_failure` is actually set to
859        configured: bool,
860        /// Actionable hint surfaced to the user (not part of the Display string)
861        suggestion: Option<String>,
862    },
863}
864
865// ═══════════════════════════════════════════════════════════
866// WASM PLUGIN ERRORS (feature = "wasm-plugins")
867// ═══════════════════════════════════════════════════════════
868
869/// Errors related to loading and executing WASM plugins
870///
871/// Only available when the `wasm-plugins` feature is enabled.
872/// These errors occur when loading a `.wasm` module, validating its
873/// mandatory exports, or invoking a guest function across the host/guest
874/// boundary. See `WASM_PLUGIN_INTERFACE.md` for the full ABI contract.
875#[cfg(feature = "wasm-plugins")]
876#[derive(Debug, Error)]
877pub enum WasmError {
878    /// The `.wasm` module could not be loaded or instantiated
879    ///
880    /// # Example
881    ///
882    /// ```
883    /// use dynamic_cli::error::WasmError;
884    /// use std::path::PathBuf;
885    ///
886    /// let error = WasmError::LoadFailed {
887    ///     path: PathBuf::from("plugin.wasm"),
888    ///     source: anyhow::anyhow!("invalid magic number"),
889    ///     suggestion: Some("Verify the file is a valid WASM binary.".to_string()),
890    /// };
891    /// let msg = format!("{}", error);
892    /// assert!(msg.contains("plugin.wasm"));
893    /// ```
894    #[error("Failed to load WASM module: {path:?}: {source}")]
895    LoadFailed {
896        path: PathBuf,
897        #[source]
898        source: anyhow::Error,
899        /// Actionable hint surfaced to the user (not part of the Display string)
900        suggestion: Option<String>,
901    },
902
903    /// A mandatory or mapped export is missing from the module
904    ///
905    /// Mandatory exports are `memory`, `dcli_alloc`, `dcli_dealloc`, and the
906    /// mapped business function. See `WASM_PLUGIN_INTERFACE.md`.
907    ///
908    /// # Example
909    ///
910    /// ```
911    /// use dynamic_cli::error::WasmError;
912    ///
913    /// let error = WasmError::FunctionNotFound {
914    ///     function: "dcli_dealloc".to_string(),
915    ///     module: "plugin.wasm".to_string(),
916    ///     suggestion: Some(
917    ///         "Export `dcli_dealloc(ptr: i32, size: i32)` from the WASM module.".to_string()
918    ///     ),
919    /// };
920    /// let msg = format!("{}", error);
921    /// assert!(msg.contains("dcli_dealloc"));
922    /// ```
923    #[error("WASM function '{function}' not found in module '{module}'")]
924    FunctionNotFound {
925        function: String,
926        module: String,
927        /// Actionable hint surfaced to the user (not part of the Display string)
928        suggestion: Option<String>,
929    },
930
931    /// The guest function returned a non-zero error code
932    ///
933    /// `message` is populated from `dcli_last_error_message()` when the
934    /// module exports it; otherwise `None`.
935    ///
936    /// # Example
937    ///
938    /// ```
939    /// use dynamic_cli::error::WasmError;
940    ///
941    /// let error = WasmError::GuestError {
942    ///     code: 1,
943    ///     message: Some("invalid argument".to_string()),
944    /// };
945    /// let msg = format!("{}", error);
946    /// assert!(msg.contains('1'));
947    /// ```
948    #[error("WASM guest returned error code {code}{}",
949        .message.as_ref().map(|m| format!(": {m}")).unwrap_or_default())]
950    GuestError {
951        code: i32,
952        /// Detailed message from `dcli_last_error_message()`, if exported
953        message: Option<String>,
954    },
955
956    /// Failed to serialize handler arguments before crossing the WASM boundary
957    ///
958    /// # Example
959    ///
960    /// ```
961    /// use dynamic_cli::error::WasmError;
962    ///
963    /// let error = WasmError::SerializationFailed("unsupported map key type".to_string());
964    /// let msg = format!("{}", error);
965    /// assert!(msg.contains("unsupported map key type"));
966    /// ```
967    #[error("Failed to serialize arguments for WASM call: {0}")]
968    SerializationFailed(String),
969
970    /// Failed to read from or write to the guest's linear memory
971    ///
972    /// # Example
973    ///
974    /// ```
975    /// use dynamic_cli::error::WasmError;
976    ///
977    /// let error = WasmError::MemoryAccessFailed {
978    ///     reason: "write out of bounds".to_string(),
979    /// };
980    /// let msg = format!("{}", error);
981    /// assert!(msg.contains("out of bounds"));
982    /// ```
983    #[error("Failed to access WASM guest memory: {reason}")]
984    MemoryAccessFailed { reason: String },
985}
986
987// ═══════════════════════════════════════════════════════════
988// HELPERS FOR CREATING CONTEXTUAL ERRORS
989// ═══════════════════════════════════════════════════════════
990
991impl ParseError {
992    /// Create an unknown command error with Levenshtein suggestions
993    ///
994    /// Automatically computes similar command names from the available list.
995    ///
996    /// # Arguments
997    ///
998    /// * `command` - The command typed by the user
999    /// * `available` - List of available commands
1000    ///
1001    /// # Example
1002    ///
1003    /// ```
1004    /// use dynamic_cli::error::ParseError;
1005    ///
1006    /// let available = vec!["simulate".to_string(), "validate".to_string()];
1007    /// let error = ParseError::unknown_command_with_suggestions("simulat", &available);
1008    /// match error {
1009    ///     ParseError::UnknownCommand { suggestions, .. } => {
1010    ///         assert!(suggestions.contains(&"simulate".to_string()));
1011    ///     }
1012    ///     _ => panic!("wrong variant"),
1013    /// }
1014    /// ```
1015    pub fn unknown_command_with_suggestions(command: &str, available: &[String]) -> Self {
1016        Self::unknown_command(command, available, None)
1017    }
1018
1019    /// Same as [`unknown_command_with_suggestions`](Self::unknown_command_with_suggestions),
1020    /// with the hint set by the interface reporting the error.
1021    pub(crate) fn unknown_command_with_hint(
1022        command: &str,
1023        available: &[String],
1024        hint: &str,
1025    ) -> Self {
1026        Self::unknown_command(command, available, Some(hint.to_string()))
1027    }
1028
1029    fn unknown_command(command: &str, available: &[String], hint: Option<String>) -> Self {
1030        Self::UnknownCommand {
1031            command: command.to_string(),
1032            suggestions: crate::error::find_similar_strings(command, available, 3),
1033            hint,
1034        }
1035    }
1036
1037    /// Create an unknown option error with Levenshtein suggestions
1038    ///
1039    /// # Example
1040    ///
1041    /// ```
1042    /// use dynamic_cli::error::ParseError;
1043    ///
1044    /// let available = vec!["--verbose".to_string(), "--output".to_string()];
1045    /// let error = ParseError::unknown_option_with_suggestions("--verbos", "run", &available);
1046    /// match error {
1047    ///     ParseError::UnknownOption { suggestions, .. } => {
1048    ///         assert!(suggestions.contains(&"--verbose".to_string()));
1049    ///     }
1050    ///     _ => panic!("wrong variant"),
1051    /// }
1052    /// ```
1053    pub fn unknown_option_with_suggestions(
1054        flag: &str,
1055        command: &str,
1056        available: &[String],
1057    ) -> Self {
1058        let suggestions = crate::error::find_similar_strings(flag, available, 2);
1059        Self::UnknownOption {
1060            flag: flag.to_string(),
1061            command: command.to_string(),
1062            suggestions,
1063        }
1064    }
1065
1066    /// Create a missing argument error with a help hint
1067    ///
1068    /// The suggestion automatically refers the user to `--help <command>`.
1069    ///
1070    /// # Example
1071    ///
1072    /// ```
1073    /// use dynamic_cli::error::ParseError;
1074    ///
1075    /// let error = ParseError::missing_argument("filename", "process");
1076    /// match error {
1077    ///     ParseError::MissingArgument { suggestion, .. } => {
1078    ///         assert!(suggestion.is_some());
1079    ///     }
1080    ///     _ => panic!("wrong variant"),
1081    /// }
1082    /// ```
1083    pub fn missing_argument(argument: &str, command: &str) -> Self {
1084        Self::MissingArgument {
1085            argument: argument.to_string(),
1086            command: command.to_string(),
1087            suggestion: Some(format!("Run --help {command} to see required arguments.")),
1088        }
1089    }
1090
1091    /// Create a missing option error with a help hint
1092    ///
1093    /// The suggestion automatically refers the user to `--help <command>`.
1094    ///
1095    /// # Example
1096    ///
1097    /// ```
1098    /// use dynamic_cli::error::ParseError;
1099    ///
1100    /// let error = ParseError::missing_option("output", "export");
1101    /// match error {
1102    ///     ParseError::MissingOption { suggestion, .. } => {
1103    ///         assert!(suggestion.is_some());
1104    ///     }
1105    ///     _ => panic!("wrong variant"),
1106    /// }
1107    /// ```
1108    pub fn missing_option(option: &str, command: &str) -> Self {
1109        Self::MissingOption {
1110            option: option.to_string(),
1111            command: command.to_string(),
1112            suggestion: Some(format!("Run --help {command} to see required options.")),
1113        }
1114    }
1115
1116    /// Create a too-many-arguments error with a help hint
1117    ///
1118    /// # Example
1119    ///
1120    /// ```
1121    /// use dynamic_cli::error::ParseError;
1122    ///
1123    /// let error = ParseError::too_many_arguments("run", 1, 3);
1124    /// match error {
1125    ///     ParseError::TooManyArguments { suggestion, .. } => {
1126    ///         assert!(suggestion.is_some());
1127    ///     }
1128    ///     _ => panic!("wrong variant"),
1129    /// }
1130    /// ```
1131    pub fn too_many_arguments(command: &str, expected: usize, got: usize) -> Self {
1132        Self::TooManyArguments {
1133            command: command.to_string(),
1134            expected,
1135            got,
1136            suggestion: Some(format!("Run --help {command} for the expected usage.")),
1137        }
1138    }
1139}
1140
1141impl ConfigError {
1142    /// Create a file-not-found error with a standard suggestion
1143    ///
1144    /// # Example
1145    ///
1146    /// ```
1147    /// use dynamic_cli::error::ConfigError;
1148    /// use std::path::PathBuf;
1149    ///
1150    /// let error = ConfigError::file_not_found(PathBuf::from("commands.yaml"));
1151    /// match error {
1152    ///     ConfigError::FileNotFound { suggestion, .. } => {
1153    ///         assert!(suggestion.is_some());
1154    ///     }
1155    ///     _ => panic!("wrong variant"),
1156    /// }
1157    /// ```
1158    pub fn file_not_found(path: PathBuf) -> Self {
1159        Self::FileNotFound {
1160            path,
1161            suggestion: Some("Verify the path and file permissions.".to_string()),
1162        }
1163    }
1164
1165    /// Create an unsupported-format error with a standard suggestion
1166    ///
1167    /// # Example
1168    ///
1169    /// ```
1170    /// use dynamic_cli::error::ConfigError;
1171    ///
1172    /// let error = ConfigError::unsupported_format(".toml");
1173    /// match error {
1174    ///     ConfigError::UnsupportedFormat { suggestion, .. } => {
1175    ///         assert!(suggestion.is_some());
1176    ///     }
1177    ///     _ => panic!("wrong variant"),
1178    /// }
1179    /// ```
1180    pub fn unsupported_format(extension: &str) -> Self {
1181        Self::UnsupportedFormat {
1182            extension: extension.to_string(),
1183            suggestion: Some("Rename the file with a .yaml, .yml or .json extension.".to_string()),
1184        }
1185    }
1186
1187    /// Create a YAML parse error with position extracted from the serde error
1188    pub fn yaml_parse_with_location(source: serde_yaml::Error) -> Self {
1189        let location = source.location();
1190        Self::YamlParse {
1191            source,
1192            line: location.as_ref().map(|l| l.line()),
1193            column: location.map(|l| l.column()),
1194        }
1195    }
1196
1197    /// Create a JSON parse error with position extracted from the serde error
1198    pub fn json_parse_with_location(source: serde_json::Error) -> Self {
1199        Self::JsonParse {
1200            line: source.line(),
1201            column: source.column(),
1202            source,
1203        }
1204    }
1205}
1206
1207impl ExecutionError {
1208    /// Create a handler-not-found error with an actionable suggestion
1209    ///
1210    /// The suggestion interpolates the implementation name so the user
1211    /// knows exactly which `register_sync_handler()` or
1212    /// `register_async_handler()` call is missing.
1213    ///
1214    /// # Example
1215    ///
1216    /// ```
1217    /// use dynamic_cli::error::ExecutionError;
1218    ///
1219    /// let error = ExecutionError::handler_not_found("run", "run_handler");
1220    /// match error {
1221    ///     ExecutionError::HandlerNotFound { suggestion, .. } => {
1222    ///         assert!(suggestion.as_deref().unwrap_or("").contains("run_handler"));
1223    ///     }
1224    ///     _ => panic!("wrong variant"),
1225    /// }
1226    /// ```
1227    pub fn handler_not_found(command: &str, implementation: &str) -> Self {
1228        Self::HandlerNotFound {
1229            command: command.to_string(),
1230            implementation: implementation.to_string(),
1231            suggestion: Some(format!(
1232                "Ensure .register_sync_handler(\"{implementation}\", ...) or \
1233                 .register_async_handler(\"{implementation}\", ...) was called before running."
1234            )),
1235        }
1236    }
1237}
1238
1239impl RegistryError {
1240    /// Create a missing-handler error with an actionable suggestion
1241    ///
1242    /// The suggestion interpolates the command name so the user
1243    /// knows exactly which `.register_sync_handler()` call is missing.
1244    ///
1245    /// # Example
1246    ///
1247    /// ```
1248    /// use dynamic_cli::error::RegistryError;
1249    ///
1250    /// let error = RegistryError::missing_handler("export");
1251    /// match error {
1252    ///     RegistryError::MissingHandler { suggestion, .. } => {
1253    ///         assert!(suggestion.as_deref().unwrap_or("").contains("export"));
1254    ///     }
1255    ///     _ => panic!("wrong variant"),
1256    /// }
1257    /// ```
1258    pub fn missing_handler(command: &str) -> Self {
1259        Self::MissingHandler {
1260            command: command.to_string(),
1261            suggestion: Some(format!(
1262                "Call .register_sync_handler(\"{command}\", ...) before running."
1263            )),
1264        }
1265    }
1266
1267    /// Create a fault-tolerance-mismatch error with an actionable suggestion
1268    ///
1269    /// `expected` is what the handler's `expected_fault_tolerance()`
1270    /// returned; `configured` is the command's actual
1271    /// `CommandDefinition::continue_on_failure`. The suggestion tells the
1272    /// user exactly which YAML value to change to resolve the contradiction.
1273    ///
1274    /// # Example
1275    ///
1276    /// ```
1277    /// use dynamic_cli::error::RegistryError;
1278    ///
1279    /// let error = RegistryError::fault_tolerance_mismatch("solve", false, true);
1280    /// match error {
1281    ///     RegistryError::FaultToleranceMismatch { suggestion, .. } => {
1282    ///         assert!(suggestion.as_deref().unwrap_or("").contains("continue_on_failure: false"));
1283    ///     }
1284    ///     _ => panic!("wrong variant"),
1285    /// }
1286    /// ```
1287    pub fn fault_tolerance_mismatch(command: &str, expected: bool, configured: bool) -> Self {
1288        Self::FaultToleranceMismatch {
1289            command: command.to_string(),
1290            expected,
1291            configured,
1292            suggestion: Some(format!(
1293                "set `continue_on_failure: {expected}` for command '{command}' in the \
1294                 YAML config, matching what its handler expects"
1295            )),
1296        }
1297    }
1298}
1299
1300#[cfg(feature = "wasm-plugins")]
1301impl WasmError {
1302    /// Create a `FunctionNotFound` error for a missing mandatory export
1303    ///
1304    /// The suggestion names the exact signature expected for the missing
1305    /// export, taken from `WASM_PLUGIN_INTERFACE.md`.
1306    ///
1307    /// # Example
1308    ///
1309    /// ```
1310    /// use dynamic_cli::error::WasmError;
1311    ///
1312    /// let error = WasmError::missing_mandatory_export("dcli_alloc", "plugin.wasm");
1313    /// match error {
1314    ///     WasmError::FunctionNotFound { suggestion, .. } => {
1315    ///         assert!(suggestion.is_some());
1316    ///     }
1317    ///     _ => panic!("wrong variant"),
1318    /// }
1319    /// ```
1320    pub fn missing_mandatory_export(function: &str, module: &str) -> Self {
1321        let signature = match function {
1322            "dcli_alloc" => "fn dcli_alloc(size: i32) -> i32",
1323            "dcli_dealloc" => "fn dcli_dealloc(ptr: i32, size: i32)",
1324            "memory" => "(memory (export \"memory\") ...)",
1325            other => other,
1326        };
1327        Self::FunctionNotFound {
1328            function: function.to_string(),
1329            module: module.to_string(),
1330            suggestion: Some(format!(
1331                "Export `{signature}` from the WASM module. \
1332                 See WASM_PLUGIN_INTERFACE.md for the full contract."
1333            )),
1334        }
1335    }
1336
1337    /// Create a `GuestError` from a raw error code, without a detailed message
1338    ///
1339    /// Used when the module does not export `dcli_last_error_message`.
1340    ///
1341    /// # Example
1342    ///
1343    /// ```
1344    /// use dynamic_cli::error::WasmError;
1345    ///
1346    /// let error = WasmError::guest_error_without_message(2);
1347    /// match error {
1348    ///     WasmError::GuestError { code, message } => {
1349    ///         assert_eq!(code, 2);
1350    ///         assert!(message.is_none());
1351    ///     }
1352    ///     _ => panic!("wrong variant"),
1353    /// }
1354    /// ```
1355    pub fn guest_error_without_message(code: i32) -> Self {
1356        Self::GuestError {
1357            code,
1358            message: None,
1359        }
1360    }
1361}
1362
1363#[cfg(test)]
1364mod tests {
1365    use super::*;
1366
1367    // ── ConfigError ──────────────────────────────────────────
1368
1369    #[test]
1370    fn test_config_file_not_found_display() {
1371        let error = ConfigError::FileNotFound {
1372            path: PathBuf::from("/path/to/config.yaml"),
1373            suggestion: None,
1374        };
1375        let msg = format!("{}", error);
1376        assert!(msg.contains("not found"));
1377        assert!(msg.contains("config.yaml"));
1378    }
1379
1380    #[test]
1381    fn test_config_file_not_found_helper_has_suggestion() {
1382        let error = ConfigError::file_not_found(PathBuf::from("commands.yaml"));
1383        match error {
1384            ConfigError::FileNotFound { suggestion, .. } => {
1385                assert!(suggestion.is_some(), "helper must populate suggestion");
1386            }
1387            _ => panic!("wrong variant"),
1388        }
1389    }
1390
1391    #[test]
1392    fn test_config_unsupported_format_helper_has_suggestion() {
1393        let error = ConfigError::unsupported_format(".toml");
1394        match error {
1395            ConfigError::UnsupportedFormat {
1396                suggestion,
1397                extension,
1398                ..
1399            } => {
1400                assert_eq!(extension, ".toml");
1401                assert!(suggestion.is_some());
1402            }
1403            _ => panic!("wrong variant"),
1404        }
1405    }
1406
1407    #[test]
1408    fn test_config_duplicate_command_display() {
1409        let error = ConfigError::DuplicateCommand {
1410            name: "run".to_string(),
1411            suggestion: Some("Rename one of the conflicting commands.".to_string()),
1412        };
1413        let msg = format!("{}", error);
1414        assert!(msg.contains("run"));
1415        // suggestion must NOT appear in Display (it's rendered separately)
1416        assert!(!msg.contains("Rename"));
1417    }
1418
1419    #[test]
1420    fn test_config_unknown_type_display() {
1421        let error = ConfigError::UnknownType {
1422            type_name: "datetime".to_string(),
1423            context: "commands[0]".to_string(),
1424            suggestion: None,
1425        };
1426        let msg = format!("{}", error);
1427        assert!(msg.contains("datetime"));
1428    }
1429
1430    #[test]
1431    fn test_config_inconsistency_display() {
1432        let error = ConfigError::Inconsistency {
1433            details: "default not in choices".to_string(),
1434            suggestion: Some("hint".to_string()),
1435        };
1436        let msg = format!("{}", error);
1437        assert!(msg.contains("default not in choices"));
1438        assert!(!msg.contains("hint")); // suggestion separate from Display
1439    }
1440
1441    #[test]
1442    fn test_config_invalid_schema_display() {
1443        let error = ConfigError::InvalidSchema {
1444            reason: "missing field".to_string(),
1445            path: Some("commands[0]".to_string()),
1446            suggestion: None,
1447        };
1448        let msg = format!("{}", error);
1449        assert!(msg.contains("missing field"));
1450    }
1451
1452    // ── ParseError ───────────────────────────────────────────
1453
1454    #[test]
1455    fn test_parse_unknown_command_with_suggestions() {
1456        let available = vec!["simulate".to_string(), "validate".to_string()];
1457        let error = ParseError::unknown_command_with_suggestions("simulat", &available);
1458        match error {
1459            ParseError::UnknownCommand {
1460                command,
1461                suggestions,
1462                hint,
1463            } => {
1464                assert_eq!(command, "simulat");
1465                assert!(suggestions.contains(&"simulate".to_string()));
1466                assert!(hint.is_none(), "the interface sets the hint");
1467            }
1468            _ => panic!("wrong variant"),
1469        }
1470    }
1471
1472    #[test]
1473    fn test_parse_unknown_command_display_names_only_the_command() {
1474        let error = ParseError::UnknownCommand {
1475            command: "simulat".to_string(),
1476            suggestions: vec!["simulate".to_string()],
1477            hint: Some("Type ':help' for available commands".to_string()),
1478        };
1479        // Neither the suggestions nor the hint belong to the Display string.
1480        assert_eq!(error.to_string(), "Unknown command: 'simulat'");
1481    }
1482
1483    #[test]
1484    fn test_parse_missing_argument_helper_has_suggestion() {
1485        let error = ParseError::missing_argument("filename", "process");
1486        match error {
1487            ParseError::MissingArgument {
1488                suggestion,
1489                command,
1490                ..
1491            } => {
1492                assert_eq!(command, "process");
1493                let s = suggestion.unwrap();
1494                assert!(s.contains("process"));
1495                assert!(s.contains("--help"));
1496            }
1497            _ => panic!("wrong variant"),
1498        }
1499    }
1500
1501    #[test]
1502    fn test_parse_missing_option_helper_has_suggestion() {
1503        let error = ParseError::missing_option("output", "export");
1504        match error {
1505            ParseError::MissingOption {
1506                suggestion, option, ..
1507            } => {
1508                assert_eq!(option, "output");
1509                let s = suggestion.unwrap();
1510                assert!(s.contains("export"));
1511            }
1512            _ => panic!("wrong variant"),
1513        }
1514    }
1515
1516    #[test]
1517    fn test_parse_too_many_arguments_helper_has_suggestion() {
1518        let error = ParseError::too_many_arguments("run", 1, 3);
1519        match error {
1520            ParseError::TooManyArguments {
1521                suggestion,
1522                expected,
1523                got,
1524                ..
1525            } => {
1526                assert_eq!(expected, 1);
1527                assert_eq!(got, 3);
1528                assert!(suggestion.is_some());
1529            }
1530            _ => panic!("wrong variant"),
1531        }
1532    }
1533
1534    #[test]
1535    fn test_parse_missing_argument_suggestion_none_by_default() {
1536        // Direct construction without helper: suggestion is caller's responsibility
1537        let error = ParseError::MissingArgument {
1538            argument: "file".to_string(),
1539            command: "run".to_string(),
1540            suggestion: None,
1541        };
1542        match error {
1543            ParseError::MissingArgument { suggestion, .. } => assert!(suggestion.is_none()),
1544            _ => panic!("wrong variant"),
1545        }
1546    }
1547
1548    // ── ValidationError ──────────────────────────────────────
1549
1550    #[test]
1551    fn test_validation_out_of_range_display() {
1552        let error = ValidationError::OutOfRange {
1553            arg_name: "percentage".to_string(),
1554            value: 150.0,
1555            min: 0.0,
1556            max: 100.0,
1557            suggestion: None,
1558        };
1559        let msg = format!("{}", error);
1560        assert!(msg.contains("percentage"));
1561        assert!(msg.contains("150"));
1562        assert!(msg.contains("0"));
1563        assert!(msg.contains("100"));
1564    }
1565
1566    #[test]
1567    fn test_validation_out_of_range_suggestion_not_in_display() {
1568        let error = ValidationError::OutOfRange {
1569            arg_name: "percentage".to_string(),
1570            value: 150.0,
1571            min: 0.0,
1572            max: 100.0,
1573            suggestion: Some("Value must be between 0 and 100.".to_string()),
1574        };
1575        let msg = format!("{}", error);
1576        assert!(!msg.contains("Value must be between")); // suggestion is separate
1577    }
1578
1579    #[test]
1580    fn test_validation_file_not_found_suggestion() {
1581        let error = ValidationError::FileNotFound {
1582            path: PathBuf::from("data.csv"),
1583            arg_name: "input".to_string(),
1584            suggestion: Some("Check that the file exists.".to_string()),
1585        };
1586        match error {
1587            ValidationError::FileNotFound { suggestion, .. } => {
1588                assert!(suggestion.is_some());
1589            }
1590            _ => panic!("wrong variant"),
1591        }
1592    }
1593
1594    #[test]
1595    fn test_validation_missing_dependency_suggestion() {
1596        let error = ValidationError::MissingDependency {
1597            arg_name: "format".to_string(),
1598            required_arg: "output".to_string(),
1599            suggestion: Some("Add --output to your command.".to_string()),
1600        };
1601        let msg = format!("{}", error);
1602        assert!(msg.contains("format"));
1603        assert!(msg.contains("output"));
1604    }
1605
1606    #[test]
1607    fn test_validation_mutually_exclusive_suggestion() {
1608        let error = ValidationError::MutuallyExclusive {
1609            arg1: "--verbose".to_string(),
1610            arg2: "--quiet".to_string(),
1611            suggestion: Some("Remove one of the two conflicting options.".to_string()),
1612        };
1613        let msg = format!("{}", error);
1614        assert!(msg.contains("--verbose"));
1615        assert!(msg.contains("--quiet"));
1616    }
1617
1618    // ── ExecutionError ───────────────────────────────────────
1619
1620    #[test]
1621    fn test_execution_handler_not_found_helper_interpolates_impl() {
1622        let error = ExecutionError::handler_not_found("run", "run_handler");
1623        match error {
1624            ExecutionError::HandlerNotFound {
1625                suggestion,
1626                implementation,
1627                ..
1628            } => {
1629                assert_eq!(implementation, "run_handler");
1630                let s = suggestion.unwrap();
1631                assert!(s.contains("run_handler"));
1632                assert!(s.contains("register_sync_handler"));
1633            }
1634            _ => panic!("wrong variant"),
1635        }
1636    }
1637
1638    #[test]
1639    fn test_execution_context_downcast_failed_display() {
1640        let error = ExecutionError::ContextDowncastFailed {
1641            expected_type: "MyAppContext".to_string(),
1642            suggestion: None,
1643        };
1644        let msg = format!("{}", error);
1645        assert!(msg.contains("MyAppContext"));
1646    }
1647
1648    #[test]
1649    fn test_execution_invalid_context_state_suggestion() {
1650        let error = ExecutionError::InvalidContextState {
1651            reason: "pool not ready".to_string(),
1652            suggestion: Some("Ensure context is initialised.".to_string()),
1653        };
1654        let msg = format!("{}", error);
1655        assert!(msg.contains("pool not ready"));
1656    }
1657
1658    // ── RegistryError ────────────────────────────────────────
1659
1660    #[test]
1661    fn test_registry_missing_handler_helper_interpolates_command() {
1662        let error = RegistryError::missing_handler("export");
1663        match error {
1664            RegistryError::MissingHandler {
1665                suggestion,
1666                command,
1667            } => {
1668                assert_eq!(command, "export");
1669                let s = suggestion.unwrap();
1670                assert!(s.contains("export"));
1671                assert!(s.contains("register_sync_handler"));
1672            }
1673            _ => panic!("wrong variant"),
1674        }
1675    }
1676
1677    #[test]
1678    fn test_registry_duplicate_registration_display() {
1679        let error = RegistryError::DuplicateRegistration {
1680            name: "run".to_string(),
1681            suggestion: None,
1682        };
1683        let msg = format!("{}", error);
1684        assert!(msg.contains("run"));
1685    }
1686
1687    #[test]
1688    fn test_registry_duplicate_alias_display() {
1689        let error = RegistryError::DuplicateAlias {
1690            alias: "r".to_string(),
1691            existing_command: "run".to_string(),
1692            suggestion: Some("Choose a different alias.".to_string()),
1693        };
1694        let msg = format!("{}", error);
1695        assert!(msg.contains("run"));
1696        assert!(!msg.contains("Choose")); // suggestion separate from Display
1697    }
1698
1699    #[test]
1700    fn test_registry_fault_tolerance_mismatch_helper_interpolates_command() {
1701        let error = RegistryError::fault_tolerance_mismatch("solve", false, true);
1702        match error {
1703            RegistryError::FaultToleranceMismatch {
1704                command,
1705                expected,
1706                configured,
1707                suggestion,
1708            } => {
1709                assert_eq!(command, "solve");
1710                assert!(!expected);
1711                assert!(configured);
1712                let s = suggestion.unwrap();
1713                assert!(s.contains("solve"));
1714                assert!(s.contains("continue_on_failure: false"));
1715            }
1716            _ => panic!("wrong variant"),
1717        }
1718    }
1719
1720    #[test]
1721    fn test_registry_fault_tolerance_mismatch_display() {
1722        let error = RegistryError::FaultToleranceMismatch {
1723            command: "solve".to_string(),
1724            expected: false,
1725            configured: true,
1726            suggestion: None,
1727        };
1728        let msg = format!("{}", error);
1729        assert!(msg.contains("solve"));
1730        assert!(msg.contains("false"));
1731        assert!(msg.contains("true"));
1732    }
1733
1734    // ── WasmError ────────────────────────────────────────────
1735
1736    #[cfg(feature = "wasm-plugins")]
1737    #[test]
1738    fn test_wasm_load_failed_display() {
1739        let error = WasmError::LoadFailed {
1740            path: PathBuf::from("plugin.wasm"),
1741            source: anyhow::anyhow!("invalid magic number"),
1742            suggestion: None,
1743        };
1744        let msg = format!("{}", error);
1745        assert!(msg.contains("plugin.wasm"));
1746        assert!(msg.contains("invalid magic number"));
1747    }
1748
1749    #[cfg(feature = "wasm-plugins")]
1750    #[test]
1751    fn test_wasm_function_not_found_display() {
1752        let error = WasmError::FunctionNotFound {
1753            function: "dcli_alloc".to_string(),
1754            module: "plugin.wasm".to_string(),
1755            suggestion: None,
1756        };
1757        let msg = format!("{}", error);
1758        assert!(msg.contains("dcli_alloc"));
1759        assert!(msg.contains("plugin.wasm"));
1760    }
1761
1762    #[cfg(feature = "wasm-plugins")]
1763    #[test]
1764    fn test_wasm_missing_mandatory_export_helper_has_suggestion() {
1765        let error = WasmError::missing_mandatory_export("dcli_dealloc", "plugin.wasm");
1766        match error {
1767            WasmError::FunctionNotFound {
1768                suggestion,
1769                function,
1770                ..
1771            } => {
1772                assert_eq!(function, "dcli_dealloc");
1773                let s = suggestion.unwrap();
1774                assert!(s.contains("dcli_dealloc"));
1775                assert!(s.contains("WASM_PLUGIN_INTERFACE.md"));
1776            }
1777            _ => panic!("wrong variant"),
1778        }
1779    }
1780
1781    #[cfg(feature = "wasm-plugins")]
1782    #[test]
1783    fn test_wasm_guest_error_with_message_display() {
1784        let error = WasmError::GuestError {
1785            code: 1,
1786            message: Some("invalid argument".to_string()),
1787        };
1788        let msg = format!("{}", error);
1789        assert!(msg.contains('1'));
1790        assert!(msg.contains("invalid argument"));
1791    }
1792
1793    #[cfg(feature = "wasm-plugins")]
1794    #[test]
1795    fn test_wasm_guest_error_without_message_display() {
1796        let error = WasmError::guest_error_without_message(2);
1797        let msg = format!("{}", error);
1798        assert!(msg.contains('2'));
1799        match error {
1800            WasmError::GuestError { message, .. } => assert!(message.is_none()),
1801            _ => panic!("wrong variant"),
1802        }
1803    }
1804
1805    #[cfg(feature = "wasm-plugins")]
1806    #[test]
1807    fn test_wasm_serialization_failed_display() {
1808        let error = WasmError::SerializationFailed("unsupported map key type".to_string());
1809        let msg = format!("{}", error);
1810        assert!(msg.contains("unsupported map key type"));
1811    }
1812
1813    #[cfg(feature = "wasm-plugins")]
1814    #[test]
1815    fn test_wasm_memory_access_failed_display() {
1816        let error = WasmError::MemoryAccessFailed {
1817            reason: "write out of bounds".to_string(),
1818        };
1819        let msg = format!("{}", error);
1820        assert!(msg.contains("out of bounds"));
1821    }
1822
1823    #[cfg(feature = "wasm-plugins")]
1824    #[test]
1825    fn test_wasm_error_converts_into_dynamic_cli_error() {
1826        let wasm_err = WasmError::guest_error_without_message(1);
1827        let err: DynamicCliError = wasm_err.into();
1828        match err {
1829            DynamicCliError::Wasm(WasmError::GuestError { code, .. }) => assert_eq!(code, 1),
1830            _ => panic!("wrong variant"),
1831        }
1832    }
1833}