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            append_suggestion(output, suggestion.as_deref());
208        }
209
210        _ => {}
211    }
212}
213
214/// Format a configuration error, showing parse positions and suggestions
215fn format_config_error(output: &mut String, error: &ConfigError) {
216    match error {
217        ConfigError::YamlParse {
218            source,
219            line,
220            column,
221        } => {
222            output.push_str(&format!("{}\n", source));
223            if let (Some(l), Some(c)) = (line, column) {
224                output.push_str(&format!(
225                    "  {} line {}, column {}\n",
226                    color_dimmed("at"),
227                    color_arg_name(&l.to_string()),
228                    color_arg_name(&c.to_string())
229                ));
230            }
231        }
232
233        ConfigError::JsonParse {
234            source,
235            line,
236            column,
237        } => {
238            output.push_str(&format!("{}\n", source));
239            output.push_str(&format!(
240                "  {} line {}, column {}\n",
241                color_dimmed("at"),
242                color_arg_name(&line.to_string()),
243                color_arg_name(&column.to_string())
244            ));
245        }
246
247        ConfigError::InvalidSchema {
248            reason,
249            path,
250            suggestion,
251        } => {
252            output.push_str(&format!("{}\n", reason));
253            if let Some(p) = path {
254                output.push_str(&format!(
255                    "  {} {}\n",
256                    color_dimmed("in"),
257                    color_type_name(p)
258                ));
259            }
260            append_suggestion(output, suggestion.as_deref());
261        }
262
263        ConfigError::FileNotFound { suggestion, .. }
264        | ConfigError::UnsupportedFormat { suggestion, .. }
265        | ConfigError::DuplicateCommand { suggestion, .. }
266        | ConfigError::UnknownType { suggestion, .. }
267        | ConfigError::Inconsistency { suggestion, .. } => {
268            output.push_str(&format!("{}\n", error));
269            append_suggestion(output, suggestion.as_deref());
270        }
271    }
272}
273
274/// Format a validation error with its actionable suggestion
275fn format_validation_error(output: &mut String, error: &ValidationError) {
276    output.push_str(&format!("{}\n", error));
277
278    let suggestion = match error {
279        ValidationError::FileNotFound { suggestion, .. } => suggestion.as_deref(),
280        ValidationError::OutOfRange { suggestion, .. } => suggestion.as_deref(),
281        ValidationError::CustomConstraint { suggestion, .. } => suggestion.as_deref(),
282        ValidationError::MissingDependency { suggestion, .. } => suggestion.as_deref(),
283        ValidationError::MutuallyExclusive { suggestion, .. } => suggestion.as_deref(),
284        // InvalidExtension already lists the expected extensions in the message
285        ValidationError::InvalidExtension { .. } => None,
286    };
287
288    append_suggestion(output, suggestion);
289}
290
291/// Format an execution error with its actionable suggestion
292fn format_execution_error(output: &mut String, error: &ExecutionError) {
293    output.push_str(&format!("{}\n", error));
294
295    let suggestion = match error {
296        ExecutionError::HandlerNotFound { suggestion, .. } => suggestion.as_deref(),
297        ExecutionError::ContextDowncastFailed { suggestion, .. } => suggestion.as_deref(),
298        ExecutionError::InvalidContextState { suggestion, .. } => suggestion.as_deref(),
299        // CommandFailed and Interrupted carry no structured suggestion
300        ExecutionError::CommandFailed(_) | ExecutionError::Interrupted => None,
301    };
302
303    append_suggestion(output, suggestion);
304}
305
306/// Format a registry error with its actionable suggestion
307fn format_registry_error(output: &mut String, error: &RegistryError) {
308    output.push_str(&format!("{}\n", error));
309
310    let suggestion = match error {
311        RegistryError::DuplicateRegistration { suggestion, .. } => suggestion.as_deref(),
312        RegistryError::DuplicateAlias { suggestion, .. } => suggestion.as_deref(),
313        RegistryError::MissingHandler { suggestion, .. } => suggestion.as_deref(),
314    };
315
316    append_suggestion(output, suggestion);
317}
318
319/// Format a WASM plugin error with its actionable suggestion
320///
321/// Only available when the `wasm-plugins` feature is enabled.
322///
323/// `GuestError` and `SerializationFailed` and `MemoryAccessFailed` carry no
324/// structured `suggestion` field — they surface only their `Display` message.
325#[cfg(feature = "wasm-plugins")]
326fn format_wasm_error(output: &mut String, error: &WasmError) {
327    output.push_str(&format!("{}\n", error));
328
329    let suggestion = match error {
330        WasmError::LoadFailed { suggestion, .. } => suggestion.as_deref(),
331        WasmError::FunctionNotFound { suggestion, .. } => suggestion.as_deref(),
332        WasmError::GuestError { .. } => None,
333        WasmError::SerializationFailed(_) => None,
334        WasmError::MemoryAccessFailed { .. } => None,
335    };
336
337    append_suggestion(output, suggestion);
338}
339
340// ═══════════════════════════════════════════════════════════
341// SHARED HELPER
342// ═══════════════════════════════════════════════════════════
343
344/// Append a suggestion line to the output buffer
345///
346/// Renders the line only when `suggestion` is `Some`. The format is:
347///
348/// ```text
349///   ℹ  <suggestion text>
350/// ```
351///
352/// When `suggestion` is `None`, this is a no-op.
353fn append_suggestion(output: &mut String, suggestion: Option<&str>) {
354    if let Some(s) = suggestion {
355        output.push_str(&format!("  {} {}\n", color_info("ℹ"), s));
356    }
357}
358
359// ═══════════════════════════════════════════════════════════
360// TESTS
361// ═══════════════════════════════════════════════════════════
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use std::path::PathBuf;
367
368    // ── format_error — Config ────────────────────────────────
369
370    #[test]
371    fn test_format_config_file_not_found_contains_path() {
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        assert!(formatted.contains("Error:"));
380        assert!(formatted.contains("test.yaml"));
381    }
382
383    #[test]
384    fn test_format_config_file_not_found_with_suggestion() {
385        let error: DynamicCliError = ConfigError::FileNotFound {
386            path: PathBuf::from("test.yaml"),
387            suggestion: Some("Verify the path.".to_string()),
388        }
389        .into();
390
391        let formatted = format_error(&error);
392        assert!(formatted.contains("Verify the path."));
393    }
394
395    #[test]
396    fn test_format_config_file_not_found_no_suggestion_no_hint_line() {
397        let error: DynamicCliError = ConfigError::FileNotFound {
398            path: PathBuf::from("test.yaml"),
399            suggestion: None,
400        }
401        .into();
402
403        let formatted = format_error(&error);
404        // The ℹ line must not appear when suggestion is None
405        assert!(!formatted.contains('ℹ'));
406    }
407
408    #[test]
409    fn test_format_config_unsupported_format_with_suggestion() {
410        let error: DynamicCliError = ConfigError::UnsupportedFormat {
411            extension: ".toml".to_string(),
412            suggestion: Some("Use .yaml instead.".to_string()),
413        }
414        .into();
415
416        let formatted = format_error(&error);
417        assert!(formatted.contains(".toml"));
418        assert!(formatted.contains("Use .yaml instead."));
419    }
420
421    #[test]
422    fn test_format_config_yaml_parse_contains_location() {
423        let yaml_error = serde_yaml::from_str::<serde_yaml::Value>("invalid: [")
424            .err()
425            .unwrap();
426
427        let error: DynamicCliError = ConfigError::yaml_parse_with_location(yaml_error).into();
428        let formatted = format_error(&error);
429        assert!(formatted.contains("Error:"));
430    }
431
432    #[test]
433    fn test_format_config_invalid_schema_with_path_and_suggestion() {
434        let error: DynamicCliError = ConfigError::InvalidSchema {
435            reason: "missing field".to_string(),
436            path: Some("commands[0]".to_string()),
437            suggestion: Some("Add a name field.".to_string()),
438        }
439        .into();
440
441        let formatted = format_error(&error);
442        assert!(formatted.contains("missing field"));
443        assert!(formatted.contains("commands[0]"));
444        assert!(formatted.contains("Add a name field."));
445    }
446
447    // ── format_error — Parse ─────────────────────────────────
448
449    #[test]
450    fn test_format_parse_unknown_command_with_suggestions() {
451        let error: DynamicCliError = ParseError::UnknownCommand {
452            command: "simulat".to_string(),
453            suggestions: vec!["simulate".to_string(), "validation".to_string()],
454        }
455        .into();
456
457        let formatted = format_error(&error);
458        assert!(formatted.contains("Unknown command"));
459        assert!(formatted.contains("simulat"));
460        assert!(formatted.contains("Did you mean"));
461        assert!(formatted.contains("simulate"));
462    }
463
464    #[test]
465    fn test_format_parse_unknown_command_no_suggestions() {
466        let error: DynamicCliError = ParseError::UnknownCommand {
467            command: "xyz".to_string(),
468            suggestions: vec![],
469        }
470        .into();
471
472        let formatted = format_error(&error);
473        assert!(formatted.contains("xyz"));
474        assert!(!formatted.contains("Did you mean"));
475    }
476
477    #[test]
478    fn test_format_parse_missing_argument_with_suggestion() {
479        let error: DynamicCliError = ParseError::MissingArgument {
480            argument: "file".to_string(),
481            command: "process".to_string(),
482            suggestion: Some("Run --help process to see required arguments.".to_string()),
483        }
484        .into();
485
486        let formatted = format_error(&error);
487        assert!(formatted.contains("file"));
488        assert!(formatted.contains("Run --help process"));
489    }
490
491    #[test]
492    fn test_format_parse_missing_option_with_suggestion() {
493        let error: DynamicCliError = ParseError::MissingOption {
494            option: "output".to_string(),
495            command: "export".to_string(),
496            suggestion: Some("Run --help export to see required options.".to_string()),
497        }
498        .into();
499
500        let formatted = format_error(&error);
501        assert!(formatted.contains("output"));
502        assert!(formatted.contains("Run --help export"));
503    }
504
505    #[test]
506    fn test_format_parse_too_many_arguments_with_suggestion() {
507        let error: DynamicCliError = ParseError::TooManyArguments {
508            command: "run".to_string(),
509            expected: 1,
510            got: 3,
511            suggestion: Some("Run --help run for the expected usage.".to_string()),
512        }
513        .into();
514
515        let formatted = format_error(&error);
516        assert!(formatted.contains("run"));
517        assert!(formatted.contains("Run --help run"));
518    }
519
520    #[test]
521    fn test_format_parse_type_parse_error_shows_info_block() {
522        let error: DynamicCliError = ParseError::TypeParseError {
523            arg_name: "count".to_string(),
524            expected_type: "integer".to_string(),
525            value: "abc".to_string(),
526            details: None,
527        }
528        .into();
529
530        let formatted = format_error(&error);
531        assert!(formatted.contains("integer"));
532        assert!(formatted.contains("count"));
533        assert!(formatted.contains("abc"));
534    }
535
536    // ── format_error — Validation ────────────────────────────
537
538    #[test]
539    fn test_format_validation_file_not_found_with_suggestion() {
540        let error: DynamicCliError = ValidationError::FileNotFound {
541            path: PathBuf::from("data.csv"),
542            arg_name: "input".to_string(),
543            suggestion: Some("Check that the file exists.".to_string()),
544        }
545        .into();
546
547        let formatted = format_error(&error);
548        assert!(formatted.contains("data.csv"));
549        assert!(formatted.contains("Check that the file exists."));
550    }
551
552    #[test]
553    fn test_format_validation_out_of_range_with_suggestion() {
554        let error: DynamicCliError = ValidationError::OutOfRange {
555            arg_name: "percentage".to_string(),
556            value: 150.0,
557            min: 0.0,
558            max: 100.0,
559            suggestion: Some("Value must be between 0 and 100.".to_string()),
560        }
561        .into();
562
563        let formatted = format_error(&error);
564        assert!(formatted.contains("percentage"));
565        assert!(formatted.contains("Value must be between 0 and 100."));
566    }
567
568    #[test]
569    fn test_format_validation_mutually_exclusive_with_suggestion() {
570        let error: DynamicCliError = ValidationError::MutuallyExclusive {
571            arg1: "--verbose".to_string(),
572            arg2: "--quiet".to_string(),
573            suggestion: Some("Remove one of the two conflicting options.".to_string()),
574        }
575        .into();
576
577        let formatted = format_error(&error);
578        assert!(formatted.contains("--verbose"));
579        assert!(formatted.contains("Remove one of the two conflicting options."));
580    }
581
582    #[test]
583    fn test_format_validation_missing_dependency_with_suggestion() {
584        let error: DynamicCliError = ValidationError::MissingDependency {
585            arg_name: "format".to_string(),
586            required_arg: "output".to_string(),
587            suggestion: Some("Add --output to your command.".to_string()),
588        }
589        .into();
590
591        let formatted = format_error(&error);
592        assert!(formatted.contains("format"));
593        assert!(formatted.contains("Add --output to your command."));
594    }
595
596    #[test]
597    fn test_format_validation_invalid_extension_no_suggestion_line() {
598        // InvalidExtension has no suggestion field; the message itself lists extensions
599        let error: DynamicCliError = ValidationError::InvalidExtension {
600            arg_name: "input".to_string(),
601            path: PathBuf::from("data.png"),
602            expected: vec![".csv".to_string(), ".tsv".to_string()],
603        }
604        .into();
605
606        let formatted = format_error(&error);
607        assert!(formatted.contains("data.png"));
608        assert!(!formatted.contains('ℹ'));
609    }
610
611    // ── format_error — Execution ─────────────────────────────
612
613    #[test]
614    fn test_format_execution_handler_not_found_with_suggestion() {
615        let error: DynamicCliError = ExecutionError::HandlerNotFound {
616            command: "run".to_string(),
617            implementation: "run_handler".to_string(),
618            suggestion: Some(
619                "Ensure .register_sync_handler(\"run_handler\", ...) was called.".to_string(),
620            ),
621        }
622        .into();
623
624        let formatted = format_error(&error);
625        assert!(formatted.contains("run"));
626        assert!(formatted.contains("run_handler"));
627        assert!(formatted.contains("register_sync_handler"));
628    }
629
630    #[test]
631    fn test_format_execution_context_downcast_failed_with_suggestion() {
632        let error: DynamicCliError = ExecutionError::ContextDowncastFailed {
633            expected_type: "MyCtx".to_string(),
634            suggestion: Some("Check the context type.".to_string()),
635        }
636        .into();
637
638        let formatted = format_error(&error);
639        assert!(formatted.contains("MyCtx"));
640        assert!(formatted.contains("Check the context type."));
641    }
642
643    #[test]
644    fn test_format_execution_interrupted_no_suggestion() {
645        let error: DynamicCliError = ExecutionError::Interrupted.into();
646        let formatted = format_error(&error);
647        assert!(formatted.contains("interrupted"));
648        assert!(!formatted.contains('ℹ'));
649    }
650
651    // ── format_error — Registry ──────────────────────────────
652
653    #[test]
654    fn test_format_registry_missing_handler_with_suggestion() {
655        let error: DynamicCliError = RegistryError::MissingHandler {
656            command: "export".to_string(),
657            suggestion: Some(
658                "Call .register_sync_handler(\"export\", ...) before running.".to_string(),
659            ),
660        }
661        .into();
662
663        let formatted = format_error(&error);
664        assert!(formatted.contains("export"));
665        assert!(formatted.contains("register_sync_handler"));
666    }
667
668    #[test]
669    fn test_format_registry_duplicate_registration_with_suggestion() {
670        let error: DynamicCliError = RegistryError::DuplicateRegistration {
671            name: "run".to_string(),
672            suggestion: Some("Command names must be unique.".to_string()),
673        }
674        .into();
675
676        let formatted = format_error(&error);
677        assert!(formatted.contains("run"));
678        assert!(formatted.contains("Command names must be unique."));
679    }
680
681    #[test]
682    fn test_format_registry_duplicate_alias_with_suggestion() {
683        let error: DynamicCliError = RegistryError::DuplicateAlias {
684            alias: "r".to_string(),
685            existing_command: "run".to_string(),
686            suggestion: Some("Choose a different alias.".to_string()),
687        }
688        .into();
689
690        let formatted = format_error(&error);
691        assert!(formatted.contains("run"));
692        assert!(formatted.contains("Choose a different alias."));
693    }
694
695    // ── WasmError ────────────────────────────────────────────
696
697    #[cfg(feature = "wasm-plugins")]
698    #[test]
699    fn test_format_wasm_function_not_found_with_suggestion() {
700        let error: DynamicCliError = WasmError::FunctionNotFound {
701            function: "dcli_dealloc".to_string(),
702            module: "plugin.wasm".to_string(),
703            suggestion: Some("Export `dcli_dealloc(ptr: i32, size: i32)`.".to_string()),
704        }
705        .into();
706
707        let formatted = format_error(&error);
708        assert!(formatted.contains("dcli_dealloc"));
709        assert!(formatted.contains("Export `dcli_dealloc"));
710    }
711
712    #[cfg(feature = "wasm-plugins")]
713    #[test]
714    fn test_format_wasm_guest_error_without_suggestion_line() {
715        let error: DynamicCliError = WasmError::GuestError {
716            code: 1,
717            message: Some("invalid argument".to_string()),
718        }
719        .into();
720
721        let formatted = format_error(&error);
722        assert!(formatted.contains("invalid argument"));
723        // GuestError carries no structured suggestion — no "ℹ" line expected
724        assert!(!formatted.contains('ℹ'));
725    }
726
727    // ── display_error ────────────────────────────────────────
728
729    #[test]
730    fn test_display_error_does_not_panic() {
731        let error: DynamicCliError = ConfigError::FileNotFound {
732            path: PathBuf::from("test.yaml"),
733            suggestion: None,
734        }
735        .into();
736        // Writes to stderr — must not panic
737        display_error(&error);
738    }
739}