bashrs/models/
diagnostic.rs

1// Rich error diagnostics for Rash transpiler
2// Testing Spec Section 1.6: Error message quality ≥0.7
3//
4// Provides structured, helpful error messages with:
5// - Source location (file:line:column)
6// - Error category and explanation
7// - Helpful suggestions
8// - Related information
9
10use crate::models::Error;
11use std::fmt;
12
13/// Enhanced diagnostic information for errors
14#[derive(Debug, Clone)]
15pub struct Diagnostic {
16    /// The underlying error
17    pub error: String,
18
19    /// Source file path (if available)
20    pub file: Option<String>,
21
22    /// Line number (if available)
23    pub line: Option<usize>,
24
25    /// Column number (if available)
26    pub column: Option<usize>,
27
28    /// Error category (for grouping similar errors)
29    pub category: ErrorCategory,
30
31    /// Additional context/explanation
32    pub note: Option<String>,
33
34    /// Suggested fix or workaround
35    pub help: Option<String>,
36
37    /// Code snippet (if available)
38    pub snippet: Option<String>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum ErrorCategory {
43    /// Syntax/parse errors
44    Syntax,
45
46    /// Unsupported Rust features
47    UnsupportedFeature,
48
49    /// Validation errors
50    Validation,
51
52    /// IR generation errors
53    Transpilation,
54
55    /// I/O errors
56    Io,
57
58    /// Internal compiler errors
59    Internal,
60}
61
62impl Diagnostic {
63    /// Create a diagnostic from an error with context
64    pub fn from_error(error: &Error, file: Option<String>) -> Self {
65        let (category, note, help) = Self::categorize_error(error);
66
67        // Note: syn::Error already includes location in its Display impl
68        // We don't extract it separately as proc_macro2::Span doesn't expose line/column
69        let (line, column) = (None, None);
70
71        Self {
72            error: error.to_string(),
73            file,
74            line,
75            column,
76            category,
77            note,
78            help,
79            snippet: None,
80        }
81    }
82
83    /// Categorize error and provide helpful context
84    fn categorize_error(error: &Error) -> (ErrorCategory, Option<String>, Option<String>) {
85        match error {
86            Error::Parse(_) => (
87                ErrorCategory::Syntax,
88                Some("Rash uses a subset of Rust syntax for transpilation to shell scripts.".to_string()),
89                Some("Ensure your code uses supported Rust syntax. See docs/user-guide.md for details.".to_string()),
90            ),
91
92            Error::Validation(msg) if msg.contains("Only functions") => (
93                ErrorCategory::UnsupportedFeature,
94                Some("Rash only supports function definitions at the top level.".to_string()),
95                Some("Remove struct, trait, impl, or other definitions. Only 'fn' declarations are allowed.".to_string()),
96            ),
97
98            Error::Validation(msg) if msg.contains("Unsupported") => (
99                ErrorCategory::UnsupportedFeature,
100                Some("This Rust feature is not supported for shell script transpilation.".to_string()),
101                Some("Check the user guide for supported features, or file an issue for feature requests.".to_string()),
102            ),
103
104            Error::Validation(msg) => (
105                ErrorCategory::Validation,
106                Some(format!("Validation failed: {}", msg)),
107                Some("Review the error message and ensure your code follows Rash constraints.".to_string()),
108            ),
109
110            Error::IrGeneration(msg) => (
111                ErrorCategory::Transpilation,
112                Some(format!("Failed to generate intermediate representation: {}", msg)),
113                Some("This is likely a transpiler bug. Please report this issue.".to_string()),
114            ),
115
116            Error::Io(_) => (
117                ErrorCategory::Io,
118                Some("Failed to read or write files.".to_string()),
119                Some("Check file paths and permissions.".to_string()),
120            ),
121
122            Error::Unsupported(feature) => (
123                ErrorCategory::UnsupportedFeature,
124                Some(format!("The feature '{}' is not yet supported for transpilation.", feature)),
125                Some("See docs/user-guide.md for supported features, or use a workaround.".to_string()),
126            ),
127
128            _ => (
129                ErrorCategory::Internal,
130                Some("An internal error occurred during transpilation.".to_string()),
131                Some("This may be a bug. Please report this with a minimal reproduction.".to_string()),
132            ),
133        }
134    }
135
136    /// Calculate quality score (0.0 to 1.0)
137    pub fn quality_score(&self) -> f32 {
138        let mut score = 0.0;
139
140        // Has error prefix (always present)
141        score += 1.0;
142
143        // Has source location (file is most important)
144        if self.file.is_some() {
145            score += 1.0;
146        }
147        if self.line.is_some() {
148            score += 0.25;
149        }
150        if self.column.is_some() {
151            score += 0.25;
152        }
153
154        // Has code snippet (nice to have but not always possible)
155        if self.snippet.is_some() {
156            score += 1.0;
157        }
158
159        // Has explanation (note) - CRITICAL for user understanding
160        if self.note.is_some() {
161            score += 2.5;
162        }
163
164        // Has suggestion (help) - CRITICAL for actionability
165        if self.help.is_some() {
166            score += 2.5;
167        }
168
169        score / 8.5 // Normalize to 0-1 (max 8.5 points)
170    }
171}
172
173impl fmt::Display for Diagnostic {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        // Error header with location
176        write!(f, "error")?;
177
178        if let Some(file) = &self.file {
179            write!(f, " in {}", file)?;
180            if let Some(line) = self.line {
181                write!(f, ":{}", line)?;
182                if let Some(col) = self.column {
183                    write!(f, ":{}", col)?;
184                }
185            }
186        }
187
188        writeln!(f, ": {}", self.error)?;
189
190        // Code snippet (if available)
191        if let Some(snippet) = &self.snippet {
192            writeln!(f)?;
193            writeln!(f, "{}", snippet)?;
194            if let Some(col) = self.column {
195                // Add caret indicator
196                writeln!(f, "{}^", " ".repeat(col.saturating_sub(1)))?;
197            }
198        }
199
200        // Note (explanation)
201        if let Some(note) = &self.note {
202            writeln!(f)?;
203            writeln!(f, "note: {}", note)?;
204        }
205
206        // Help (suggestion)
207        if let Some(help) = &self.help {
208            writeln!(f)?;
209            writeln!(f, "help: {}", help)?;
210        }
211
212        Ok(())
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn test_diagnostic_quality_score() {
222        let mut diag = Diagnostic {
223            error: "test error".to_string(),
224            file: None,
225            line: None,
226            column: None,
227            category: ErrorCategory::Syntax,
228            note: None,
229            help: None,
230            snippet: None,
231        };
232
233        // Baseline: just error prefix
234        assert!(diag.quality_score() < 0.7); // Only error prefix, no context
235
236        // Add location
237        diag.file = Some("test.rs".to_string());
238        diag.line = Some(10);
239        diag.column = Some(5);
240        assert!(diag.quality_score() < 0.7); // Missing note+help, below threshold
241
242        // Add note and help (target ≥0.7)
243        diag.note = Some("Explanation".to_string());
244        diag.help = Some("Suggestion".to_string());
245        assert!(diag.quality_score() >= 0.7); // Should exceed 0.7 threshold
246    }
247
248    #[test]
249    fn test_unsupported_feature_diagnostic() {
250        let error = Error::Validation("Only functions are allowed in Rash code".to_string());
251        let diag = Diagnostic::from_error(&error, Some("example.rs".to_string()));
252
253        assert_eq!(diag.category, ErrorCategory::UnsupportedFeature);
254        assert!(diag.note.is_some());
255        assert!(diag.help.is_some());
256
257        // Should achieve ≥0.7 quality score
258        assert!(
259            diag.quality_score() >= 0.7,
260            "Quality score {} should be ≥0.7",
261            diag.quality_score()
262        );
263    }
264
265    #[test]
266    fn test_diagnostic_display() {
267        let diag = Diagnostic {
268            error: "unexpected token".to_string(),
269            file: Some("main.rs".to_string()),
270            line: Some(5),
271            column: Some(10),
272            category: ErrorCategory::Syntax,
273            note: Some("Expected a semicolon here".to_string()),
274            help: Some("Add ';' after the statement".to_string()),
275            snippet: None,
276        };
277
278        let output = format!("{}", diag);
279        assert!(output.contains("error in main.rs:5:10"));
280        assert!(output.contains("note: Expected a semicolon"));
281        assert!(output.contains("help: Add ';'"));
282    }
283
284    // ====== Additional Tests for Coverage ======
285
286    #[test]
287    fn test_diagnostic_display_no_file() {
288        let diag = Diagnostic {
289            error: "parse error".to_string(),
290            file: None,
291            line: None,
292            column: None,
293            category: ErrorCategory::Syntax,
294            note: None,
295            help: None,
296            snippet: None,
297        };
298
299        let output = format!("{}", diag);
300        assert!(output.contains("error: parse error"));
301        assert!(!output.contains("in "));
302    }
303
304    #[test]
305    fn test_diagnostic_display_file_only() {
306        let diag = Diagnostic {
307            error: "file error".to_string(),
308            file: Some("test.rs".to_string()),
309            line: None,
310            column: None,
311            category: ErrorCategory::Io,
312            note: None,
313            help: None,
314            snippet: None,
315        };
316
317        let output = format!("{}", diag);
318        assert!(output.contains("error in test.rs"));
319        assert!(!output.contains(":0")); // No line number
320    }
321
322    #[test]
323    fn test_diagnostic_display_file_and_line() {
324        let diag = Diagnostic {
325            error: "line error".to_string(),
326            file: Some("test.rs".to_string()),
327            line: Some(42),
328            column: None,
329            category: ErrorCategory::Validation,
330            note: None,
331            help: None,
332            snippet: None,
333        };
334
335        let output = format!("{}", diag);
336        assert!(output.contains("error in test.rs:42"));
337    }
338
339    #[test]
340    fn test_diagnostic_display_with_snippet() {
341        let diag = Diagnostic {
342            error: "syntax error".to_string(),
343            file: Some("test.rs".to_string()),
344            line: Some(5),
345            column: Some(10),
346            category: ErrorCategory::Syntax,
347            note: None,
348            help: None,
349            snippet: Some("let x = foo(".to_string()),
350        };
351
352        let output = format!("{}", diag);
353        assert!(output.contains("let x = foo("));
354        assert!(output.contains("^")); // Caret indicator
355    }
356
357    #[test]
358    fn test_diagnostic_display_snippet_column_0() {
359        let diag = Diagnostic {
360            error: "syntax error".to_string(),
361            file: Some("test.rs".to_string()),
362            line: Some(5),
363            column: Some(0),
364            category: ErrorCategory::Syntax,
365            note: None,
366            help: None,
367            snippet: Some("bad code".to_string()),
368        };
369
370        let output = format!("{}", diag);
371        assert!(output.contains("bad code"));
372        assert!(output.contains("^")); // Caret at column 0
373    }
374
375    #[test]
376    fn test_quality_score_with_snippet() {
377        let diag = Diagnostic {
378            error: "test error".to_string(),
379            file: Some("test.rs".to_string()),
380            line: Some(10),
381            column: Some(5),
382            category: ErrorCategory::Syntax,
383            note: Some("Explanation".to_string()),
384            help: Some("Suggestion".to_string()),
385            snippet: Some("let x = bad;".to_string()),
386        };
387
388        // With snippet, score should be higher
389        let score = diag.quality_score();
390        assert!(
391            score > 0.9,
392            "Score with snippet should be >0.9, got {}",
393            score
394        );
395    }
396
397    #[test]
398    fn test_categorize_parse_error() {
399        let error = Error::Parse(syn::Error::new(proc_macro2::Span::call_site(), "test"));
400        let diag = Diagnostic::from_error(&error, None);
401
402        assert_eq!(diag.category, ErrorCategory::Syntax);
403        assert!(diag.note.is_some());
404        assert!(diag.help.is_some());
405    }
406
407    #[test]
408    fn test_categorize_validation_unsupported() {
409        let error = Error::Validation("Unsupported expression type".to_string());
410        let diag = Diagnostic::from_error(&error, None);
411
412        assert_eq!(diag.category, ErrorCategory::UnsupportedFeature);
413    }
414
415    #[test]
416    fn test_categorize_validation_generic() {
417        let error = Error::Validation("Some validation issue".to_string());
418        let diag = Diagnostic::from_error(&error, None);
419
420        assert_eq!(diag.category, ErrorCategory::Validation);
421    }
422
423    #[test]
424    fn test_categorize_ir_generation() {
425        let error = Error::IrGeneration("Failed to generate IR".to_string());
426        let diag = Diagnostic::from_error(&error, None);
427
428        assert_eq!(diag.category, ErrorCategory::Transpilation);
429        assert!(diag
430            .note
431            .as_ref()
432            .unwrap()
433            .contains("intermediate representation"));
434    }
435
436    #[test]
437    fn test_categorize_io_error() {
438        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
439        let error = Error::Io(io_err);
440        let diag = Diagnostic::from_error(&error, None);
441
442        assert_eq!(diag.category, ErrorCategory::Io);
443        assert!(diag.help.as_ref().unwrap().contains("permissions"));
444    }
445
446    #[test]
447    fn test_categorize_unsupported() {
448        let error = Error::Unsupported("async functions".to_string());
449        let diag = Diagnostic::from_error(&error, None);
450
451        assert_eq!(diag.category, ErrorCategory::UnsupportedFeature);
452        assert!(diag.note.as_ref().unwrap().contains("async functions"));
453    }
454
455    #[test]
456    fn test_categorize_internal_error() {
457        let error = Error::Internal("unexpected state".to_string());
458        let diag = Diagnostic::from_error(&error, None);
459
460        assert_eq!(diag.category, ErrorCategory::Internal);
461        assert!(diag.help.as_ref().unwrap().contains("bug"));
462    }
463
464    #[test]
465    fn test_error_category_equality() {
466        assert_eq!(ErrorCategory::Syntax, ErrorCategory::Syntax);
467        assert_ne!(ErrorCategory::Syntax, ErrorCategory::Io);
468        assert_eq!(
469            ErrorCategory::UnsupportedFeature,
470            ErrorCategory::UnsupportedFeature
471        );
472        assert_eq!(ErrorCategory::Validation, ErrorCategory::Validation);
473        assert_eq!(ErrorCategory::Transpilation, ErrorCategory::Transpilation);
474        assert_eq!(ErrorCategory::Internal, ErrorCategory::Internal);
475    }
476
477    #[test]
478    fn test_diagnostic_clone() {
479        let diag = Diagnostic {
480            error: "test".to_string(),
481            file: Some("test.rs".to_string()),
482            line: Some(1),
483            column: Some(1),
484            category: ErrorCategory::Syntax,
485            note: Some("note".to_string()),
486            help: Some("help".to_string()),
487            snippet: Some("code".to_string()),
488        };
489
490        let cloned = diag.clone();
491        assert_eq!(diag.error, cloned.error);
492        assert_eq!(diag.file, cloned.file);
493        assert_eq!(diag.category, cloned.category);
494    }
495
496    #[test]
497    fn test_error_category_debug() {
498        let cat = ErrorCategory::Syntax;
499        let debug_str = format!("{:?}", cat);
500        assert_eq!(debug_str, "Syntax");
501    }
502
503    #[test]
504    fn test_diagnostic_debug() {
505        let diag = Diagnostic {
506            error: "test".to_string(),
507            file: None,
508            line: None,
509            column: None,
510            category: ErrorCategory::Syntax,
511            note: None,
512            help: None,
513            snippet: None,
514        };
515
516        let debug_str = format!("{:?}", diag);
517        assert!(debug_str.contains("Diagnostic"));
518        assert!(debug_str.contains("test"));
519    }
520}