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