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