orlint/
reporter.rs

1// Reporter for lint issues
2use serde::{Deserialize, Serialize};
3use std::fs::File;
4use std::io::{self, Write};
5use std::path::Path;
6
7/// Issue severity
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
9pub enum Severity {
10    /// Error - must be fixed
11    Error,
12    /// Warning - should be fixed
13    #[default]
14    Warning,
15    /// Info - suggested improvement
16    Info,
17}
18
19// Custom deserialization to handle case-insensitive strings
20impl<'de> Deserialize<'de> for Severity {
21    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
22    where
23        D: serde::Deserializer<'de>,
24    {
25        let s = String::deserialize(deserializer)?;
26        match s.to_lowercase().as_str() {
27            "error" => Ok(Severity::Error),
28            "warning" => Ok(Severity::Warning),
29            "info" => Ok(Severity::Info),
30            _ => Err(serde::de::Error::custom(format!(
31                "Unknown severity level: {s}. Expected one of: Error, Warning, Info"
32            ))),
33        }
34    }
35}
36
37impl std::fmt::Display for Severity {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            Severity::Error => write!(f, "error"),
41            Severity::Warning => write!(f, "warning"),
42            Severity::Info => write!(f, "info"),
43        }
44    }
45}
46
47/// Lint issue
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Issue {
50    /// Name of the rule that found the issue
51    pub rule: String,
52    /// Message describing the issue
53    pub message: String,
54    /// File where the issue was found
55    pub file: String,
56    /// Line number where the issue was found
57    pub line: usize,
58    /// Column number where the issue was found
59    pub column: usize,
60    /// Severity of the issue
61    pub severity: Severity,
62}
63
64/// Reporter for lint issues
65pub struct Reporter {
66    /// Format to use when reporting issues
67    format: ReportFormat,
68    /// Output file path
69    output_path: Option<String>,
70}
71
72/// Report format
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74enum ReportFormat {
75    /// Plain text
76    Text,
77    /// JSON
78    Json,
79    /// HTML
80    Html,
81}
82
83impl Reporter {
84    /// Create a new text reporter
85    pub fn new_text() -> Self {
86        Self {
87            format: ReportFormat::Text,
88            output_path: None,
89        }
90    }
91
92    /// Create a new JSON reporter
93    pub fn new_json() -> Self {
94        Self {
95            format: ReportFormat::Json,
96            output_path: None,
97        }
98    }
99
100    /// Create a new HTML reporter
101    pub fn new_html() -> Self {
102        Self {
103            format: ReportFormat::Html,
104            output_path: None,
105        }
106    }
107
108    /// Set output file path
109    pub fn with_output_path(mut self, path: &str) -> Self {
110        self.output_path = Some(path.to_string());
111        self
112    }
113
114    /// Report issues for a single file
115    pub fn report_issues(&self, file_path: &str, issues: &[Issue]) {
116        let output = match self.format {
117            ReportFormat::Text => self.generate_text_report(file_path, issues),
118            ReportFormat::Json => self.generate_json_report(issues),
119            ReportFormat::Html => self.generate_html_report(file_path, issues),
120        };
121
122        self.write_output(&output);
123    }
124
125    /// Report issues for multiple files
126    pub fn report_all_issues(&self, issues: &[Issue]) {
127        let output = match self.format {
128            ReportFormat::Text => self.generate_text_report_all(issues),
129            ReportFormat::Json => self.generate_json_report(issues),
130            ReportFormat::Html => self.generate_html_report_all(issues),
131        };
132
133        self.write_output(&output);
134    }
135
136    /// Generate a text report for a single file
137    fn generate_text_report(&self, file_path: &str, issues: &[Issue]) -> String {
138        let mut output = String::new();
139
140        if issues.is_empty() {
141            output.push_str(&format!("{file_path}: No issues found\n"));
142            return output;
143        }
144
145        output.push_str(&format!("{file_path}: {} issues found\n", issues.len()));
146
147        for issue in issues {
148            output.push_str(&format!(
149                "  {0}:{1}:{2}: [{3}] {4} ({5})\n",
150                issue.file, issue.line, issue.column, issue.severity, issue.message, issue.rule,
151            ));
152        }
153
154        output
155    }
156
157    /// Generate a text report for multiple files
158    fn generate_text_report_all(&self, issues: &[Issue]) -> String {
159        let mut output = String::new();
160
161        if issues.is_empty() {
162            output.push_str("No issues found\n");
163            return output;
164        }
165
166        // Group issues by file
167        let mut files = std::collections::HashMap::new();
168        for issue in issues {
169            files
170                .entry(issue.file.clone())
171                .or_insert_with(Vec::new)
172                .push(issue);
173        }
174
175        output.push_str(&format!(
176            "Found {} issues in {} files\n",
177            issues.len(),
178            files.len()
179        ));
180
181        // Sort files by name for consistent output
182        let mut files: Vec<_> = files.into_iter().collect();
183        files.sort_by(|(a, _), (b, _)| a.cmp(b));
184
185        for (file, file_issues) in files {
186            output.push_str(&format!("\n{}: {} issues\n", file, file_issues.len()));
187
188            // Sort issues by line number
189            let mut sorted_issues = file_issues;
190            sorted_issues.sort_by_key(|i| (i.line, i.column));
191
192            for issue in sorted_issues {
193                output.push_str(&format!(
194                    "  {}:{}: [{}] {} ({})\n",
195                    issue.line, issue.column, issue.severity, issue.message, issue.rule,
196                ));
197            }
198        }
199
200        output
201    }
202
203    /// Generate a JSON report
204    fn generate_json_report(&self, issues: &[Issue]) -> String {
205        serde_json::to_string_pretty(&issues)
206            .unwrap_or_else(|_| "Error serializing issues".to_string())
207    }
208
209    /// Generate an HTML report for a single file
210    fn generate_html_report(&self, _file_path: &str, issues: &[Issue]) -> String {
211        self.generate_html_report_all(issues)
212    }
213
214    /// Generate an HTML report for multiple files
215    fn generate_html_report_all(&self, issues: &[Issue]) -> String {
216        let mut html = String::new();
217
218        // HTML header
219        html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
220        html.push_str("    <meta charset=\"UTF-8\">\n");
221        html.push_str(
222            "    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n",
223        );
224        html.push_str("    <title>Orbit Analyzer Report</title>\n");
225        html.push_str("    <style>\n");
226        html.push_str("        body { font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; max-width: 1200px; margin: 0 auto; padding: 20px; }\n");
227        html.push_str("        h1, h2, h3 { color: #0066cc; }\n");
228        html.push_str("        .summary { background-color: #f5f5f5; border-radius: 5px; padding: 15px; margin-bottom: 20px; }\n");
229        html.push_str("        .file { margin-bottom: 30px; border: 1px solid #ddd; border-radius: 5px; padding: 10px; }\n");
230        html.push_str("        .file-header { background-color: #f0f0f0; padding: 10px; margin: -10px -10px 10px -10px; border-bottom: 1px solid #ddd; border-radius: 5px 5px 0 0; }\n");
231        html.push_str(
232            "        .issue { margin-bottom: 10px; padding: 10px; border-radius: 3px; }\n",
233        );
234        html.push_str(
235            "        .error { background-color: #ffeeee; border-left: 5px solid #ff6666; }\n",
236        );
237        html.push_str(
238            "        .warning { background-color: #fff8e6; border-left: 5px solid #ffcc66; }\n",
239        );
240        html.push_str(
241            "        .info { background-color: #e6f5ff; border-left: 5px solid #66b3ff; }\n",
242        );
243        html.push_str(
244            "        .location { font-family: monospace; font-size: 0.9em; color: #666; }\n",
245        );
246        html.push_str("        .rule { font-size: 0.8em; color: #666; float: right; }\n");
247        html.push_str("        .clear { clear: both; }\n");
248        html.push_str("    </style>\n");
249        html.push_str("</head>\n<body>\n");
250        html.push_str("    <h1>Orbit Analyzer Report</h1>\n");
251
252        // Summary
253        let error_count = issues
254            .iter()
255            .filter(|i| i.severity == Severity::Error)
256            .count();
257        let warning_count = issues
258            .iter()
259            .filter(|i| i.severity == Severity::Warning)
260            .count();
261        let info_count = issues
262            .iter()
263            .filter(|i| i.severity == Severity::Info)
264            .count();
265
266        // Group issues by file
267        let mut files = std::collections::HashMap::new();
268        for issue in issues {
269            files
270                .entry(issue.file.clone())
271                .or_insert_with(Vec::new)
272                .push(issue);
273        }
274
275        html.push_str("    <div class=\"summary\">\n");
276        html.push_str("        <h2>Summary</h2>\n");
277        html.push_str(&format!(
278            "        <p>Found {} issues in {} files</p>\n",
279            issues.len(),
280            files.len()
281        ));
282        html.push_str("        <ul>\n");
283        html.push_str(&format!("            <li>Errors: {error_count}</li>\n"));
284        html.push_str(&format!("            <li>Warnings: {warning_count}</li>\n"));
285        html.push_str(&format!("            <li>Information: {info_count}</li>\n"));
286        html.push_str("        </ul>\n");
287        html.push_str("    </div>\n");
288
289        if !issues.is_empty() {
290            html.push_str("    <h2>Issues</h2>\n");
291
292            // Sort files by name
293            let mut files: Vec<_> = files.into_iter().collect();
294            files.sort_by(|(a, _), (b, _)| a.cmp(b));
295
296            for (file, file_issues) in files {
297                html.push_str("    <div class=\"file\">\n");
298                html.push_str("        <div class=\"file-header\">\n");
299                html.push_str(&format!("            <h3>{file}</h3>\n"));
300                html.push_str(&format!(
301                    "            <p>{} issues</p>\n",
302                    file_issues.len()
303                ));
304                html.push_str("        </div>\n");
305
306                // Sort issues by line number
307                let mut sorted_issues = file_issues;
308                sorted_issues.sort_by_key(|i| (i.line, i.column));
309
310                for issue in sorted_issues {
311                    let severity_class = match issue.severity {
312                        Severity::Error => "error",
313                        Severity::Warning => "warning",
314                        Severity::Info => "info",
315                    };
316
317                    html.push_str(&format!("        <div class=\"issue {severity_class}\">\n"));
318                    html.push_str(&format!(
319                        "            <div class=\"rule\">{}</div>\n",
320                        issue.rule
321                    ));
322                    html.push_str(&format!(
323                        "            <div class=\"message\">{}</div>\n",
324                        issue.message
325                    ));
326                    html.push_str(&format!(
327                        "            <div class=\"location\">Line {}, Column {}</div>\n",
328                        issue.line, issue.column
329                    ));
330                    html.push_str("            <div class=\"clear\"></div>\n");
331                    html.push_str("        </div>\n");
332                }
333
334                html.push_str("    </div>\n");
335            }
336        }
337
338        // HTML footer
339        html.push_str("</body>\n</html>");
340
341        html
342    }
343
344    /// Write output to a file or stdout
345    fn write_output(&self, output: &str) {
346        match &self.output_path {
347            Some(path) => {
348                let path = Path::new(path);
349                if let Some(parent) = path.parent() {
350                    if !parent.exists() {
351                        if let Err(e) = std::fs::create_dir_all(parent) {
352                            eprintln!("Error creating directory {}: {}", parent.display(), e);
353                            return;
354                        }
355                    }
356                }
357
358                match File::create(path) {
359                    Ok(mut file) => {
360                        if let Err(e) = file.write_all(output.as_bytes()) {
361                            eprintln!("Error writing to {}: {}", path.display(), e);
362                        }
363                    }
364                    Err(e) => {
365                        eprintln!("Error creating file {}: {}", path.display(), e);
366                    }
367                }
368            }
369            None => {
370                let stdout = io::stdout();
371                let mut handle = stdout.lock();
372                if let Err(e) = handle.write_all(output.as_bytes()) {
373                    eprintln!("Error writing to stdout: {e}");
374                }
375            }
376        }
377    }
378}