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
29use crate::error::{
30    ConfigError, DynamicCliError, ExecutionError, ParseError, RegistryError, ValidationError,
31};
32
33// ═══════════════════════════════════════════════════════════
34// COLOR PALETTE  (mirrors DefaultHelpFormatter)
35// ═══════════════════════════════════════════════════════════
36
37/// Render text as a bold red error label (used for "Error:")
38fn color_error(s: &str) -> String {
39    s.red().bold().to_string()
40}
41
42/// Render a question mark prompt (used before "Did you mean:")
43fn color_question(s: &str) -> String {
44    s.yellow().bold().to_string()
45}
46
47/// Render a bullet point character
48fn color_bullet(s: &str) -> String {
49    s.cyan().to_string()
50}
51
52/// Render a Levenshtein suggestion (command / option name)
53fn color_suggestion(s: &str) -> String {
54    s.green().to_string()
55}
56
57/// Render an info symbol (used before the actionable suggestion line)
58fn color_info(s: &str) -> String {
59    s.blue().bold().to_string()
60}
61
62/// Render a type name or path
63fn color_type_name(s: &str) -> String {
64    s.cyan().to_string()
65}
66
67/// Render an argument or option name
68fn color_arg_name(s: &str) -> String {
69    s.yellow().to_string()
70}
71
72/// Render an invalid value
73fn color_value(s: &str) -> String {
74    s.red().to_string()
75}
76
77/// Render dimmed secondary text (e.g., "at", "in")
78fn color_dimmed(s: &str) -> String {
79    s.dimmed().to_string()
80}
81
82// ═══════════════════════════════════════════════════════════
83// PUBLIC API
84// ═══════════════════════════════════════════════════════════
85
86/// Print an error to stderr in a user-friendly way
87///
88/// Writes the formatted error (with ANSI colors) to stderr.
89///
90/// # Example
91///
92/// ```no_run
93/// use dynamic_cli::error::{display_error, ParseError};
94///
95/// let error = ParseError::UnknownCommand {
96///     command: "simulat".to_string(),
97///     suggestions: vec!["simulate".to_string()],
98/// };
99/// display_error(&error.into());
100/// ```
101pub fn display_error(error: &DynamicCliError) {
102    eprintln!("{}", format_error(error));
103}
104
105/// Format an error as a colored, human-readable string
106///
107/// Generates a string suitable for display in the terminal.
108/// The format is:
109///
110/// ```text
111/// Error: <main message>
112///   ℹ  <actionable suggestion>
113/// ```
114///
115/// For parse errors with Levenshtein suggestions, a "Did you mean:" block
116/// is appended instead of the `ℹ` line.
117///
118/// # Arguments
119///
120/// * `error` - The error to format
121///
122/// # Example
123///
124/// ```
125/// use dynamic_cli::error::{format_error, ConfigError};
126/// use std::path::PathBuf;
127///
128/// let error: dynamic_cli::error::DynamicCliError = ConfigError::FileNotFound {
129///     path: PathBuf::from("config.yaml"),
130///     suggestion: Some("Verify the path and file permissions.".to_string()),
131/// }.into();
132///
133/// let formatted = format_error(&error);
134/// assert!(formatted.contains("Error:"));
135/// assert!(formatted.contains("config.yaml"));
136/// ```
137pub fn format_error(error: &DynamicCliError) -> String {
138    let mut output = String::new();
139
140    output.push_str(&format!("{} ", color_error("Error:")));
141
142    match error {
143        DynamicCliError::Parse(e) => format_parse_error(&mut output, e),
144        DynamicCliError::Config(e) => format_config_error(&mut output, e),
145        DynamicCliError::Validation(e) => format_validation_error(&mut output, e),
146        DynamicCliError::Execution(e) => format_execution_error(&mut output, e),
147        DynamicCliError::Registry(e) => format_registry_error(&mut output, e),
148        DynamicCliError::Io(e) => output.push_str(&format!("{}\n", e)),
149    }
150
151    output
152}
153
154// ═══════════════════════════════════════════════════════════
155// CATEGORY FORMATTERS
156// ═══════════════════════════════════════════════════════════
157
158/// Format a parse error, appending Levenshtein suggestions when available
159fn format_parse_error(output: &mut String, error: &ParseError) {
160    output.push_str(&format!("{}\n", error));
161
162    match error {
163        ParseError::UnknownCommand { suggestions, .. } if !suggestions.is_empty() => {
164            output.push_str(&format!("\n{} Did you mean:\n", color_question("?")));
165            for s in suggestions {
166                output.push_str(&format!(
167                    "  {} {}\n",
168                    color_bullet("•"),
169                    color_suggestion(s)
170                ));
171            }
172        }
173
174        ParseError::UnknownOption { suggestions, .. } 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
185        ParseError::TypeParseError {
186            arg_name,
187            expected_type,
188            value,
189            ..
190        } => {
191            output.push_str(&format!(
192                "\n{} Expected type {} for argument {}, got: {}\n",
193                color_info("ℹ"),
194                color_type_name(expected_type),
195                color_arg_name(arg_name),
196                color_value(value)
197            ));
198        }
199
200        ParseError::MissingArgument { suggestion, .. }
201        | ParseError::MissingOption { suggestion, .. }
202        | ParseError::TooManyArguments { suggestion, .. } => {
203            append_suggestion(output, suggestion.as_deref());
204        }
205
206        _ => {}
207    }
208}
209
210/// Format a configuration error, showing parse positions and suggestions
211fn format_config_error(output: &mut String, error: &ConfigError) {
212    match error {
213        ConfigError::YamlParse {
214            source,
215            line,
216            column,
217        } => {
218            output.push_str(&format!("{}\n", source));
219            if let (Some(l), Some(c)) = (line, column) {
220                output.push_str(&format!(
221                    "  {} line {}, column {}\n",
222                    color_dimmed("at"),
223                    color_arg_name(&l.to_string()),
224                    color_arg_name(&c.to_string())
225                ));
226            }
227        }
228
229        ConfigError::JsonParse {
230            source,
231            line,
232            column,
233        } => {
234            output.push_str(&format!("{}\n", source));
235            output.push_str(&format!(
236                "  {} line {}, column {}\n",
237                color_dimmed("at"),
238                color_arg_name(&line.to_string()),
239                color_arg_name(&column.to_string())
240            ));
241        }
242
243        ConfigError::InvalidSchema {
244            reason,
245            path,
246            suggestion,
247        } => {
248            output.push_str(&format!("{}\n", reason));
249            if let Some(p) = path {
250                output.push_str(&format!(
251                    "  {} {}\n",
252                    color_dimmed("in"),
253                    color_type_name(p)
254                ));
255            }
256            append_suggestion(output, suggestion.as_deref());
257        }
258
259        ConfigError::FileNotFound { suggestion, .. }
260        | ConfigError::UnsupportedFormat { suggestion, .. }
261        | ConfigError::DuplicateCommand { suggestion, .. }
262        | ConfigError::UnknownType { suggestion, .. }
263        | ConfigError::Inconsistency { suggestion, .. } => {
264            output.push_str(&format!("{}\n", error));
265            append_suggestion(output, suggestion.as_deref());
266        }
267    }
268}
269
270/// Format a validation error with its actionable suggestion
271fn format_validation_error(output: &mut String, error: &ValidationError) {
272    output.push_str(&format!("{}\n", error));
273
274    let suggestion = match error {
275        ValidationError::FileNotFound { suggestion, .. } => suggestion.as_deref(),
276        ValidationError::OutOfRange { suggestion, .. } => suggestion.as_deref(),
277        ValidationError::CustomConstraint { suggestion, .. } => suggestion.as_deref(),
278        ValidationError::MissingDependency { suggestion, .. } => suggestion.as_deref(),
279        ValidationError::MutuallyExclusive { suggestion, .. } => suggestion.as_deref(),
280        // InvalidExtension already lists the expected extensions in the message
281        ValidationError::InvalidExtension { .. } => None,
282    };
283
284    append_suggestion(output, suggestion);
285}
286
287/// Format an execution error with its actionable suggestion
288fn format_execution_error(output: &mut String, error: &ExecutionError) {
289    output.push_str(&format!("{}\n", error));
290
291    let suggestion = match error {
292        ExecutionError::HandlerNotFound { suggestion, .. } => suggestion.as_deref(),
293        ExecutionError::ContextDowncastFailed { suggestion, .. } => suggestion.as_deref(),
294        ExecutionError::InvalidContextState { suggestion, .. } => suggestion.as_deref(),
295        // CommandFailed and Interrupted carry no structured suggestion
296        ExecutionError::CommandFailed(_) | ExecutionError::Interrupted => None,
297    };
298
299    append_suggestion(output, suggestion);
300}
301
302/// Format a registry error with its actionable suggestion
303fn format_registry_error(output: &mut String, error: &RegistryError) {
304    output.push_str(&format!("{}\n", error));
305
306    let suggestion = match error {
307        RegistryError::DuplicateRegistration { suggestion, .. } => suggestion.as_deref(),
308        RegistryError::DuplicateAlias { suggestion, .. } => suggestion.as_deref(),
309        RegistryError::MissingHandler { suggestion, .. } => suggestion.as_deref(),
310    };
311
312    append_suggestion(output, suggestion);
313}
314
315// ═══════════════════════════════════════════════════════════
316// SHARED HELPER
317// ═══════════════════════════════════════════════════════════
318
319/// Append a suggestion line to the output buffer
320///
321/// Renders the line only when `suggestion` is `Some`. The format is:
322///
323/// ```text
324///   ℹ  <suggestion text>
325/// ```
326///
327/// When `suggestion` is `None`, this is a no-op.
328fn append_suggestion(output: &mut String, suggestion: Option<&str>) {
329    if let Some(s) = suggestion {
330        output.push_str(&format!("  {} {}\n", color_info("ℹ"), s));
331    }
332}
333
334// ═══════════════════════════════════════════════════════════
335// TESTS
336// ═══════════════════════════════════════════════════════════
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use std::path::PathBuf;
342
343    // ── format_error — Config ────────────────────────────────
344
345    #[test]
346    fn test_format_config_file_not_found_contains_path() {
347        let error: DynamicCliError = ConfigError::FileNotFound {
348            path: PathBuf::from("test.yaml"),
349            suggestion: None,
350        }
351        .into();
352
353        let formatted = format_error(&error);
354        assert!(formatted.contains("Error:"));
355        assert!(formatted.contains("test.yaml"));
356    }
357
358    #[test]
359    fn test_format_config_file_not_found_with_suggestion() {
360        let error: DynamicCliError = ConfigError::FileNotFound {
361            path: PathBuf::from("test.yaml"),
362            suggestion: Some("Verify the path.".to_string()),
363        }
364        .into();
365
366        let formatted = format_error(&error);
367        assert!(formatted.contains("Verify the path."));
368    }
369
370    #[test]
371    fn test_format_config_file_not_found_no_suggestion_no_hint_line() {
372        let error: DynamicCliError = ConfigError::FileNotFound {
373            path: PathBuf::from("test.yaml"),
374            suggestion: None,
375        }
376        .into();
377
378        let formatted = format_error(&error);
379        // The ℹ line must not appear when suggestion is None
380        assert!(!formatted.contains('ℹ'));
381    }
382
383    #[test]
384    fn test_format_config_unsupported_format_with_suggestion() {
385        let error: DynamicCliError = ConfigError::UnsupportedFormat {
386            extension: ".toml".to_string(),
387            suggestion: Some("Use .yaml instead.".to_string()),
388        }
389        .into();
390
391        let formatted = format_error(&error);
392        assert!(formatted.contains(".toml"));
393        assert!(formatted.contains("Use .yaml instead."));
394    }
395
396    #[test]
397    fn test_format_config_yaml_parse_contains_location() {
398        let yaml_error = serde_yaml::from_str::<serde_yaml::Value>("invalid: [")
399            .err()
400            .unwrap();
401
402        let error: DynamicCliError = ConfigError::yaml_parse_with_location(yaml_error).into();
403        let formatted = format_error(&error);
404        assert!(formatted.contains("Error:"));
405    }
406
407    #[test]
408    fn test_format_config_invalid_schema_with_path_and_suggestion() {
409        let error: DynamicCliError = ConfigError::InvalidSchema {
410            reason: "missing field".to_string(),
411            path: Some("commands[0]".to_string()),
412            suggestion: Some("Add a name field.".to_string()),
413        }
414        .into();
415
416        let formatted = format_error(&error);
417        assert!(formatted.contains("missing field"));
418        assert!(formatted.contains("commands[0]"));
419        assert!(formatted.contains("Add a name field."));
420    }
421
422    // ── format_error — Parse ─────────────────────────────────
423
424    #[test]
425    fn test_format_parse_unknown_command_with_suggestions() {
426        let error: DynamicCliError = ParseError::UnknownCommand {
427            command: "simulat".to_string(),
428            suggestions: vec!["simulate".to_string(), "validation".to_string()],
429        }
430        .into();
431
432        let formatted = format_error(&error);
433        assert!(formatted.contains("Unknown command"));
434        assert!(formatted.contains("simulat"));
435        assert!(formatted.contains("Did you mean"));
436        assert!(formatted.contains("simulate"));
437    }
438
439    #[test]
440    fn test_format_parse_unknown_command_no_suggestions() {
441        let error: DynamicCliError = ParseError::UnknownCommand {
442            command: "xyz".to_string(),
443            suggestions: vec![],
444        }
445        .into();
446
447        let formatted = format_error(&error);
448        assert!(formatted.contains("xyz"));
449        assert!(!formatted.contains("Did you mean"));
450    }
451
452    #[test]
453    fn test_format_parse_missing_argument_with_suggestion() {
454        let error: DynamicCliError = ParseError::MissingArgument {
455            argument: "file".to_string(),
456            command: "process".to_string(),
457            suggestion: Some("Run --help process to see required arguments.".to_string()),
458        }
459        .into();
460
461        let formatted = format_error(&error);
462        assert!(formatted.contains("file"));
463        assert!(formatted.contains("Run --help process"));
464    }
465
466    #[test]
467    fn test_format_parse_missing_option_with_suggestion() {
468        let error: DynamicCliError = ParseError::MissingOption {
469            option: "output".to_string(),
470            command: "export".to_string(),
471            suggestion: Some("Run --help export to see required options.".to_string()),
472        }
473        .into();
474
475        let formatted = format_error(&error);
476        assert!(formatted.contains("output"));
477        assert!(formatted.contains("Run --help export"));
478    }
479
480    #[test]
481    fn test_format_parse_too_many_arguments_with_suggestion() {
482        let error: DynamicCliError = ParseError::TooManyArguments {
483            command: "run".to_string(),
484            expected: 1,
485            got: 3,
486            suggestion: Some("Run --help run for the expected usage.".to_string()),
487        }
488        .into();
489
490        let formatted = format_error(&error);
491        assert!(formatted.contains("run"));
492        assert!(formatted.contains("Run --help run"));
493    }
494
495    #[test]
496    fn test_format_parse_type_parse_error_shows_info_block() {
497        let error: DynamicCliError = ParseError::TypeParseError {
498            arg_name: "count".to_string(),
499            expected_type: "integer".to_string(),
500            value: "abc".to_string(),
501            details: None,
502        }
503        .into();
504
505        let formatted = format_error(&error);
506        assert!(formatted.contains("integer"));
507        assert!(formatted.contains("count"));
508        assert!(formatted.contains("abc"));
509    }
510
511    // ── format_error — Validation ────────────────────────────
512
513    #[test]
514    fn test_format_validation_file_not_found_with_suggestion() {
515        let error: DynamicCliError = ValidationError::FileNotFound {
516            path: PathBuf::from("data.csv"),
517            arg_name: "input".to_string(),
518            suggestion: Some("Check that the file exists.".to_string()),
519        }
520        .into();
521
522        let formatted = format_error(&error);
523        assert!(formatted.contains("data.csv"));
524        assert!(formatted.contains("Check that the file exists."));
525    }
526
527    #[test]
528    fn test_format_validation_out_of_range_with_suggestion() {
529        let error: DynamicCliError = ValidationError::OutOfRange {
530            arg_name: "percentage".to_string(),
531            value: 150.0,
532            min: 0.0,
533            max: 100.0,
534            suggestion: Some("Value must be between 0 and 100.".to_string()),
535        }
536        .into();
537
538        let formatted = format_error(&error);
539        assert!(formatted.contains("percentage"));
540        assert!(formatted.contains("Value must be between 0 and 100."));
541    }
542
543    #[test]
544    fn test_format_validation_mutually_exclusive_with_suggestion() {
545        let error: DynamicCliError = ValidationError::MutuallyExclusive {
546            arg1: "--verbose".to_string(),
547            arg2: "--quiet".to_string(),
548            suggestion: Some("Remove one of the two conflicting options.".to_string()),
549        }
550        .into();
551
552        let formatted = format_error(&error);
553        assert!(formatted.contains("--verbose"));
554        assert!(formatted.contains("Remove one of the two conflicting options."));
555    }
556
557    #[test]
558    fn test_format_validation_missing_dependency_with_suggestion() {
559        let error: DynamicCliError = ValidationError::MissingDependency {
560            arg_name: "format".to_string(),
561            required_arg: "output".to_string(),
562            suggestion: Some("Add --output to your command.".to_string()),
563        }
564        .into();
565
566        let formatted = format_error(&error);
567        assert!(formatted.contains("format"));
568        assert!(formatted.contains("Add --output to your command."));
569    }
570
571    #[test]
572    fn test_format_validation_invalid_extension_no_suggestion_line() {
573        // InvalidExtension has no suggestion field; the message itself lists extensions
574        let error: DynamicCliError = ValidationError::InvalidExtension {
575            arg_name: "input".to_string(),
576            path: PathBuf::from("data.png"),
577            expected: vec![".csv".to_string(), ".tsv".to_string()],
578        }
579        .into();
580
581        let formatted = format_error(&error);
582        assert!(formatted.contains("data.png"));
583        assert!(!formatted.contains('ℹ'));
584    }
585
586    // ── format_error — Execution ─────────────────────────────
587
588    #[test]
589    fn test_format_execution_handler_not_found_with_suggestion() {
590        let error: DynamicCliError = ExecutionError::HandlerNotFound {
591            command: "run".to_string(),
592            implementation: "run_handler".to_string(),
593            suggestion: Some(
594                "Ensure .register_handler(\"run_handler\", ...) was called.".to_string(),
595            ),
596        }
597        .into();
598
599        let formatted = format_error(&error);
600        assert!(formatted.contains("run"));
601        assert!(formatted.contains("run_handler"));
602        assert!(formatted.contains("register_handler"));
603    }
604
605    #[test]
606    fn test_format_execution_context_downcast_failed_with_suggestion() {
607        let error: DynamicCliError = ExecutionError::ContextDowncastFailed {
608            expected_type: "MyCtx".to_string(),
609            suggestion: Some("Check the context type.".to_string()),
610        }
611        .into();
612
613        let formatted = format_error(&error);
614        assert!(formatted.contains("MyCtx"));
615        assert!(formatted.contains("Check the context type."));
616    }
617
618    #[test]
619    fn test_format_execution_interrupted_no_suggestion() {
620        let error: DynamicCliError = ExecutionError::Interrupted.into();
621        let formatted = format_error(&error);
622        assert!(formatted.contains("interrupted"));
623        assert!(!formatted.contains('ℹ'));
624    }
625
626    // ── format_error — Registry ──────────────────────────────
627
628    #[test]
629    fn test_format_registry_missing_handler_with_suggestion() {
630        let error: DynamicCliError = RegistryError::MissingHandler {
631            command: "export".to_string(),
632            suggestion: Some("Call .register_handler(\"export\", ...) before running.".to_string()),
633        }
634        .into();
635
636        let formatted = format_error(&error);
637        assert!(formatted.contains("export"));
638        assert!(formatted.contains("register_handler"));
639    }
640
641    #[test]
642    fn test_format_registry_duplicate_registration_with_suggestion() {
643        let error: DynamicCliError = RegistryError::DuplicateRegistration {
644            name: "run".to_string(),
645            suggestion: Some("Command names must be unique.".to_string()),
646        }
647        .into();
648
649        let formatted = format_error(&error);
650        assert!(formatted.contains("run"));
651        assert!(formatted.contains("Command names must be unique."));
652    }
653
654    #[test]
655    fn test_format_registry_duplicate_alias_with_suggestion() {
656        let error: DynamicCliError = RegistryError::DuplicateAlias {
657            alias: "r".to_string(),
658            existing_command: "run".to_string(),
659            suggestion: Some("Choose a different alias.".to_string()),
660        }
661        .into();
662
663        let formatted = format_error(&error);
664        assert!(formatted.contains("run"));
665        assert!(formatted.contains("Choose a different alias."));
666    }
667
668    // ── display_error ────────────────────────────────────────
669
670    #[test]
671    fn test_display_error_does_not_panic() {
672        let error: DynamicCliError = ConfigError::FileNotFound {
673            path: PathBuf::from("test.yaml"),
674            suggestion: None,
675        }
676        .into();
677        // Writes to stderr — must not panic
678        display_error(&error);
679    }
680}