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    };
319
320    append_suggestion(output, suggestion);
321}
322
323/// Format a WASM plugin error with its actionable suggestion
324///
325/// Only available when the `wasm-plugins` feature is enabled.
326///
327/// `GuestError` and `SerializationFailed` and `MemoryAccessFailed` carry no
328/// structured `suggestion` field — they surface only their `Display` message.
329#[cfg(feature = "wasm-plugins")]
330fn format_wasm_error(output: &mut String, error: &WasmError) {
331    output.push_str(&format!("{}\n", error));
332
333    let suggestion = match error {
334        WasmError::LoadFailed { suggestion, .. } => suggestion.as_deref(),
335        WasmError::FunctionNotFound { suggestion, .. } => suggestion.as_deref(),
336        WasmError::GuestError { .. } => None,
337        WasmError::SerializationFailed(_) => None,
338        WasmError::MemoryAccessFailed { .. } => None,
339    };
340
341    append_suggestion(output, suggestion);
342}
343
344// ═══════════════════════════════════════════════════════════
345// SHARED HELPER
346// ═══════════════════════════════════════════════════════════
347
348/// Append a suggestion line to the output buffer
349///
350/// Renders the line only when `suggestion` is `Some`. The format is:
351///
352/// ```text
353///   ℹ  <suggestion text>
354/// ```
355///
356/// When `suggestion` is `None`, this is a no-op.
357fn append_suggestion(output: &mut String, suggestion: Option<&str>) {
358    if let Some(s) = suggestion {
359        output.push_str(&format!("  {} {}\n", color_info("ℹ"), s));
360    }
361}
362
363// ═══════════════════════════════════════════════════════════
364// TESTS
365// ═══════════════════════════════════════════════════════════
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use std::path::PathBuf;
371
372    // ── format_error — Config ────────────────────────────────
373
374    #[test]
375    fn test_format_config_file_not_found_contains_path() {
376        let error: DynamicCliError = ConfigError::FileNotFound {
377            path: PathBuf::from("test.yaml"),
378            suggestion: None,
379        }
380        .into();
381
382        let formatted = format_error(&error);
383        assert!(formatted.contains("Error:"));
384        assert!(formatted.contains("test.yaml"));
385    }
386
387    #[test]
388    fn test_format_config_file_not_found_with_suggestion() {
389        let error: DynamicCliError = ConfigError::FileNotFound {
390            path: PathBuf::from("test.yaml"),
391            suggestion: Some("Verify the path.".to_string()),
392        }
393        .into();
394
395        let formatted = format_error(&error);
396        assert!(formatted.contains("Verify the path."));
397    }
398
399    #[test]
400    fn test_format_config_file_not_found_no_suggestion_no_hint_line() {
401        let error: DynamicCliError = ConfigError::FileNotFound {
402            path: PathBuf::from("test.yaml"),
403            suggestion: None,
404        }
405        .into();
406
407        let formatted = format_error(&error);
408        // The ℹ line must not appear when suggestion is None
409        assert!(!formatted.contains('ℹ'));
410    }
411
412    #[test]
413    fn test_format_config_unsupported_format_with_suggestion() {
414        let error: DynamicCliError = ConfigError::UnsupportedFormat {
415            extension: ".toml".to_string(),
416            suggestion: Some("Use .yaml instead.".to_string()),
417        }
418        .into();
419
420        let formatted = format_error(&error);
421        assert!(formatted.contains(".toml"));
422        assert!(formatted.contains("Use .yaml instead."));
423    }
424
425    #[test]
426    fn test_format_config_yaml_parse_contains_location() {
427        let yaml_error = serde_yaml::from_str::<serde_yaml::Value>("invalid: [")
428            .err()
429            .unwrap();
430
431        let error: DynamicCliError = ConfigError::yaml_parse_with_location(yaml_error).into();
432        let formatted = format_error(&error);
433        assert!(formatted.contains("Error:"));
434    }
435
436    #[test]
437    fn test_format_config_invalid_schema_with_path_and_suggestion() {
438        let error: DynamicCliError = ConfigError::InvalidSchema {
439            reason: "missing field".to_string(),
440            path: Some("commands[0]".to_string()),
441            suggestion: Some("Add a name field.".to_string()),
442        }
443        .into();
444
445        let formatted = format_error(&error);
446        assert!(formatted.contains("missing field"));
447        assert!(formatted.contains("commands[0]"));
448        assert!(formatted.contains("Add a name field."));
449    }
450
451    // ── format_error — Parse ─────────────────────────────────
452
453    #[test]
454    fn test_format_parse_unknown_command_with_suggestions() {
455        let error: DynamicCliError = ParseError::UnknownCommand {
456            command: "simulat".to_string(),
457            suggestions: vec!["simulate".to_string(), "validation".to_string()],
458        }
459        .into();
460
461        let formatted = format_error(&error);
462        assert!(formatted.contains("Unknown command"));
463        assert!(formatted.contains("simulat"));
464        assert!(formatted.contains("Did you mean"));
465        assert!(formatted.contains("simulate"));
466    }
467
468    #[test]
469    fn test_format_parse_unknown_command_no_suggestions() {
470        let error: DynamicCliError = ParseError::UnknownCommand {
471            command: "xyz".to_string(),
472            suggestions: vec![],
473        }
474        .into();
475
476        let formatted = format_error(&error);
477        assert!(formatted.contains("xyz"));
478        assert!(!formatted.contains("Did you mean"));
479    }
480
481    #[test]
482    fn test_format_parse_missing_argument_with_suggestion() {
483        let error: DynamicCliError = ParseError::MissingArgument {
484            argument: "file".to_string(),
485            command: "process".to_string(),
486            suggestion: Some("Run --help process to see required arguments.".to_string()),
487        }
488        .into();
489
490        let formatted = format_error(&error);
491        assert!(formatted.contains("file"));
492        assert!(formatted.contains("Run --help process"));
493    }
494
495    #[test]
496    fn test_format_parse_missing_option_with_suggestion() {
497        let error: DynamicCliError = ParseError::MissingOption {
498            option: "output".to_string(),
499            command: "export".to_string(),
500            suggestion: Some("Run --help export to see required options.".to_string()),
501        }
502        .into();
503
504        let formatted = format_error(&error);
505        assert!(formatted.contains("output"));
506        assert!(formatted.contains("Run --help export"));
507    }
508
509    #[test]
510    fn test_format_parse_too_many_arguments_with_suggestion() {
511        let error: DynamicCliError = ParseError::TooManyArguments {
512            command: "run".to_string(),
513            expected: 1,
514            got: 3,
515            suggestion: Some("Run --help run for the expected usage.".to_string()),
516        }
517        .into();
518
519        let formatted = format_error(&error);
520        assert!(formatted.contains("run"));
521        assert!(formatted.contains("Run --help run"));
522    }
523
524    // ── format_error — Parse (DD-024 repeatable options, #37) ───────────
525
526    #[test]
527    fn test_format_parse_unknown_option_parameter_with_suggestion() {
528        let error: DynamicCliError = ParseError::UnknownOptionParameter {
529            option: "output".to_string(),
530            discriminant: "csv".to_string(),
531            key: "compression".to_string(),
532            valid_keys: vec!["file".to_string(), "resolution".to_string()],
533            suggestion: Some("Run --help export to see valid keys for --output csv.".to_string()),
534        }
535        .into();
536
537        let formatted = format_error(&error);
538        assert!(formatted.contains("compression"));
539        assert!(formatted.contains("csv"));
540        assert!(formatted.contains("file"));
541        assert!(formatted.contains("resolution"));
542        assert!(formatted.contains("Run --help export"));
543    }
544
545    #[test]
546    fn test_format_parse_missing_required_option_parameter_with_suggestion() {
547        let error: DynamicCliError = ParseError::MissingRequiredOptionParameter {
548            option: "output".to_string(),
549            discriminant: "csv".to_string(),
550            key: "file".to_string(),
551            suggestion: Some(
552                "Run --help export to see required keys for --output csv.".to_string(),
553            ),
554        }
555        .into();
556
557        let formatted = format_error(&error);
558        assert!(formatted.contains("file"));
559        assert!(formatted.contains("csv"));
560        assert!(formatted.contains("Run --help export"));
561    }
562
563    #[test]
564    fn test_format_parse_unknown_discriminant_with_suggestion() {
565        let error: DynamicCliError = ParseError::UnknownDiscriminant {
566            option: "output".to_string(),
567            value: "xml".to_string(),
568            valid_choices: vec!["csv".to_string(), "plot".to_string()],
569            suggestion: Some("Run --help export to see valid --output kinds.".to_string()),
570        }
571        .into();
572
573        let formatted = format_error(&error);
574        assert!(formatted.contains("xml"));
575        assert!(formatted.contains("csv"));
576        assert!(formatted.contains("plot"));
577        assert!(formatted.contains("Run --help export"));
578    }
579
580    #[test]
581    fn test_format_parse_duplicate_option_occurrence_with_suggestion() {
582        let error: DynamicCliError = ParseError::DuplicateOptionOccurrence {
583            option: "output".to_string(),
584            discriminant: "csv".to_string(),
585            params: vec![("file".to_string(), "results.csv".to_string())],
586            suggestion: Some(
587                "Remove one of the two identical --output csv occurrences.".to_string(),
588            ),
589        }
590        .into();
591
592        let formatted = format_error(&error);
593        assert!(formatted.contains("results.csv"));
594        assert!(formatted.contains("csv"));
595        assert!(formatted.contains("Remove one of the two identical"));
596    }
597
598    #[test]
599    fn test_debug_new_parse_error_variants_dd024() {
600        // Debug is derived; a smoke test is enough to guard against a
601        // silent #[derive(Debug)] removal on ParseError.
602        let variants: Vec<ParseError> = vec![
603            ParseError::UnknownOptionParameter {
604                option: "output".to_string(),
605                discriminant: "csv".to_string(),
606                key: "compression".to_string(),
607                valid_keys: vec!["file".to_string()],
608                suggestion: None,
609            },
610            ParseError::MissingRequiredOptionParameter {
611                option: "output".to_string(),
612                discriminant: "csv".to_string(),
613                key: "file".to_string(),
614                suggestion: None,
615            },
616            ParseError::UnknownDiscriminant {
617                option: "output".to_string(),
618                value: "xml".to_string(),
619                valid_choices: vec!["csv".to_string()],
620                suggestion: None,
621            },
622            ParseError::DuplicateOptionOccurrence {
623                option: "output".to_string(),
624                discriminant: "csv".to_string(),
625                params: vec![("file".to_string(), "results.csv".to_string())],
626                suggestion: None,
627            },
628        ];
629
630        for variant in variants {
631            let debug_str = format!("{:?}", variant);
632            assert!(!debug_str.is_empty());
633        }
634    }
635
636    #[test]
637    fn test_format_parse_type_parse_error_shows_info_block() {
638        let error: DynamicCliError = ParseError::TypeParseError {
639            arg_name: "count".to_string(),
640            expected_type: "integer".to_string(),
641            value: "abc".to_string(),
642            details: None,
643        }
644        .into();
645
646        let formatted = format_error(&error);
647        assert!(formatted.contains("integer"));
648        assert!(formatted.contains("count"));
649        assert!(formatted.contains("abc"));
650    }
651
652    // ── format_error — Validation ────────────────────────────
653
654    #[test]
655    fn test_format_validation_file_not_found_with_suggestion() {
656        let error: DynamicCliError = ValidationError::FileNotFound {
657            path: PathBuf::from("data.csv"),
658            arg_name: "input".to_string(),
659            suggestion: Some("Check that the file exists.".to_string()),
660        }
661        .into();
662
663        let formatted = format_error(&error);
664        assert!(formatted.contains("data.csv"));
665        assert!(formatted.contains("Check that the file exists."));
666    }
667
668    #[test]
669    fn test_format_validation_out_of_range_with_suggestion() {
670        let error: DynamicCliError = ValidationError::OutOfRange {
671            arg_name: "percentage".to_string(),
672            value: 150.0,
673            min: 0.0,
674            max: 100.0,
675            suggestion: Some("Value must be between 0 and 100.".to_string()),
676        }
677        .into();
678
679        let formatted = format_error(&error);
680        assert!(formatted.contains("percentage"));
681        assert!(formatted.contains("Value must be between 0 and 100."));
682    }
683
684    #[test]
685    fn test_format_validation_mutually_exclusive_with_suggestion() {
686        let error: DynamicCliError = ValidationError::MutuallyExclusive {
687            arg1: "--verbose".to_string(),
688            arg2: "--quiet".to_string(),
689            suggestion: Some("Remove one of the two conflicting options.".to_string()),
690        }
691        .into();
692
693        let formatted = format_error(&error);
694        assert!(formatted.contains("--verbose"));
695        assert!(formatted.contains("Remove one of the two conflicting options."));
696    }
697
698    #[test]
699    fn test_format_validation_missing_dependency_with_suggestion() {
700        let error: DynamicCliError = ValidationError::MissingDependency {
701            arg_name: "format".to_string(),
702            required_arg: "output".to_string(),
703            suggestion: Some("Add --output to your command.".to_string()),
704        }
705        .into();
706
707        let formatted = format_error(&error);
708        assert!(formatted.contains("format"));
709        assert!(formatted.contains("Add --output to your command."));
710    }
711
712    #[test]
713    fn test_format_validation_invalid_extension_no_suggestion_line() {
714        // InvalidExtension has no suggestion field; the message itself lists extensions
715        let error: DynamicCliError = ValidationError::InvalidExtension {
716            arg_name: "input".to_string(),
717            path: PathBuf::from("data.png"),
718            expected: vec![".csv".to_string(), ".tsv".to_string()],
719        }
720        .into();
721
722        let formatted = format_error(&error);
723        assert!(formatted.contains("data.png"));
724        assert!(!formatted.contains('ℹ'));
725    }
726
727    // ── format_error — Execution ─────────────────────────────
728
729    #[test]
730    fn test_format_execution_handler_not_found_with_suggestion() {
731        let error: DynamicCliError = ExecutionError::HandlerNotFound {
732            command: "run".to_string(),
733            implementation: "run_handler".to_string(),
734            suggestion: Some(
735                "Ensure .register_sync_handler(\"run_handler\", ...) was called.".to_string(),
736            ),
737        }
738        .into();
739
740        let formatted = format_error(&error);
741        assert!(formatted.contains("run"));
742        assert!(formatted.contains("run_handler"));
743        assert!(formatted.contains("register_sync_handler"));
744    }
745
746    #[test]
747    fn test_format_execution_context_downcast_failed_with_suggestion() {
748        let error: DynamicCliError = ExecutionError::ContextDowncastFailed {
749            expected_type: "MyCtx".to_string(),
750            suggestion: Some("Check the context type.".to_string()),
751        }
752        .into();
753
754        let formatted = format_error(&error);
755        assert!(formatted.contains("MyCtx"));
756        assert!(formatted.contains("Check the context type."));
757    }
758
759    #[test]
760    fn test_format_execution_interrupted_no_suggestion() {
761        let error: DynamicCliError = ExecutionError::Interrupted.into();
762        let formatted = format_error(&error);
763        assert!(formatted.contains("interrupted"));
764        assert!(!formatted.contains('ℹ'));
765    }
766
767    // ── format_error — Registry ──────────────────────────────
768
769    #[test]
770    fn test_format_registry_missing_handler_with_suggestion() {
771        let error: DynamicCliError = RegistryError::MissingHandler {
772            command: "export".to_string(),
773            suggestion: Some(
774                "Call .register_sync_handler(\"export\", ...) before running.".to_string(),
775            ),
776        }
777        .into();
778
779        let formatted = format_error(&error);
780        assert!(formatted.contains("export"));
781        assert!(formatted.contains("register_sync_handler"));
782    }
783
784    #[test]
785    fn test_format_registry_duplicate_registration_with_suggestion() {
786        let error: DynamicCliError = RegistryError::DuplicateRegistration {
787            name: "run".to_string(),
788            suggestion: Some("Command names must be unique.".to_string()),
789        }
790        .into();
791
792        let formatted = format_error(&error);
793        assert!(formatted.contains("run"));
794        assert!(formatted.contains("Command names must be unique."));
795    }
796
797    #[test]
798    fn test_format_registry_duplicate_alias_with_suggestion() {
799        let error: DynamicCliError = RegistryError::DuplicateAlias {
800            alias: "r".to_string(),
801            existing_command: "run".to_string(),
802            suggestion: Some("Choose a different alias.".to_string()),
803        }
804        .into();
805
806        let formatted = format_error(&error);
807        assert!(formatted.contains("run"));
808        assert!(formatted.contains("Choose a different alias."));
809    }
810
811    // ── WasmError ────────────────────────────────────────────
812
813    #[cfg(feature = "wasm-plugins")]
814    #[test]
815    fn test_format_wasm_function_not_found_with_suggestion() {
816        let error: DynamicCliError = WasmError::FunctionNotFound {
817            function: "dcli_dealloc".to_string(),
818            module: "plugin.wasm".to_string(),
819            suggestion: Some("Export `dcli_dealloc(ptr: i32, size: i32)`.".to_string()),
820        }
821        .into();
822
823        let formatted = format_error(&error);
824        assert!(formatted.contains("dcli_dealloc"));
825        assert!(formatted.contains("Export `dcli_dealloc"));
826    }
827
828    #[cfg(feature = "wasm-plugins")]
829    #[test]
830    fn test_format_wasm_guest_error_without_suggestion_line() {
831        let error: DynamicCliError = WasmError::GuestError {
832            code: 1,
833            message: Some("invalid argument".to_string()),
834        }
835        .into();
836
837        let formatted = format_error(&error);
838        assert!(formatted.contains("invalid argument"));
839        // GuestError carries no structured suggestion — no "ℹ" line expected
840        assert!(!formatted.contains('ℹ'));
841    }
842
843    // ── display_error ────────────────────────────────────────
844
845    #[test]
846    fn test_display_error_does_not_panic() {
847        let error: DynamicCliError = ConfigError::FileNotFound {
848            path: PathBuf::from("test.yaml"),
849            suggestion: None,
850        }
851        .into();
852        // Writes to stderr — must not panic
853        display_error(&error);
854    }
855}