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