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