1use serde::{Deserialize, Serialize};
3use std::fs::File;
4use std::io::{self, Write};
5use std::path::Path;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
9pub enum Severity {
10 Error,
12 #[default]
14 Warning,
15 Info,
17}
18
19impl<'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#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Issue {
50 pub rule: String,
52 pub message: String,
54 pub file: String,
56 pub line: usize,
58 pub column: usize,
60 pub severity: Severity,
62}
63
64pub struct Reporter {
66 format: ReportFormat,
68 output_path: Option<String>,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74enum ReportFormat {
75 Text,
77 Json,
79 Html,
81}
82
83impl Reporter {
84 pub fn new_text() -> Self {
86 Self {
87 format: ReportFormat::Text,
88 output_path: None,
89 }
90 }
91
92 pub fn new_json() -> Self {
94 Self {
95 format: ReportFormat::Json,
96 output_path: None,
97 }
98 }
99
100 pub fn new_html() -> Self {
102 Self {
103 format: ReportFormat::Html,
104 output_path: None,
105 }
106 }
107
108 pub fn with_output_path(mut self, path: &str) -> Self {
110 self.output_path = Some(path.to_string());
111 self
112 }
113
114 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 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 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 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 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 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 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 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 fn generate_html_report(&self, _file_path: &str, issues: &[Issue]) -> String {
211 self.generate_html_report_all(issues)
212 }
213
214 fn generate_html_report_all(&self, issues: &[Issue]) -> String {
216 let mut html = String::new();
217
218 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 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 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 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 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.push_str("</body>\n</html>");
340
341 html
342 }
343
344 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}