dynamic_cli/error/
display.rs

1//! User-friendly error display
2//!
3//! Formats errors with coloring to improve readability
4//! in the terminal.
5
6use crate::error::{ConfigError, DynamicCliError, ParseError};
7
8/// Helper functions for conditional coloring
9#[cfg(feature = "colored-output")]
10mod color {
11    use colored::*;
12
13    pub fn error(s: &str) -> String {
14        s.red().bold().to_string()
15    }
16    pub fn question(s: &str) -> String {
17        s.yellow().bold().to_string()
18    }
19    pub fn bullet(s: &str) -> String {
20        s.cyan().to_string()
21    }
22    pub fn suggestion(s: &str) -> String {
23        s.green().to_string()
24    }
25    pub fn info(s: &str) -> String {
26        s.blue().bold().to_string()
27    }
28    pub fn type_name(s: &str) -> String {
29        s.cyan().to_string()
30    }
31    pub fn arg_name(s: &str) -> String {
32        s.yellow().to_string()
33    }
34    pub fn value(s: &str) -> String {
35        s.red().to_string()
36    }
37    pub fn dimmed(s: &str) -> String {
38        s.dimmed().to_string()
39    }
40}
41
42#[cfg(not(feature = "colored-output"))]
43mod color {
44    pub fn error(s: &str) -> String {
45        s.to_string()
46    }
47    pub fn question(s: &str) -> String {
48        s.to_string()
49    }
50    pub fn bullet(s: &str) -> String {
51        s.to_string()
52    }
53    pub fn suggestion(s: &str) -> String {
54        s.to_string()
55    }
56    pub fn info(s: &str) -> String {
57        s.to_string()
58    }
59    pub fn type_name(s: &str) -> String {
60        s.to_string()
61    }
62    pub fn arg_name(s: &str) -> String {
63        s.to_string()
64    }
65    pub fn value(s: &str) -> String {
66        s.to_string()
67    }
68    pub fn dimmed(s: &str) -> String {
69        s.to_string()
70    }
71}
72
73/// Display an error in a user-friendly way to the terminal
74///
75/// This function displays the error on stderr with coloring
76/// and suggestions if available.
77///
78/// # Example
79///
80/// ```no_run
81/// use dynamic_cli::error::{display_error, ParseError};
82///
83/// let error = ParseError::UnknownCommand {
84///     command: "simulat".to_string(),
85///     suggestions: vec!["simulate".to_string()],
86/// };
87/// display_error(&error.into());
88/// ```
89pub fn display_error(error: &DynamicCliError) {
90    eprintln!("{}", format_error(error));
91}
92
93/// Format an error with coloring
94///
95/// Generates a formatted string with colors and suggestions.
96/// Can be used for logging or custom display.
97///
98/// # Arguments
99///
100/// * `error` - The error to format
101///
102/// # Returns
103///
104/// Formatted string with ANSI color codes (if colored-output feature is enabled)
105///
106/// # Example
107///
108/// ```
109/// use dynamic_cli::error::{format_error, ConfigError};
110/// use std::path::PathBuf;
111///
112/// let error = ConfigError::FileNotFound {
113///     path: PathBuf::from("config.yaml"),
114/// };
115/// let formatted = format_error(&error.into());
116/// assert!(formatted.contains("Error:"));
117/// assert!(formatted.contains("config.yaml"));
118/// ```
119pub fn format_error(error: &DynamicCliError) -> String {
120    let mut output = String::new();
121
122    // Error header (colored if feature enabled)
123    output.push_str(&format!("{} ", color::error("Error:")));
124
125    // Format according to error type
126    match error {
127        DynamicCliError::Parse(e) => {
128            format_parse_error(&mut output, e);
129        }
130
131        DynamicCliError::Config(e) => {
132            format_config_error(&mut output, e);
133        }
134
135        DynamicCliError::Validation(e) => {
136            output.push_str(&format!("{}\n", e));
137        }
138
139        DynamicCliError::Execution(e) => {
140            output.push_str(&format!("{}\n", e));
141        }
142
143        DynamicCliError::Registry(e) => {
144            output.push_str(&format!("{}\n", e));
145        }
146
147        DynamicCliError::Io(e) => {
148            output.push_str(&format!("{}\n", e));
149        }
150    }
151
152    output
153}
154
155/// Format a parsing error with suggestions
156fn format_parse_error(output: &mut String, error: &ParseError) {
157    output.push_str(&format!("{}\n", error));
158
159    // Add suggestions if available
160    match error {
161        ParseError::UnknownCommand { suggestions, .. } if !suggestions.is_empty() => {
162            output.push_str(&format!("\n{} Did you mean:\n", color::question("?")));
163            for suggestion in suggestions {
164                output.push_str(&format!(
165                    "  {} {}\n",
166                    color::bullet("•"),
167                    color::suggestion(suggestion)
168                ));
169            }
170        }
171
172        ParseError::UnknownOption { suggestions, .. } if !suggestions.is_empty() => {
173            output.push_str(&format!("\n{} Did you mean:\n", color::question("?")));
174            for suggestion in suggestions {
175                output.push_str(&format!(
176                    "  {} {}\n",
177                    color::bullet("•"),
178                    color::suggestion(suggestion)
179                ));
180            }
181        }
182
183        ParseError::TypeParseError {
184            arg_name,
185            expected_type,
186            value,
187            ..
188        } => {
189            output.push_str(&format!(
190                "\n{} Expected type {} for argument {}, got: {}\n",
191                color::info("ℹ"),
192                color::type_name(expected_type),
193                color::arg_name(arg_name),
194                color::value(value)
195            ));
196        }
197
198        _ => {}
199    }
200}
201
202/// Format a configuration error with position
203fn format_config_error(output: &mut String, error: &ConfigError) {
204    match error {
205        ConfigError::YamlParse {
206            source,
207            line,
208            column,
209        } => {
210            output.push_str(&format!("{}\n", source));
211            if let (Some(l), Some(c)) = (line, column) {
212                output.push_str(&format!(
213                    "  {} line {}, column {}\n",
214                    color::dimmed("at"),
215                    color::arg_name(&l.to_string()),
216                    color::arg_name(&c.to_string())
217                ));
218            }
219        }
220
221        ConfigError::JsonParse {
222            source,
223            line,
224            column,
225        } => {
226            output.push_str(&format!("{}\n", source));
227            output.push_str(&format!(
228                "  {} line {}, column {}\n",
229                color::dimmed("at"),
230                color::arg_name(&line.to_string()),
231                color::arg_name(&column.to_string())
232            ));
233        }
234
235        ConfigError::InvalidSchema { reason, path } => {
236            output.push_str(&format!("{}\n", reason));
237            if let Some(p) = path {
238                output.push_str(&format!(
239                    "  {} {}\n",
240                    color::dimmed("in"),
241                    color::type_name(p)
242                ));
243            }
244        }
245
246        _ => {
247            output.push_str(&format!("{}\n", error));
248        }
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use std::path::PathBuf;
256
257    #[test]
258    fn test_format_error_basic() {
259        let error: DynamicCliError = ConfigError::FileNotFound {
260            path: PathBuf::from("test.yaml"),
261        }
262        .into();
263
264        let formatted = format_error(&error);
265
266        // Must contain "Error:" and the file path
267        assert!(formatted.contains("Error:"));
268        assert!(formatted.contains("test.yaml"));
269    }
270
271    #[test]
272    fn test_format_parse_error_with_suggestions() {
273        let error: DynamicCliError = ParseError::UnknownCommand {
274            command: "simulat".to_string(),
275            suggestions: vec!["simulate".to_string(), "validation".to_string()],
276        }
277        .into();
278
279        let formatted = format_error(&error);
280
281        // Must contain the error message
282        assert!(formatted.contains("Unknown command"));
283        assert!(formatted.contains("simulat"));
284
285        // Must contain suggestions
286        assert!(formatted.contains("Did you mean"));
287        assert!(formatted.contains("simulate"));
288    }
289
290    #[test]
291    fn test_format_parse_error_without_suggestions() {
292        let error: DynamicCliError = ParseError::UnknownCommand {
293            command: "xyz".to_string(),
294            suggestions: vec![],
295        }
296        .into();
297
298        let formatted = format_error(&error);
299
300        // Must contain the error message
301        assert!(formatted.contains("Unknown command"));
302        assert!(formatted.contains("xyz"));
303
304        // Must NOT contain suggestions
305        assert!(!formatted.contains("Did you mean"));
306    }
307
308    #[test]
309    fn test_format_config_error_with_location() {
310        let yaml_error = serde_yaml::from_str::<serde_yaml::Value>("invalid: [")
311            .err()
312            .unwrap();
313
314        let error: DynamicCliError = ConfigError::yaml_parse_with_location(yaml_error).into();
315        let formatted = format_error(&error);
316
317        // Must contain position information
318        assert!(formatted.contains("Error:"));
319        // Exact position depends on serde_yaml implementation
320    }
321
322    #[test]
323    fn test_display_error_does_not_panic() {
324        // Test that display_error doesn't panic (outputs to stderr)
325        let error: DynamicCliError = ConfigError::FileNotFound {
326            path: PathBuf::from("test.yaml"),
327        }
328        .into();
329
330        // Should not panic
331        display_error(&error);
332    }
333}