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    /// A handler's declared `expected_fault_tolerance()` (DD-028)
804    /// contradicts the command's configured `continue_on_failure` (DD-026)
805    ///
806    /// Raised by `CommandRegistry::register_sync`/`register_async` when the
807    /// handler expresses an opinion (`Some(bool)`) that disagrees with the
808    /// YAML value — catching a misconfiguration at startup rather than at
809    /// first use in a chain. Never raised when the handler expresses no
810    /// opinion (`None`, the default).
811    ///
812    /// # Example
813    ///
814    /// ```
815    /// use dynamic_cli::error::RegistryError;
816    ///
817    /// let error = RegistryError::FaultToleranceMismatch {
818    ///     command: "solve".to_string(),
819    ///     expected: false,
820    ///     configured: true,
821    ///     suggestion: Some(
822    ///         "set `continue_on_failure: false` for command 'solve' in the \
823    ///          YAML config, matching what its handler expects".to_string()
824    ///     ),
825    /// };
826    /// let msg = format!("{}", error);
827    /// assert!(msg.contains("solve"));
828    /// ```
829    #[error(
830        "Command '{command}' declares expected_fault_tolerance() = {expected}, \
831         but is configured with continue_on_failure: {configured}"
832    )]
833    FaultToleranceMismatch {
834        command: String,
835        /// What the handler's `expected_fault_tolerance()` returned (`Some` side)
836        expected: bool,
837        /// What `CommandDefinition::continue_on_failure` is actually set to
838        configured: bool,
839        /// Actionable hint surfaced to the user (not part of the Display string)
840        suggestion: Option<String>,
841    },
842}
843
844// ═══════════════════════════════════════════════════════════
845// WASM PLUGIN ERRORS (feature = "wasm-plugins")
846// ═══════════════════════════════════════════════════════════
847
848/// Errors related to loading and executing WASM plugins
849///
850/// Only available when the `wasm-plugins` feature is enabled.
851/// These errors occur when loading a `.wasm` module, validating its
852/// mandatory exports, or invoking a guest function across the host/guest
853/// boundary. See `WASM_PLUGIN_INTERFACE.md` for the full ABI contract.
854#[cfg(feature = "wasm-plugins")]
855#[derive(Debug, Error)]
856pub enum WasmError {
857    /// The `.wasm` module could not be loaded or instantiated
858    ///
859    /// # Example
860    ///
861    /// ```
862    /// use dynamic_cli::error::WasmError;
863    /// use std::path::PathBuf;
864    ///
865    /// let error = WasmError::LoadFailed {
866    ///     path: PathBuf::from("plugin.wasm"),
867    ///     source: anyhow::anyhow!("invalid magic number"),
868    ///     suggestion: Some("Verify the file is a valid WASM binary.".to_string()),
869    /// };
870    /// let msg = format!("{}", error);
871    /// assert!(msg.contains("plugin.wasm"));
872    /// ```
873    #[error("Failed to load WASM module: {path:?}: {source}")]
874    LoadFailed {
875        path: PathBuf,
876        #[source]
877        source: anyhow::Error,
878        /// Actionable hint surfaced to the user (not part of the Display string)
879        suggestion: Option<String>,
880    },
881
882    /// A mandatory or mapped export is missing from the module
883    ///
884    /// Mandatory exports are `memory`, `dcli_alloc`, `dcli_dealloc`, and the
885    /// mapped business function. See `WASM_PLUGIN_INTERFACE.md`.
886    ///
887    /// # Example
888    ///
889    /// ```
890    /// use dynamic_cli::error::WasmError;
891    ///
892    /// let error = WasmError::FunctionNotFound {
893    ///     function: "dcli_dealloc".to_string(),
894    ///     module: "plugin.wasm".to_string(),
895    ///     suggestion: Some(
896    ///         "Export `dcli_dealloc(ptr: i32, size: i32)` from the WASM module.".to_string()
897    ///     ),
898    /// };
899    /// let msg = format!("{}", error);
900    /// assert!(msg.contains("dcli_dealloc"));
901    /// ```
902    #[error("WASM function '{function}' not found in module '{module}'")]
903    FunctionNotFound {
904        function: String,
905        module: String,
906        /// Actionable hint surfaced to the user (not part of the Display string)
907        suggestion: Option<String>,
908    },
909
910    /// The guest function returned a non-zero error code
911    ///
912    /// `message` is populated from `dcli_last_error_message()` when the
913    /// module exports it; otherwise `None`.
914    ///
915    /// # Example
916    ///
917    /// ```
918    /// use dynamic_cli::error::WasmError;
919    ///
920    /// let error = WasmError::GuestError {
921    ///     code: 1,
922    ///     message: Some("invalid argument".to_string()),
923    /// };
924    /// let msg = format!("{}", error);
925    /// assert!(msg.contains('1'));
926    /// ```
927    #[error("WASM guest returned error code {code}{}",
928        .message.as_ref().map(|m| format!(": {m}")).unwrap_or_default())]
929    GuestError {
930        code: i32,
931        /// Detailed message from `dcli_last_error_message()`, if exported
932        message: Option<String>,
933    },
934
935    /// Failed to serialize handler arguments before crossing the WASM boundary
936    ///
937    /// # Example
938    ///
939    /// ```
940    /// use dynamic_cli::error::WasmError;
941    ///
942    /// let error = WasmError::SerializationFailed("unsupported map key type".to_string());
943    /// let msg = format!("{}", error);
944    /// assert!(msg.contains("unsupported map key type"));
945    /// ```
946    #[error("Failed to serialize arguments for WASM call: {0}")]
947    SerializationFailed(String),
948
949    /// Failed to read from or write to the guest's linear memory
950    ///
951    /// # Example
952    ///
953    /// ```
954    /// use dynamic_cli::error::WasmError;
955    ///
956    /// let error = WasmError::MemoryAccessFailed {
957    ///     reason: "write out of bounds".to_string(),
958    /// };
959    /// let msg = format!("{}", error);
960    /// assert!(msg.contains("out of bounds"));
961    /// ```
962    #[error("Failed to access WASM guest memory: {reason}")]
963    MemoryAccessFailed { reason: String },
964}
965
966// ═══════════════════════════════════════════════════════════
967// HELPERS FOR CREATING CONTEXTUAL ERRORS
968// ═══════════════════════════════════════════════════════════
969
970impl ParseError {
971    /// Create an unknown command error with Levenshtein suggestions
972    ///
973    /// Automatically computes similar command names from the available list.
974    ///
975    /// # Arguments
976    ///
977    /// * `command` - The command typed by the user
978    /// * `available` - List of available commands
979    ///
980    /// # Example
981    ///
982    /// ```
983    /// use dynamic_cli::error::ParseError;
984    ///
985    /// let available = vec!["simulate".to_string(), "validate".to_string()];
986    /// let error = ParseError::unknown_command_with_suggestions("simulat", &available);
987    /// match error {
988    ///     ParseError::UnknownCommand { suggestions, .. } => {
989    ///         assert!(suggestions.contains(&"simulate".to_string()));
990    ///     }
991    ///     _ => panic!("wrong variant"),
992    /// }
993    /// ```
994    pub fn unknown_command_with_suggestions(command: &str, available: &[String]) -> Self {
995        let suggestions = crate::error::find_similar_strings(command, available, 3);
996        Self::UnknownCommand {
997            command: command.to_string(),
998            suggestions,
999        }
1000    }
1001
1002    /// Create an unknown option error with Levenshtein suggestions
1003    ///
1004    /// # Example
1005    ///
1006    /// ```
1007    /// use dynamic_cli::error::ParseError;
1008    ///
1009    /// let available = vec!["--verbose".to_string(), "--output".to_string()];
1010    /// let error = ParseError::unknown_option_with_suggestions("--verbos", "run", &available);
1011    /// match error {
1012    ///     ParseError::UnknownOption { suggestions, .. } => {
1013    ///         assert!(suggestions.contains(&"--verbose".to_string()));
1014    ///     }
1015    ///     _ => panic!("wrong variant"),
1016    /// }
1017    /// ```
1018    pub fn unknown_option_with_suggestions(
1019        flag: &str,
1020        command: &str,
1021        available: &[String],
1022    ) -> Self {
1023        let suggestions = crate::error::find_similar_strings(flag, available, 2);
1024        Self::UnknownOption {
1025            flag: flag.to_string(),
1026            command: command.to_string(),
1027            suggestions,
1028        }
1029    }
1030
1031    /// Create a missing argument error with a help hint
1032    ///
1033    /// The suggestion automatically refers the user to `--help <command>`.
1034    ///
1035    /// # Example
1036    ///
1037    /// ```
1038    /// use dynamic_cli::error::ParseError;
1039    ///
1040    /// let error = ParseError::missing_argument("filename", "process");
1041    /// match error {
1042    ///     ParseError::MissingArgument { suggestion, .. } => {
1043    ///         assert!(suggestion.is_some());
1044    ///     }
1045    ///     _ => panic!("wrong variant"),
1046    /// }
1047    /// ```
1048    pub fn missing_argument(argument: &str, command: &str) -> Self {
1049        Self::MissingArgument {
1050            argument: argument.to_string(),
1051            command: command.to_string(),
1052            suggestion: Some(format!("Run --help {command} to see required arguments.")),
1053        }
1054    }
1055
1056    /// Create a missing option error with a help hint
1057    ///
1058    /// The suggestion automatically refers the user to `--help <command>`.
1059    ///
1060    /// # Example
1061    ///
1062    /// ```
1063    /// use dynamic_cli::error::ParseError;
1064    ///
1065    /// let error = ParseError::missing_option("output", "export");
1066    /// match error {
1067    ///     ParseError::MissingOption { suggestion, .. } => {
1068    ///         assert!(suggestion.is_some());
1069    ///     }
1070    ///     _ => panic!("wrong variant"),
1071    /// }
1072    /// ```
1073    pub fn missing_option(option: &str, command: &str) -> Self {
1074        Self::MissingOption {
1075            option: option.to_string(),
1076            command: command.to_string(),
1077            suggestion: Some(format!("Run --help {command} to see required options.")),
1078        }
1079    }
1080
1081    /// Create a too-many-arguments error with a help hint
1082    ///
1083    /// # Example
1084    ///
1085    /// ```
1086    /// use dynamic_cli::error::ParseError;
1087    ///
1088    /// let error = ParseError::too_many_arguments("run", 1, 3);
1089    /// match error {
1090    ///     ParseError::TooManyArguments { suggestion, .. } => {
1091    ///         assert!(suggestion.is_some());
1092    ///     }
1093    ///     _ => panic!("wrong variant"),
1094    /// }
1095    /// ```
1096    pub fn too_many_arguments(command: &str, expected: usize, got: usize) -> Self {
1097        Self::TooManyArguments {
1098            command: command.to_string(),
1099            expected,
1100            got,
1101            suggestion: Some(format!("Run --help {command} for the expected usage.")),
1102        }
1103    }
1104}
1105
1106impl ConfigError {
1107    /// Create a file-not-found error with a standard suggestion
1108    ///
1109    /// # Example
1110    ///
1111    /// ```
1112    /// use dynamic_cli::error::ConfigError;
1113    /// use std::path::PathBuf;
1114    ///
1115    /// let error = ConfigError::file_not_found(PathBuf::from("commands.yaml"));
1116    /// match error {
1117    ///     ConfigError::FileNotFound { suggestion, .. } => {
1118    ///         assert!(suggestion.is_some());
1119    ///     }
1120    ///     _ => panic!("wrong variant"),
1121    /// }
1122    /// ```
1123    pub fn file_not_found(path: PathBuf) -> Self {
1124        Self::FileNotFound {
1125            path,
1126            suggestion: Some("Verify the path and file permissions.".to_string()),
1127        }
1128    }
1129
1130    /// Create an unsupported-format error with a standard suggestion
1131    ///
1132    /// # Example
1133    ///
1134    /// ```
1135    /// use dynamic_cli::error::ConfigError;
1136    ///
1137    /// let error = ConfigError::unsupported_format(".toml");
1138    /// match error {
1139    ///     ConfigError::UnsupportedFormat { suggestion, .. } => {
1140    ///         assert!(suggestion.is_some());
1141    ///     }
1142    ///     _ => panic!("wrong variant"),
1143    /// }
1144    /// ```
1145    pub fn unsupported_format(extension: &str) -> Self {
1146        Self::UnsupportedFormat {
1147            extension: extension.to_string(),
1148            suggestion: Some("Rename the file with a .yaml, .yml or .json extension.".to_string()),
1149        }
1150    }
1151
1152    /// Create a YAML parse error with position extracted from the serde error
1153    pub fn yaml_parse_with_location(source: serde_yaml::Error) -> Self {
1154        let location = source.location();
1155        Self::YamlParse {
1156            source,
1157            line: location.as_ref().map(|l| l.line()),
1158            column: location.map(|l| l.column()),
1159        }
1160    }
1161
1162    /// Create a JSON parse error with position extracted from the serde error
1163    pub fn json_parse_with_location(source: serde_json::Error) -> Self {
1164        Self::JsonParse {
1165            line: source.line(),
1166            column: source.column(),
1167            source,
1168        }
1169    }
1170}
1171
1172impl ExecutionError {
1173    /// Create a handler-not-found error with an actionable suggestion
1174    ///
1175    /// The suggestion interpolates the implementation name so the user
1176    /// knows exactly which `register_sync_handler()` or
1177    /// `register_async_handler()` call is missing (DD-022).
1178    ///
1179    /// # Example
1180    ///
1181    /// ```
1182    /// use dynamic_cli::error::ExecutionError;
1183    ///
1184    /// let error = ExecutionError::handler_not_found("run", "run_handler");
1185    /// match error {
1186    ///     ExecutionError::HandlerNotFound { suggestion, .. } => {
1187    ///         assert!(suggestion.as_deref().unwrap_or("").contains("run_handler"));
1188    ///     }
1189    ///     _ => panic!("wrong variant"),
1190    /// }
1191    /// ```
1192    pub fn handler_not_found(command: &str, implementation: &str) -> Self {
1193        Self::HandlerNotFound {
1194            command: command.to_string(),
1195            implementation: implementation.to_string(),
1196            suggestion: Some(format!(
1197                "Ensure .register_sync_handler(\"{implementation}\", ...) or \
1198                 .register_async_handler(\"{implementation}\", ...) was called before running."
1199            )),
1200        }
1201    }
1202}
1203
1204impl RegistryError {
1205    /// Create a missing-handler error with an actionable suggestion
1206    ///
1207    /// The suggestion interpolates the command name so the user
1208    /// knows exactly which `.register_sync_handler()` call is missing.
1209    ///
1210    /// # Example
1211    ///
1212    /// ```
1213    /// use dynamic_cli::error::RegistryError;
1214    ///
1215    /// let error = RegistryError::missing_handler("export");
1216    /// match error {
1217    ///     RegistryError::MissingHandler { suggestion, .. } => {
1218    ///         assert!(suggestion.as_deref().unwrap_or("").contains("export"));
1219    ///     }
1220    ///     _ => panic!("wrong variant"),
1221    /// }
1222    /// ```
1223    pub fn missing_handler(command: &str) -> Self {
1224        Self::MissingHandler {
1225            command: command.to_string(),
1226            suggestion: Some(format!(
1227                "Call .register_sync_handler(\"{command}\", ...) before running."
1228            )),
1229        }
1230    }
1231
1232    /// Create a fault-tolerance-mismatch error with an actionable suggestion
1233    /// (DD-028)
1234    ///
1235    /// `expected` is what the handler's `expected_fault_tolerance()`
1236    /// returned; `configured` is the command's actual
1237    /// `CommandDefinition::continue_on_failure`. The suggestion tells the
1238    /// user exactly which YAML value to change to resolve the contradiction.
1239    ///
1240    /// # Example
1241    ///
1242    /// ```
1243    /// use dynamic_cli::error::RegistryError;
1244    ///
1245    /// let error = RegistryError::fault_tolerance_mismatch("solve", false, true);
1246    /// match error {
1247    ///     RegistryError::FaultToleranceMismatch { suggestion, .. } => {
1248    ///         assert!(suggestion.as_deref().unwrap_or("").contains("continue_on_failure: false"));
1249    ///     }
1250    ///     _ => panic!("wrong variant"),
1251    /// }
1252    /// ```
1253    pub fn fault_tolerance_mismatch(command: &str, expected: bool, configured: bool) -> Self {
1254        Self::FaultToleranceMismatch {
1255            command: command.to_string(),
1256            expected,
1257            configured,
1258            suggestion: Some(format!(
1259                "set `continue_on_failure: {expected}` for command '{command}' in the \
1260                 YAML config, matching what its handler expects"
1261            )),
1262        }
1263    }
1264}
1265
1266#[cfg(feature = "wasm-plugins")]
1267impl WasmError {
1268    /// Create a `FunctionNotFound` error for a missing mandatory export
1269    ///
1270    /// The suggestion names the exact signature expected for the missing
1271    /// export, taken from `WASM_PLUGIN_INTERFACE.md`.
1272    ///
1273    /// # Example
1274    ///
1275    /// ```
1276    /// use dynamic_cli::error::WasmError;
1277    ///
1278    /// let error = WasmError::missing_mandatory_export("dcli_alloc", "plugin.wasm");
1279    /// match error {
1280    ///     WasmError::FunctionNotFound { suggestion, .. } => {
1281    ///         assert!(suggestion.is_some());
1282    ///     }
1283    ///     _ => panic!("wrong variant"),
1284    /// }
1285    /// ```
1286    pub fn missing_mandatory_export(function: &str, module: &str) -> Self {
1287        let signature = match function {
1288            "dcli_alloc" => "fn dcli_alloc(size: i32) -> i32",
1289            "dcli_dealloc" => "fn dcli_dealloc(ptr: i32, size: i32)",
1290            "memory" => "(memory (export \"memory\") ...)",
1291            other => other,
1292        };
1293        Self::FunctionNotFound {
1294            function: function.to_string(),
1295            module: module.to_string(),
1296            suggestion: Some(format!(
1297                "Export `{signature}` from the WASM module. \
1298                 See WASM_PLUGIN_INTERFACE.md for the full contract."
1299            )),
1300        }
1301    }
1302
1303    /// Create a `GuestError` from a raw error code, without a detailed message
1304    ///
1305    /// Used when the module does not export `dcli_last_error_message`.
1306    ///
1307    /// # Example
1308    ///
1309    /// ```
1310    /// use dynamic_cli::error::WasmError;
1311    ///
1312    /// let error = WasmError::guest_error_without_message(2);
1313    /// match error {
1314    ///     WasmError::GuestError { code, message } => {
1315    ///         assert_eq!(code, 2);
1316    ///         assert!(message.is_none());
1317    ///     }
1318    ///     _ => panic!("wrong variant"),
1319    /// }
1320    /// ```
1321    pub fn guest_error_without_message(code: i32) -> Self {
1322        Self::GuestError {
1323            code,
1324            message: None,
1325        }
1326    }
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331    use super::*;
1332
1333    // ── ConfigError ──────────────────────────────────────────
1334
1335    #[test]
1336    fn test_config_file_not_found_display() {
1337        let error = ConfigError::FileNotFound {
1338            path: PathBuf::from("/path/to/config.yaml"),
1339            suggestion: None,
1340        };
1341        let msg = format!("{}", error);
1342        assert!(msg.contains("not found"));
1343        assert!(msg.contains("config.yaml"));
1344    }
1345
1346    #[test]
1347    fn test_config_file_not_found_helper_has_suggestion() {
1348        let error = ConfigError::file_not_found(PathBuf::from("commands.yaml"));
1349        match error {
1350            ConfigError::FileNotFound { suggestion, .. } => {
1351                assert!(suggestion.is_some(), "helper must populate suggestion");
1352            }
1353            _ => panic!("wrong variant"),
1354        }
1355    }
1356
1357    #[test]
1358    fn test_config_unsupported_format_helper_has_suggestion() {
1359        let error = ConfigError::unsupported_format(".toml");
1360        match error {
1361            ConfigError::UnsupportedFormat {
1362                suggestion,
1363                extension,
1364                ..
1365            } => {
1366                assert_eq!(extension, ".toml");
1367                assert!(suggestion.is_some());
1368            }
1369            _ => panic!("wrong variant"),
1370        }
1371    }
1372
1373    #[test]
1374    fn test_config_duplicate_command_display() {
1375        let error = ConfigError::DuplicateCommand {
1376            name: "run".to_string(),
1377            suggestion: Some("Rename one of the conflicting commands.".to_string()),
1378        };
1379        let msg = format!("{}", error);
1380        assert!(msg.contains("run"));
1381        // suggestion must NOT appear in Display (it's rendered separately)
1382        assert!(!msg.contains("Rename"));
1383    }
1384
1385    #[test]
1386    fn test_config_unknown_type_display() {
1387        let error = ConfigError::UnknownType {
1388            type_name: "datetime".to_string(),
1389            context: "commands[0]".to_string(),
1390            suggestion: None,
1391        };
1392        let msg = format!("{}", error);
1393        assert!(msg.contains("datetime"));
1394    }
1395
1396    #[test]
1397    fn test_config_inconsistency_display() {
1398        let error = ConfigError::Inconsistency {
1399            details: "default not in choices".to_string(),
1400            suggestion: Some("hint".to_string()),
1401        };
1402        let msg = format!("{}", error);
1403        assert!(msg.contains("default not in choices"));
1404        assert!(!msg.contains("hint")); // suggestion separate from Display
1405    }
1406
1407    #[test]
1408    fn test_config_invalid_schema_display() {
1409        let error = ConfigError::InvalidSchema {
1410            reason: "missing field".to_string(),
1411            path: Some("commands[0]".to_string()),
1412            suggestion: None,
1413        };
1414        let msg = format!("{}", error);
1415        assert!(msg.contains("missing field"));
1416    }
1417
1418    // ── ParseError ───────────────────────────────────────────
1419
1420    #[test]
1421    fn test_parse_unknown_command_with_suggestions() {
1422        let available = vec!["simulate".to_string(), "validate".to_string()];
1423        let error = ParseError::unknown_command_with_suggestions("simulat", &available);
1424        match error {
1425            ParseError::UnknownCommand {
1426                command,
1427                suggestions,
1428            } => {
1429                assert_eq!(command, "simulat");
1430                assert!(suggestions.contains(&"simulate".to_string()));
1431            }
1432            _ => panic!("wrong variant"),
1433        }
1434    }
1435
1436    #[test]
1437    fn test_parse_missing_argument_helper_has_suggestion() {
1438        let error = ParseError::missing_argument("filename", "process");
1439        match error {
1440            ParseError::MissingArgument {
1441                suggestion,
1442                command,
1443                ..
1444            } => {
1445                assert_eq!(command, "process");
1446                let s = suggestion.unwrap();
1447                assert!(s.contains("process"));
1448                assert!(s.contains("--help"));
1449            }
1450            _ => panic!("wrong variant"),
1451        }
1452    }
1453
1454    #[test]
1455    fn test_parse_missing_option_helper_has_suggestion() {
1456        let error = ParseError::missing_option("output", "export");
1457        match error {
1458            ParseError::MissingOption {
1459                suggestion, option, ..
1460            } => {
1461                assert_eq!(option, "output");
1462                let s = suggestion.unwrap();
1463                assert!(s.contains("export"));
1464            }
1465            _ => panic!("wrong variant"),
1466        }
1467    }
1468
1469    #[test]
1470    fn test_parse_too_many_arguments_helper_has_suggestion() {
1471        let error = ParseError::too_many_arguments("run", 1, 3);
1472        match error {
1473            ParseError::TooManyArguments {
1474                suggestion,
1475                expected,
1476                got,
1477                ..
1478            } => {
1479                assert_eq!(expected, 1);
1480                assert_eq!(got, 3);
1481                assert!(suggestion.is_some());
1482            }
1483            _ => panic!("wrong variant"),
1484        }
1485    }
1486
1487    #[test]
1488    fn test_parse_missing_argument_suggestion_none_by_default() {
1489        // Direct construction without helper: suggestion is caller's responsibility
1490        let error = ParseError::MissingArgument {
1491            argument: "file".to_string(),
1492            command: "run".to_string(),
1493            suggestion: None,
1494        };
1495        match error {
1496            ParseError::MissingArgument { suggestion, .. } => assert!(suggestion.is_none()),
1497            _ => panic!("wrong variant"),
1498        }
1499    }
1500
1501    // ── ValidationError ──────────────────────────────────────
1502
1503    #[test]
1504    fn test_validation_out_of_range_display() {
1505        let error = ValidationError::OutOfRange {
1506            arg_name: "percentage".to_string(),
1507            value: 150.0,
1508            min: 0.0,
1509            max: 100.0,
1510            suggestion: None,
1511        };
1512        let msg = format!("{}", error);
1513        assert!(msg.contains("percentage"));
1514        assert!(msg.contains("150"));
1515        assert!(msg.contains("0"));
1516        assert!(msg.contains("100"));
1517    }
1518
1519    #[test]
1520    fn test_validation_out_of_range_suggestion_not_in_display() {
1521        let error = ValidationError::OutOfRange {
1522            arg_name: "percentage".to_string(),
1523            value: 150.0,
1524            min: 0.0,
1525            max: 100.0,
1526            suggestion: Some("Value must be between 0 and 100.".to_string()),
1527        };
1528        let msg = format!("{}", error);
1529        assert!(!msg.contains("Value must be between")); // suggestion is separate
1530    }
1531
1532    #[test]
1533    fn test_validation_file_not_found_suggestion() {
1534        let error = ValidationError::FileNotFound {
1535            path: PathBuf::from("data.csv"),
1536            arg_name: "input".to_string(),
1537            suggestion: Some("Check that the file exists.".to_string()),
1538        };
1539        match error {
1540            ValidationError::FileNotFound { suggestion, .. } => {
1541                assert!(suggestion.is_some());
1542            }
1543            _ => panic!("wrong variant"),
1544        }
1545    }
1546
1547    #[test]
1548    fn test_validation_missing_dependency_suggestion() {
1549        let error = ValidationError::MissingDependency {
1550            arg_name: "format".to_string(),
1551            required_arg: "output".to_string(),
1552            suggestion: Some("Add --output to your command.".to_string()),
1553        };
1554        let msg = format!("{}", error);
1555        assert!(msg.contains("format"));
1556        assert!(msg.contains("output"));
1557    }
1558
1559    #[test]
1560    fn test_validation_mutually_exclusive_suggestion() {
1561        let error = ValidationError::MutuallyExclusive {
1562            arg1: "--verbose".to_string(),
1563            arg2: "--quiet".to_string(),
1564            suggestion: Some("Remove one of the two conflicting options.".to_string()),
1565        };
1566        let msg = format!("{}", error);
1567        assert!(msg.contains("--verbose"));
1568        assert!(msg.contains("--quiet"));
1569    }
1570
1571    // ── ExecutionError ───────────────────────────────────────
1572
1573    #[test]
1574    fn test_execution_handler_not_found_helper_interpolates_impl() {
1575        let error = ExecutionError::handler_not_found("run", "run_handler");
1576        match error {
1577            ExecutionError::HandlerNotFound {
1578                suggestion,
1579                implementation,
1580                ..
1581            } => {
1582                assert_eq!(implementation, "run_handler");
1583                let s = suggestion.unwrap();
1584                assert!(s.contains("run_handler"));
1585                assert!(s.contains("register_sync_handler"));
1586            }
1587            _ => panic!("wrong variant"),
1588        }
1589    }
1590
1591    #[test]
1592    fn test_execution_context_downcast_failed_display() {
1593        let error = ExecutionError::ContextDowncastFailed {
1594            expected_type: "MyAppContext".to_string(),
1595            suggestion: None,
1596        };
1597        let msg = format!("{}", error);
1598        assert!(msg.contains("MyAppContext"));
1599    }
1600
1601    #[test]
1602    fn test_execution_invalid_context_state_suggestion() {
1603        let error = ExecutionError::InvalidContextState {
1604            reason: "pool not ready".to_string(),
1605            suggestion: Some("Ensure context is initialised.".to_string()),
1606        };
1607        let msg = format!("{}", error);
1608        assert!(msg.contains("pool not ready"));
1609    }
1610
1611    // ── RegistryError ────────────────────────────────────────
1612
1613    #[test]
1614    fn test_registry_missing_handler_helper_interpolates_command() {
1615        let error = RegistryError::missing_handler("export");
1616        match error {
1617            RegistryError::MissingHandler {
1618                suggestion,
1619                command,
1620            } => {
1621                assert_eq!(command, "export");
1622                let s = suggestion.unwrap();
1623                assert!(s.contains("export"));
1624                assert!(s.contains("register_sync_handler"));
1625            }
1626            _ => panic!("wrong variant"),
1627        }
1628    }
1629
1630    #[test]
1631    fn test_registry_duplicate_registration_display() {
1632        let error = RegistryError::DuplicateRegistration {
1633            name: "run".to_string(),
1634            suggestion: None,
1635        };
1636        let msg = format!("{}", error);
1637        assert!(msg.contains("run"));
1638    }
1639
1640    #[test]
1641    fn test_registry_duplicate_alias_display() {
1642        let error = RegistryError::DuplicateAlias {
1643            alias: "r".to_string(),
1644            existing_command: "run".to_string(),
1645            suggestion: Some("Choose a different alias.".to_string()),
1646        };
1647        let msg = format!("{}", error);
1648        assert!(msg.contains("run"));
1649        assert!(!msg.contains("Choose")); // suggestion separate from Display
1650    }
1651
1652    #[test]
1653    fn test_registry_fault_tolerance_mismatch_helper_interpolates_command() {
1654        let error = RegistryError::fault_tolerance_mismatch("solve", false, true);
1655        match error {
1656            RegistryError::FaultToleranceMismatch {
1657                command,
1658                expected,
1659                configured,
1660                suggestion,
1661            } => {
1662                assert_eq!(command, "solve");
1663                assert!(!expected);
1664                assert!(configured);
1665                let s = suggestion.unwrap();
1666                assert!(s.contains("solve"));
1667                assert!(s.contains("continue_on_failure: false"));
1668            }
1669            _ => panic!("wrong variant"),
1670        }
1671    }
1672
1673    #[test]
1674    fn test_registry_fault_tolerance_mismatch_display() {
1675        let error = RegistryError::FaultToleranceMismatch {
1676            command: "solve".to_string(),
1677            expected: false,
1678            configured: true,
1679            suggestion: None,
1680        };
1681        let msg = format!("{}", error);
1682        assert!(msg.contains("solve"));
1683        assert!(msg.contains("false"));
1684        assert!(msg.contains("true"));
1685    }
1686
1687    // ── WasmError ────────────────────────────────────────────
1688
1689    #[cfg(feature = "wasm-plugins")]
1690    #[test]
1691    fn test_wasm_load_failed_display() {
1692        let error = WasmError::LoadFailed {
1693            path: PathBuf::from("plugin.wasm"),
1694            source: anyhow::anyhow!("invalid magic number"),
1695            suggestion: None,
1696        };
1697        let msg = format!("{}", error);
1698        assert!(msg.contains("plugin.wasm"));
1699        assert!(msg.contains("invalid magic number"));
1700    }
1701
1702    #[cfg(feature = "wasm-plugins")]
1703    #[test]
1704    fn test_wasm_function_not_found_display() {
1705        let error = WasmError::FunctionNotFound {
1706            function: "dcli_alloc".to_string(),
1707            module: "plugin.wasm".to_string(),
1708            suggestion: None,
1709        };
1710        let msg = format!("{}", error);
1711        assert!(msg.contains("dcli_alloc"));
1712        assert!(msg.contains("plugin.wasm"));
1713    }
1714
1715    #[cfg(feature = "wasm-plugins")]
1716    #[test]
1717    fn test_wasm_missing_mandatory_export_helper_has_suggestion() {
1718        let error = WasmError::missing_mandatory_export("dcli_dealloc", "plugin.wasm");
1719        match error {
1720            WasmError::FunctionNotFound {
1721                suggestion,
1722                function,
1723                ..
1724            } => {
1725                assert_eq!(function, "dcli_dealloc");
1726                let s = suggestion.unwrap();
1727                assert!(s.contains("dcli_dealloc"));
1728                assert!(s.contains("WASM_PLUGIN_INTERFACE.md"));
1729            }
1730            _ => panic!("wrong variant"),
1731        }
1732    }
1733
1734    #[cfg(feature = "wasm-plugins")]
1735    #[test]
1736    fn test_wasm_guest_error_with_message_display() {
1737        let error = WasmError::GuestError {
1738            code: 1,
1739            message: Some("invalid argument".to_string()),
1740        };
1741        let msg = format!("{}", error);
1742        assert!(msg.contains('1'));
1743        assert!(msg.contains("invalid argument"));
1744    }
1745
1746    #[cfg(feature = "wasm-plugins")]
1747    #[test]
1748    fn test_wasm_guest_error_without_message_display() {
1749        let error = WasmError::guest_error_without_message(2);
1750        let msg = format!("{}", error);
1751        assert!(msg.contains('2'));
1752        match error {
1753            WasmError::GuestError { message, .. } => assert!(message.is_none()),
1754            _ => panic!("wrong variant"),
1755        }
1756    }
1757
1758    #[cfg(feature = "wasm-plugins")]
1759    #[test]
1760    fn test_wasm_serialization_failed_display() {
1761        let error = WasmError::SerializationFailed("unsupported map key type".to_string());
1762        let msg = format!("{}", error);
1763        assert!(msg.contains("unsupported map key type"));
1764    }
1765
1766    #[cfg(feature = "wasm-plugins")]
1767    #[test]
1768    fn test_wasm_memory_access_failed_display() {
1769        let error = WasmError::MemoryAccessFailed {
1770            reason: "write out of bounds".to_string(),
1771        };
1772        let msg = format!("{}", error);
1773        assert!(msg.contains("out of bounds"));
1774    }
1775
1776    #[cfg(feature = "wasm-plugins")]
1777    #[test]
1778    fn test_wasm_error_converts_into_dynamic_cli_error() {
1779        let wasm_err = WasmError::guest_error_without_message(1);
1780        let err: DynamicCliError = wasm_err.into();
1781        match err {
1782            DynamicCliError::Wasm(WasmError::GuestError { code, .. }) => assert_eq!(code, 1),
1783            _ => panic!("wrong variant"),
1784        }
1785    }
1786}