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}