Skip to main content

dynamic_cli/error/
display.rs

1//! User-friendly error display
2//!
3//! Formats errors with coloring to improve readability in the terminal.
4//!
5//! The `colored` crate is used unconditionally, consistent with the rest of
6//! the framework's output (help formatter, REPL prompt). Applications that
7//! need plain-text output can disable ANSI codes at the OS level or redirect
8//! stderr to a file.
9//!
10//! # Output format
11//!
12//! ```text
13//! Error: <main message>
14//!   ℹ  <suggestion>       ← only when a suggestion is available
15//! ```
16//!
17//! For parse errors with Levenshtein suggestions:
18//!
19//! ```text
20//! Error: Unknown command: 'simulat'. Type 'help' for available commands.
21//!
22//! ?  Did you mean:
23//!   •  simulate
24//!   •  simulate2
25//! ```
26
27use colored::Colorize;
28
29#[cfg(feature = "wasm-plugins")]
30use crate::error::WasmError;
31use crate::error::{
32    ConfigError, DynamicCliError, ExecutionError, ParseError, RegistryError, ValidationError,
33};
34
35// ═══════════════════════════════════════════════════════════
36// COLOR PALETTE  (mirrors DefaultHelpFormatter)
37// ═══════════════════════════════════════════════════════════
38
39/// Render text as a bold red error label (used for "Error:")
40fn color_error(s: &str) -> String {
41    s.red().bold().to_string()
42}
43
44/// Render a question mark prompt (used before "Did you mean:")
45fn color_question(s: &str) -> String {
46    s.yellow().bold().to_string()
47}
48
49/// Render a bullet point character
50fn color_bullet(s: &str) -> String {
51    s.cyan().to_string()
52}
53
54/// Render a Levenshtein suggestion (command / option name)
55fn color_suggestion(s: &str) -> String {
56    s.green().to_string()
57}
58
59/// Render an info symbol (used before the actionable suggestion line)
60fn color_info(s: &str) -> String {
61    s.blue().bold().to_string()
62}
63
64/// Render a type name or path
65fn color_type_name(s: &str) -> String {
66    s.cyan().to_string()
67}
68
69/// Render an argument or option name
70fn color_arg_name(s: &str) -> String {
71    s.yellow().to_string()
72}
73
74/// Render an invalid value
75fn color_value(s: &str) -> String {
76    s.red().to_string()
77}
78
79/// Render dimmed secondary text (e.g., "at", "in")
80fn color_dimmed(s: &str) -> String {
81    s.dimmed().to_string()
82}
83
84// ═══════════════════════════════════════════════════════════
85// PUBLIC API
86// ═══════════════════════════════════════════════════════════
87
88/// Print an error to stderr in a user-friendly way
89///
90/// Writes the formatted error (with ANSI colors) to stderr.
91///
92/// # Example
93///
94/// ```no_run
95/// use dynamic_cli::error::{display_error, ParseError};
96///
97/// let error = ParseError::UnknownCommand {
98///     command: "simulat".to_string(),
99///     suggestions: vec!["simulate".to_string()],
100/// };
101/// display_error(&error.into());
102/// ```
103pub fn display_error(error: &DynamicCliError) {
104    eprintln!("{}", format_error(error));
105}
106
107/// Format an error as a colored, human-readable string
108///
109/// Generates a string suitable for display in the terminal.
110/// The format is:
111///
112/// ```text
113/// Error: <main message>
114///   ℹ  <actionable suggestion>
115/// ```
116///
117/// For parse errors with Levenshtein suggestions, a "Did you mean:" block
118/// is appended instead of the `ℹ` line.
119///
120/// # Arguments
121///
122/// * `error` - The error to format
123///
124/// # Example
125///
126/// ```
127/// use dynamic_cli::error::{format_error, ConfigError};
128/// use std::path::PathBuf;
129///
130/// let error: dynamic_cli::error::DynamicCliError = ConfigError::FileNotFound {
131///     path: PathBuf::from("config.yaml"),
132///     suggestion: Some("Verify the path and file permissions.".to_string()),
133/// }.into();
134///
135/// let formatted = format_error(&error);
136/// assert!(formatted.contains("Error:"));
137/// assert!(formatted.contains("config.yaml"));
138/// ```
139pub fn format_error(error: &DynamicCliError) -> String {
140    let mut output = String::new();
141
142    output.push_str(&format!("{} ", color_error("Error:")));
143
144    match error {
145        DynamicCliError::Parse(e) => format_parse_error(&mut output, e),
146        DynamicCliError::Config(e) => format_config_error(&mut output, e),
147        DynamicCliError::Validation(e) => format_validation_error(&mut output, e),
148        DynamicCliError::Execution(e) => format_execution_error(&mut output, e),
149        DynamicCliError::Registry(e) => format_registry_error(&mut output, e),
150        #[cfg(feature = "wasm-plugins")]
151        DynamicCliError::Wasm(e) => format_wasm_error(&mut output, e),
152        DynamicCliError::Io(e) => output.push_str(&format!("{}\n", e)),
153    }
154
155    output
156}
157
158// ═══════════════════════════════════════════════════════════
159// CATEGORY FORMATTERS
160// ═══════════════════════════════════════════════════════════
161
162/// Format a parse error, appending Levenshtein suggestions when available
163fn format_parse_error(output: &mut String, error: &ParseError) {
164    output.push_str(&format!("{}\n", error));
165
166    match error {
167        ParseError::UnknownCommand { suggestions, .. } if !suggestions.is_empty() => {
168            output.push_str(&format!("\n{} Did you mean:\n", color_question("?")));
169            for s in suggestions {
170                output.push_str(&format!(
171                    "  {} {}\n",
172                    color_bullet("•"),
173                    color_suggestion(s)
174                ));
175            }
176        }
177
178        ParseError::UnknownOption { suggestions, .. } if !suggestions.is_empty() => {
179            output.push_str(&format!("\n{} Did you mean:\n", color_question("?")));
180            for s in suggestions {
181                output.push_str(&format!(
182                    "  {} {}\n",
183                    color_bullet("•"),
184                    color_suggestion(s)
185                ));
186            }
187        }
188
189        ParseError::TypeParseError {
190            arg_name,
191            expected_type,
192            value,
193            ..
194        } => {
195            output.push_str(&format!(
196                "\n{} Expected type {} for argument {}, got: {}\n",
197                color_info("ℹ"),
198                color_type_name(expected_type),
199                color_arg_name(arg_name),
200                color_value(value)
201            ));
202        }
203
204        ParseError::MissingArgument { suggestion, .. }
205        | ParseError::MissingOption { suggestion, .. }
206        | ParseError::TooManyArguments { suggestion, .. }
207        | ParseError::UnknownOptionParameter { suggestion, .. }
208        | ParseError::MissingRequiredOptionParameter { suggestion, .. }
209        | ParseError::UnknownDiscriminant { suggestion, .. }
210        | ParseError::DuplicateOptionOccurrence { suggestion, .. } => {
211            append_suggestion(output, suggestion.as_deref());
212        }
213
214        _ => {}
215    }
216}
217
218/// Format a configuration error, showing parse positions and suggestions
219fn format_config_error(output: &mut String, error: &ConfigError) {
220    match error {
221        ConfigError::YamlParse {
222            source,
223            line,
224            column,
225        } => {
226            output.push_str(&format!("{}\n", source));
227            if let (Some(l), Some(c)) = (line, column) {
228                output.push_str(&format!(
229                    "  {} line {}, column {}\n",
230                    color_dimmed("at"),
231                    color_arg_name(&l.to_string()),
232                    color_arg_name(&c.to_string())
233                ));
234            }
235        }
236
237        ConfigError::JsonParse {
238            source,
239            line,
240            column,
241        } => {
242            output.push_str(&format!("{}\n", source));
243            output.push_str(&format!(
244                "  {} line {}, column {}\n",
245                color_dimmed("at"),
246                color_arg_name(&line.to_string()),
247                color_arg_name(&column.to_string())
248            ));
249        }
250
251        ConfigError::InvalidSchema {
252            reason,
253            path,
254            suggestion,
255        } => {
256            output.push_str(&format!("{}\n", reason));
257            if let Some(p) = path {
258                output.push_str(&format!(
259                    "  {} {}\n",
260                    color_dimmed("in"),
261                    color_type_name(p)
262                ));
263            }
264            append_suggestion(output, suggestion.as_deref());
265        }
266
267        ConfigError::FileNotFound { suggestion, .. }
268        | ConfigError::UnsupportedFormat { suggestion, .. }
269        | ConfigError::DuplicateCommand { suggestion, .. }
270        | ConfigError::UnknownType { suggestion, .. }
271        | ConfigError::Inconsistency { suggestion, .. } => {
272            output.push_str(&format!("{}\n", error));
273            append_suggestion(output, suggestion.as_deref());
274        }
275    }
276}
277
278/// Format a validation error with its actionable suggestion
279fn format_validation_error(output: &mut String, error: &ValidationError) {
280    output.push_str(&format!("{}\n", error));
281
282    let suggestion = match error {
283        ValidationError::FileNotFound { suggestion, .. } => suggestion.as_deref(),
284        ValidationError::OutOfRange { suggestion, .. } => suggestion.as_deref(),
285        ValidationError::CustomConstraint { suggestion, .. } => suggestion.as_deref(),
286        ValidationError::MissingDependency { suggestion, .. } => suggestion.as_deref(),
287        ValidationError::MutuallyExclusive { suggestion, .. } => suggestion.as_deref(),
288        // InvalidExtension already lists the expected extensions in the message
289        ValidationError::InvalidExtension { .. } => None,
290    };
291
292    append_suggestion(output, suggestion);
293}
294
295/// Format an execution error with its actionable suggestion
296fn format_execution_error(output: &mut String, error: &ExecutionError) {
297    output.push_str(&format!("{}\n", error));
298
299    let suggestion = match error {
300        ExecutionError::HandlerNotFound { suggestion, .. } => suggestion.as_deref(),
301        ExecutionError::ContextDowncastFailed { suggestion, .. } => suggestion.as_deref(),
302        ExecutionError::InvalidContextState { suggestion, .. } => suggestion.as_deref(),
303        // CommandFailed and Interrupted carry no structured suggestion
304        ExecutionError::CommandFailed(_) | ExecutionError::Interrupted => None,
305    };
306
307    append_suggestion(output, suggestion);
308}
309
310/// Format a registry error with its actionable suggestion
311fn format_registry_error(output: &mut String, error: &RegistryError) {
312    output.push_str(&format!("{}\n", error));
313
314    let suggestion = match error {
315        RegistryError::DuplicateRegistration { suggestion, .. } => suggestion.as_deref(),
316        RegistryError::DuplicateAlias { suggestion, .. } => suggestion.as_deref(),
317        RegistryError::MissingHandler { suggestion, .. } => suggestion.as_deref(),
318        RegistryError::FaultToleranceMismatch { suggestion, .. } => suggestion.as_deref(),
319    };
320
321    append_suggestion(output, suggestion);
322}
323
324/// Format a WASM plugin error with its actionable suggestion
325///
326/// Only available when the `wasm-plugins` feature is enabled.
327///
328/// `GuestError` and `SerializationFailed` and `MemoryAccessFailed` carry no
329/// structured `suggestion` field — they surface only their `Display` message.
330#[cfg(feature = "wasm-plugins")]
331fn format_wasm_error(output: &mut String, error: &WasmError) {
332    output.push_str(&format!("{}\n", error));
333
334    let suggestion = match error {
335        WasmError::LoadFailed { suggestion, .. } => suggestion.as_deref(),
336        WasmError::FunctionNotFound { suggestion, .. } => suggestion.as_deref(),
337        WasmError::GuestError { .. } => None,
338        WasmError::SerializationFailed(_) => None,
339        WasmError::MemoryAccessFailed { .. } => None,
340    };
341
342    append_suggestion(output, suggestion);
343}
344
345// ═══════════════════════════════════════════════════════════
346// SHARED HELPER
347// ═══════════════════════════════════════════════════════════
348
349/// Append a suggestion line to the output buffer
350///
351/// Renders the line only when `suggestion` is `Some`. The format is:
352///
353/// ```text
354///   ℹ  <suggestion text>
355/// ```
356///
357/// When `suggestion` is `None`, this is a no-op.
358fn append_suggestion(output: &mut String, suggestion: Option<&str>) {
359    if let Some(s) = suggestion {
360        output.push_str(&format!("  {} {}\n", color_info("ℹ"), s));
361    }
362}
363
364// ═══════════════════════════════════════════════════════════
365// TESTS
366// ═══════════════════════════════════════════════════════════
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use std::path::PathBuf;
372
373    // ── format_error — Config ────────────────────────────────
374
375    #[test]
376    fn test_format_config_file_not_found_contains_path() {
377        let error: DynamicCliError = ConfigError::FileNotFound {
378            path: PathBuf::from("test.yaml"),
379            suggestion: None,
380        }
381        .into();
382
383        let formatted = format_error(&error);
384        assert!(formatted.contains("Error:"));
385        assert!(formatted.contains("test.yaml"));
386    }
387
388    #[test]
389    fn test_format_config_file_not_found_with_suggestion() {
390        let error: DynamicCliError = ConfigError::FileNotFound {
391            path: PathBuf::from("test.yaml"),
392            suggestion: Some("Verify the path.".to_string()),
393        }
394        .into();
395
396        let formatted = format_error(&error);
397        assert!(formatted.contains("Verify the path."));
398    }
399
400    #[test]
401    fn test_format_config_file_not_found_no_suggestion_no_hint_line() {
402        let error: DynamicCliError = ConfigError::FileNotFound {
403            path: PathBuf::from("test.yaml"),
404            suggestion: None,
405        }
406        .into();
407
408        let formatted = format_error(&error);
409        // The ℹ line must not appear when suggestion is None
410        assert!(!formatted.contains('ℹ'));
411    }
412
413    #[test]
414    fn test_format_config_unsupported_format_with_suggestion() {
415        let error: DynamicCliError = ConfigError::UnsupportedFormat {
416            extension: ".toml".to_string(),
417            suggestion: Some("Use .yaml instead.".to_string()),
418        }
419        .into();
420
421        let formatted = format_error(&error);
422        assert!(formatted.contains(".toml"));
423        assert!(formatted.contains("Use .yaml instead."));
424    }
425
426    #[test]
427    fn test_format_config_yaml_parse_contains_location() {
428        let yaml_error = serde_yaml::from_str::<serde_yaml::Value>("invalid: [")
429            .err()
430            .unwrap();
431
432        let error: DynamicCliError = ConfigError::yaml_parse_with_location(yaml_error).into();
433        let formatted = format_error(&error);
434        assert!(formatted.contains("Error:"));
435    }
436
437    #[test]
438    fn test_format_config_invalid_schema_with_path_and_suggestion() {
439        let error: DynamicCliError = ConfigError::InvalidSchema {
440            reason: "missing field".to_string(),
441            path: Some("commands[0]".to_string()),
442            suggestion: Some("Add a name field.".to_string()),
443        }
444        .into();
445
446        let formatted = format_error(&error);
447        assert!(formatted.contains("missing field"));
448        assert!(formatted.contains("commands[0]"));
449        assert!(formatted.contains("Add a name field."));
450    }
451
452    // ── format_error — Parse ─────────────────────────────────
453
454    #[test]
455    fn test_format_parse_unknown_command_with_suggestions() {
456        let error: DynamicCliError = ParseError::UnknownCommand {
457            command: "simulat".to_string(),
458            suggestions: vec!["simulate".to_string(), "validation".to_string()],
459        }
460        .into();
461
462        let formatted = format_error(&error);
463        assert!(formatted.contains("Unknown command"));
464        assert!(formatted.contains("simulat"));
465        assert!(formatted.contains("Did you mean"));
466        assert!(formatted.contains("simulate"));
467    }
468
469    #[test]
470    fn test_format_parse_unknown_command_no_suggestions() {
471        let error: DynamicCliError = ParseError::UnknownCommand {
472            command: "xyz".to_string(),
473            suggestions: vec![],
474        }
475        .into();
476
477        let formatted = format_error(&error);
478        assert!(formatted.contains("xyz"));
479        assert!(!formatted.contains("Did you mean"));
480    }
481
482    #[test]
483    fn test_format_parse_missing_argument_with_suggestion() {
484        let error: DynamicCliError = ParseError::MissingArgument {
485            argument: "file".to_string(),
486            command: "process".to_string(),
487            suggestion: Some("Run --help process to see required arguments.".to_string()),
488        }
489        .into();
490
491        let formatted = format_error(&error);
492        assert!(formatted.contains("file"));
493        assert!(formatted.contains("Run --help process"));
494    }
495
496    #[test]
497    fn test_format_parse_missing_option_with_suggestion() {
498        let error: DynamicCliError = ParseError::MissingOption {
499            option: "output".to_string(),
500            command: "export".to_string(),
501            suggestion: Some("Run --help export to see required options.".to_string()),
502        }
503        .into();
504
505        let formatted = format_error(&error);
506        assert!(formatted.contains("output"));
507        assert!(formatted.contains("Run --help export"));
508    }
509
510    #[test]
511    fn test_format_parse_too_many_arguments_with_suggestion() {
512        let error: DynamicCliError = ParseError::TooManyArguments {
513            command: "run".to_string(),
514            expected: 1,
515            got: 3,
516            suggestion: Some("Run --help run for the expected usage.".to_string()),
517        }
518        .into();
519
520        let formatted = format_error(&error);
521        assert!(formatted.contains("run"));
522        assert!(formatted.contains("Run --help run"));
523    }
524
525    // ── format_error — Parse (DD-024 repeatable options, #37) ───────────
526
527    #[test]
528    fn test_format_parse_unknown_option_parameter_with_suggestion() {
529        let error: DynamicCliError = ParseError::UnknownOptionParameter {
530            option: "output".to_string(),
531            discriminant: "csv".to_string(),
532            key: "compression".to_string(),
533            valid_keys: vec!["file".to_string(), "resolution".to_string()],
534            suggestion: Some("Run --help export to see valid keys for --output csv.".to_string()),
535        }
536        .into();
537
538        let formatted = format_error(&error);
539        assert!(formatted.contains("compression"));
540        assert!(formatted.contains("csv"));
541        assert!(formatted.contains("file"));
542        assert!(formatted.contains("resolution"));
543        assert!(formatted.contains("Run --help export"));
544    }
545
546    #[test]
547    fn test_format_parse_missing_required_option_parameter_with_suggestion() {
548        let error: DynamicCliError = ParseError::MissingRequiredOptionParameter {
549            option: "output".to_string(),
550            discriminant: "csv".to_string(),
551            key: "file".to_string(),
552            suggestion: Some(
553                "Run --help export to see required keys for --output csv.".to_string(),
554            ),
555        }
556        .into();
557
558        let formatted = format_error(&error);
559        assert!(formatted.contains("file"));
560        assert!(formatted.contains("csv"));
561        assert!(formatted.contains("Run --help export"));
562    }
563
564    #[test]
565    fn test_format_parse_unknown_discriminant_with_suggestion() {
566        let error: DynamicCliError = ParseError::UnknownDiscriminant {
567            option: "output".to_string(),
568            value: "xml".to_string(),
569            valid_choices: vec!["csv".to_string(), "plot".to_string()],
570            suggestion: Some("Run --help export to see valid --output kinds.".to_string()),
571        }
572        .into();
573
574        let formatted = format_error(&error);
575        assert!(formatted.contains("xml"));
576        assert!(formatted.contains("csv"));
577        assert!(formatted.contains("plot"));
578        assert!(formatted.contains("Run --help export"));
579    }
580
581    #[test]
582    fn test_format_parse_duplicate_option_occurrence_with_suggestion() {
583        let error: DynamicCliError = ParseError::DuplicateOptionOccurrence {
584            option: "output".to_string(),
585            discriminant: "csv".to_string(),
586            params: vec![("file".to_string(), "results.csv".to_string())],
587            suggestion: Some(
588                "Remove one of the two identical --output csv occurrences.".to_string(),
589            ),
590        }
591        .into();
592
593        let formatted = format_error(&error);
594        assert!(formatted.contains("results.csv"));
595        assert!(formatted.contains("csv"));
596        assert!(formatted.contains("Remove one of the two identical"));
597    }
598
599    #[test]
600    fn test_debug_new_parse_error_variants_dd024() {
601        // Debug is derived; a smoke test is enough to guard against a
602        // silent #[derive(Debug)] removal on ParseError.
603        let variants: Vec<ParseError> = vec![
604            ParseError::UnknownOptionParameter {
605                option: "output".to_string(),
606                discriminant: "csv".to_string(),
607                key: "compression".to_string(),
608                valid_keys: vec!["file".to_string()],
609                suggestion: None,
610            },
611            ParseError::MissingRequiredOptionParameter {
612                option: "output".to_string(),
613                discriminant: "csv".to_string(),
614                key: "file".to_string(),
615                suggestion: None,
616            },
617            ParseError::UnknownDiscriminant {
618                option: "output".to_string(),
619                value: "xml".to_string(),
620                valid_choices: vec!["csv".to_string()],
621                suggestion: None,
622            },
623            ParseError::DuplicateOptionOccurrence {
624                option: "output".to_string(),
625                discriminant: "csv".to_string(),
626                params: vec![("file".to_string(), "results.csv".to_string())],
627                suggestion: None,
628            },
629        ];
630
631        for variant in variants {
632            let debug_str = format!("{:?}", variant);
633            assert!(!debug_str.is_empty());
634        }
635    }
636
637    #[test]
638    fn test_format_parse_type_parse_error_shows_info_block() {
639        let error: DynamicCliError = ParseError::TypeParseError {
640            arg_name: "count".to_string(),
641            expected_type: "integer".to_string(),
642            value: "abc".to_string(),
643            details: None,
644        }
645        .into();
646
647        let formatted = format_error(&error);
648        assert!(formatted.contains("integer"));
649        assert!(formatted.contains("count"));
650        assert!(formatted.contains("abc"));
651    }
652
653    // ── format_error — Validation ────────────────────────────
654
655    #[test]
656    fn test_format_validation_file_not_found_with_suggestion() {
657        let error: DynamicCliError = ValidationError::FileNotFound {
658            path: PathBuf::from("data.csv"),
659            arg_name: "input".to_string(),
660            suggestion: Some("Check that the file exists.".to_string()),
661        }
662        .into();
663
664        let formatted = format_error(&error);
665        assert!(formatted.contains("data.csv"));
666        assert!(formatted.contains("Check that the file exists."));
667    }
668
669    #[test]
670    fn test_format_validation_out_of_range_with_suggestion() {
671        let error: DynamicCliError = ValidationError::OutOfRange {
672            arg_name: "percentage".to_string(),
673            value: 150.0,
674            min: 0.0,
675            max: 100.0,
676            suggestion: Some("Value must be between 0 and 100.".to_string()),
677        }
678        .into();
679
680        let formatted = format_error(&error);
681        assert!(formatted.contains("percentage"));
682        assert!(formatted.contains("Value must be between 0 and 100."));
683    }
684
685    #[test]
686    fn test_format_validation_mutually_exclusive_with_suggestion() {
687        let error: DynamicCliError = ValidationError::MutuallyExclusive {
688            arg1: "--verbose".to_string(),
689            arg2: "--quiet".to_string(),
690            suggestion: Some("Remove one of the two conflicting options.".to_string()),
691        }
692        .into();
693
694        let formatted = format_error(&error);
695        assert!(formatted.contains("--verbose"));
696        assert!(formatted.contains("Remove one of the two conflicting options."));
697    }
698
699    #[test]
700    fn test_format_validation_missing_dependency_with_suggestion() {
701        let error: DynamicCliError = ValidationError::MissingDependency {
702            arg_name: "format".to_string(),
703            required_arg: "output".to_string(),
704            suggestion: Some("Add --output to your command.".to_string()),
705        }
706        .into();
707
708        let formatted = format_error(&error);
709        assert!(formatted.contains("format"));
710        assert!(formatted.contains("Add --output to your command."));
711    }
712
713    #[test]
714    fn test_format_validation_invalid_extension_no_suggestion_line() {
715        // InvalidExtension has no suggestion field; the message itself lists extensions
716        let error: DynamicCliError = ValidationError::InvalidExtension {
717            arg_name: "input".to_string(),
718            path: PathBuf::from("data.png"),
719            expected: vec![".csv".to_string(), ".tsv".to_string()],
720        }
721        .into();
722
723        let formatted = format_error(&error);
724        assert!(formatted.contains("data.png"));
725        assert!(!formatted.contains('ℹ'));
726    }
727
728    // ── format_error — Execution ─────────────────────────────
729
730    #[test]
731    fn test_format_execution_handler_not_found_with_suggestion() {
732        let error: DynamicCliError = ExecutionError::HandlerNotFound {
733            command: "run".to_string(),
734            implementation: "run_handler".to_string(),
735            suggestion: Some(
736                "Ensure .register_sync_handler(\"run_handler\", ...) was called.".to_string(),
737            ),
738        }
739        .into();
740
741        let formatted = format_error(&error);
742        assert!(formatted.contains("run"));
743        assert!(formatted.contains("run_handler"));
744        assert!(formatted.contains("register_sync_handler"));
745    }
746
747    #[test]
748    fn test_format_execution_context_downcast_failed_with_suggestion() {
749        let error: DynamicCliError = ExecutionError::ContextDowncastFailed {
750            expected_type: "MyCtx".to_string(),
751            suggestion: Some("Check the context type.".to_string()),
752        }
753        .into();
754
755        let formatted = format_error(&error);
756        assert!(formatted.contains("MyCtx"));
757        assert!(formatted.contains("Check the context type."));
758    }
759
760    #[test]
761    fn test_format_execution_interrupted_no_suggestion() {
762        let error: DynamicCliError = ExecutionError::Interrupted.into();
763        let formatted = format_error(&error);
764        assert!(formatted.contains("interrupted"));
765        assert!(!formatted.contains('ℹ'));
766    }
767
768    // ── format_error — Registry ──────────────────────────────
769
770    #[test]
771    fn test_format_registry_missing_handler_with_suggestion() {
772        let error: DynamicCliError = RegistryError::MissingHandler {
773            command: "export".to_string(),
774            suggestion: Some(
775                "Call .register_sync_handler(\"export\", ...) before running.".to_string(),
776            ),
777        }
778        .into();
779
780        let formatted = format_error(&error);
781        assert!(formatted.contains("export"));
782        assert!(formatted.contains("register_sync_handler"));
783    }
784
785    #[test]
786    fn test_format_registry_duplicate_registration_with_suggestion() {
787        let error: DynamicCliError = RegistryError::DuplicateRegistration {
788            name: "run".to_string(),
789            suggestion: Some("Command names must be unique.".to_string()),
790        }
791        .into();
792
793        let formatted = format_error(&error);
794        assert!(formatted.contains("run"));
795        assert!(formatted.contains("Command names must be unique."));
796    }
797
798    #[test]
799    fn test_format_registry_duplicate_alias_with_suggestion() {
800        let error: DynamicCliError = RegistryError::DuplicateAlias {
801            alias: "r".to_string(),
802            existing_command: "run".to_string(),
803            suggestion: Some("Choose a different alias.".to_string()),
804        }
805        .into();
806
807        let formatted = format_error(&error);
808        assert!(formatted.contains("run"));
809        assert!(formatted.contains("Choose a different alias."));
810    }
811
812    #[test]
813    fn test_format_registry_fault_tolerance_mismatch_with_suggestion() {
814        let error: DynamicCliError =
815            RegistryError::fault_tolerance_mismatch("solve", false, true).into();
816
817        let formatted = format_error(&error);
818        assert!(formatted.contains("solve"));
819        assert!(formatted.contains("continue_on_failure: false"));
820    }
821
822    // ── WasmError ────────────────────────────────────────────
823
824    #[cfg(feature = "wasm-plugins")]
825    #[test]
826    fn test_format_wasm_function_not_found_with_suggestion() {
827        let error: DynamicCliError = WasmError::FunctionNotFound {
828            function: "dcli_dealloc".to_string(),
829            module: "plugin.wasm".to_string(),
830            suggestion: Some("Export `dcli_dealloc(ptr: i32, size: i32)`.".to_string()),
831        }
832        .into();
833
834        let formatted = format_error(&error);
835        assert!(formatted.contains("dcli_dealloc"));
836        assert!(formatted.contains("Export `dcli_dealloc"));
837    }
838
839    #[cfg(feature = "wasm-plugins")]
840    #[test]
841    fn test_format_wasm_guest_error_without_suggestion_line() {
842        let error: DynamicCliError = WasmError::GuestError {
843            code: 1,
844            message: Some("invalid argument".to_string()),
845        }
846        .into();
847
848        let formatted = format_error(&error);
849        assert!(formatted.contains("invalid argument"));
850        // GuestError carries no structured suggestion — no "ℹ" line expected
851        assert!(!formatted.contains('ℹ'));
852    }
853
854    // ── display_error ────────────────────────────────────────
855
856    #[test]
857    fn test_display_error_does_not_panic() {
858        let error: DynamicCliError = ConfigError::FileNotFound {
859            path: PathBuf::from("test.yaml"),
860            suggestion: None,
861        }
862        .into();
863        // Writes to stderr — must not panic
864        display_error(&error);
865    }
866}