1use std::{collections::HashMap, fmt};
10
11use console::measure_text_width;
12use owo_colors::OwoColorize;
13use terminal_size::{Width, terminal_size};
14
15use crate::analyzer::AnalysisResult;
16
17const COLUMN_GAP: usize = 4;
19
20const MIN_ANALYZER_WIDTH: usize = 40;
22
23const MAX_ANALYZER_WIDTH: usize = 80;
25
26struct RenderedAnalyzer {
28 lines: Vec<String>,
29 width: usize
30}
31
32fn render_analyzer_block(
34 analyzer_name: &str,
35 message_map: &HashMap<String, Vec<(String, Vec<usize>)>>,
36 color: bool
37) -> RenderedAnalyzer {
38 let mut content_lines = Vec::new();
39 let mut max_width = MIN_ANALYZER_WIDTH;
40
41 let total_issues: usize = message_map
42 .values()
43 .map(|files| files.iter().map(|(_, lines)| lines.len()).sum::<usize>())
44 .sum();
45
46 let header = if color {
47 format!(
48 "[{}] - {} issues",
49 analyzer_name.yellow().bold(),
50 total_issues.to_string().cyan()
51 )
52 } else {
53 format!("[{}] - {} issues", analyzer_name, total_issues)
54 };
55
56 max_width = max_width.max(measure_text_width(&header));
57 content_lines.push(header);
58
59 for (message, file_list) in message_map {
60 let msg_line = format!(" {}", message);
61 max_width = max_width.max(measure_text_width(&msg_line));
62 content_lines.push(msg_line);
63 content_lines.push(String::new());
64
65 for (file_path, mut file_lines) in file_list.iter().map(|(f, l)| (f, l.clone())) {
66 file_lines.sort_unstable();
67
68 let file_line = if color {
69 format!(" {} → Lines: ", file_path.blue())
70 } else {
71 format!(" {} → Lines: ", file_path)
72 };
73
74 let lines_str: Vec<String> = file_lines.iter().map(|l| l.to_string()).collect();
75 let joined = if color {
76 lines_str
77 .iter()
78 .map(|l| format!("{}", l.magenta()))
79 .collect::<Vec<_>>()
80 .join(", ")
81 } else {
82 lines_str.join(", ")
83 };
84
85 if joined.len() > 60 {
86 let mut line_chunks = Vec::new();
87 let mut current_line = String::new();
88
89 for (i, line_num) in lines_str.iter().enumerate() {
90 let separator = if i == 0 { "" } else { ", " };
91 let addition = if color {
92 format!("{}{}", separator, line_num.magenta())
93 } else {
94 format!("{}{}", separator, line_num)
95 };
96
97 let addition_len = separator.len() + line_num.len();
98
99 if current_line.len() + addition_len > 60 && !current_line.is_empty() {
100 line_chunks.push(current_line.clone());
101 current_line = if color {
102 format!("{}", line_num.magenta())
103 } else {
104 line_num.clone()
105 };
106 } else {
107 current_line.push_str(&addition);
108 }
109 }
110
111 if !current_line.is_empty() {
112 line_chunks.push(current_line);
113 }
114
115 for (i, chunk) in line_chunks.iter().enumerate() {
116 let full_line = if i == 0 {
117 format!("{}{}", file_line, chunk)
118 } else {
119 format!(" {} {}", " ".repeat(file_path.len() + 9), chunk)
120 };
121 max_width = max_width.max(measure_text_width(&full_line));
122 content_lines.push(full_line);
123 }
124 } else {
125 let full_line = format!("{}{}", file_line, joined);
126 max_width = max_width.max(measure_text_width(&full_line));
127 content_lines.push(full_line);
128 }
129 }
130
131 content_lines.push(String::new());
132 }
133
134 let final_width = max_width.clamp(MIN_ANALYZER_WIDTH, MAX_ANALYZER_WIDTH);
135 let separator = "─".repeat(final_width);
136 let footer = "═".repeat(final_width);
137
138 let mut lines = Vec::with_capacity(content_lines.len() + 2);
139 lines.push(content_lines[0].clone());
140 lines.push(if color {
141 separator.dimmed().to_string()
142 } else {
143 separator
144 });
145
146 for line in &content_lines[1..] {
147 let line_width = measure_text_width(line);
148 if line_width > final_width {
149 let mut truncated = line.clone();
150 while measure_text_width(&truncated) > final_width - 3 {
151 truncated.pop();
152 }
153 truncated.push_str("...");
154 lines.push(truncated);
155 } else {
156 lines.push(line.clone());
157 }
158 }
159
160 lines.push(if color {
161 footer.dimmed().to_string()
162 } else {
163 footer
164 });
165
166 RenderedAnalyzer {
167 lines,
168 width: final_width
169 }
170}
171
172fn calculate_columns(analyzers: &[RenderedAnalyzer], term_width: usize) -> usize {
174 if analyzers.is_empty() {
175 return 1;
176 }
177
178 let max_analyzer_width = analyzers
179 .iter()
180 .map(|a| a.width)
181 .max()
182 .unwrap_or(MIN_ANALYZER_WIDTH)
183 .max(MIN_ANALYZER_WIDTH);
184
185 for cols in (1..=analyzers.len()).rev() {
186 let total_width = cols * max_analyzer_width + (cols.saturating_sub(1)) * COLUMN_GAP;
187
188 if total_width <= term_width {
189 return cols;
190 }
191 }
192
193 1
194}
195
196fn render_grid(analyzers: &[RenderedAnalyzer], columns: usize) -> String {
198 let mut output = String::new();
199
200 if analyzers.is_empty() {
201 return output;
202 }
203
204 if columns == 1 {
205 for analyzer in analyzers {
206 for line in &analyzer.lines {
207 output.push_str(line);
208 output.push('\n');
209 }
210 output.push('\n');
211 }
212 return output;
213 }
214
215 let col_width = analyzers
216 .iter()
217 .map(|a| a.width)
218 .max()
219 .unwrap_or(MIN_ANALYZER_WIDTH);
220
221 for chunk in analyzers.chunks(columns) {
222 let max_lines = chunk.iter().map(|a| a.lines.len()).max().unwrap_or(0);
223
224 for row_idx in 0..max_lines {
225 let mut row_output = String::with_capacity(columns * (col_width + COLUMN_GAP));
226
227 for (col_idx, analyzer) in chunk.iter().enumerate() {
228 let line = analyzer
229 .lines
230 .get(row_idx)
231 .map(String::as_str)
232 .unwrap_or("");
233
234 let visual_width = measure_text_width(line);
235 let padding = col_width.saturating_sub(visual_width);
236
237 row_output.push_str(line);
238 row_output.push_str(&" ".repeat(padding));
239
240 if col_idx < chunk.len() - 1 {
241 row_output.push_str(&" ".repeat(COLUMN_GAP));
242 }
243 }
244
245 output.push_str(&row_output);
246 output.push('\n');
247 }
248
249 output.push('\n');
250 }
251
252 output
253}
254
255pub struct Report {
260 pub file_path: String,
262 pub results: Vec<(String, AnalysisResult)>
264}
265
266impl Report {
267 pub fn new(file_path: String) -> Self {
277 Self {
278 file_path,
279 results: Vec::new()
280 }
281 }
282
283 pub fn add_result(&mut self, analyzer_name: String, result: AnalysisResult) {
290 self.results.push((analyzer_name, result));
291 }
292
293 pub fn total_issues(&self) -> usize {
299 self.results.iter().map(|(_, r)| r.issues.len()).sum()
300 }
301
302 pub fn total_fixable(&self) -> usize {
308 self.results.iter().map(|(_, r)| r.fixable_count).sum()
309 }
310}
311
312impl fmt::Display for Report {
313 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314 writeln!(f, "Quality report for: {}", self.file_path)?;
315 writeln!(f, "=")?;
316
317 for (analyzer_name, result) in &self.results {
318 if result.issues.is_empty() {
319 continue;
320 }
321
322 writeln!(f, "\n[{}]", analyzer_name)?;
323 for issue in &result.issues {
324 write!(f, " {}:{} - {}", issue.line, issue.column, issue.message)?;
325 if issue.fix.is_available() {
326 if let Some((import, _pattern, _replacement)) = issue.fix.as_import() {
327 write!(f, "\n Fix: Add import: {}", import)?;
328 write!(f, "\n (Will replace path with short name)")?;
329 } else if let Some(simple) = issue.fix.as_simple() {
330 write!(f, "\n Fix: {}", simple)?;
331 }
332 }
333 writeln!(f)?;
334 }
335 }
336
337 writeln!(f, "\nTotal issues: {}", self.total_issues())?;
338 writeln!(f, "Fixable: {}", self.total_fixable())?;
339
340 Ok(())
341 }
342}
343
344pub struct GlobalReport {
348 pub reports: Vec<Report>
350}
351
352impl GlobalReport {
353 pub fn new() -> Self {
355 Self {
356 reports: Vec::new()
357 }
358 }
359
360 pub fn add_report(&mut self, report: Report) {
362 self.reports.push(report);
363 }
364
365 pub fn total_issues(&self) -> usize {
367 self.reports.iter().map(|r| r.total_issues()).sum()
368 }
369
370 pub fn total_fixable(&self) -> usize {
372 self.reports.iter().map(|r| r.total_fixable()).sum()
373 }
374
375 pub fn display_compact(&self, color: bool) -> String {
377 let mut output = String::new();
378
379 if color {
380 output.push_str(&format!(
381 "{}: {}\n",
382 "Total issues".green().bold(),
383 self.total_issues().to_string().green().bold()
384 ));
385 output.push_str(&format!(
386 "{}: {}\n",
387 "Fixable".green().bold(),
388 self.total_fixable().to_string().green().bold()
389 ));
390 } else {
391 output.push_str(&format!("Total issues: {}\n", self.total_issues()));
392 output.push_str(&format!("Fixable: {}\n", self.total_fixable()));
393 }
394
395 output
396 }
397
398 pub fn display_analyzer(&self, analyzer_name: &str, color: bool) -> String {
400 type FileLines = Vec<(String, Vec<usize>)>;
401 type MessageGroups = HashMap<String, FileLines>;
402
403 let mut message_map: MessageGroups = HashMap::new();
404
405 for report in &self.reports {
406 for (name, result) in &report.results {
407 if name != analyzer_name || result.issues.is_empty() {
408 continue;
409 }
410
411 for issue in &result.issues {
412 let file_list = message_map.entry(issue.message.clone()).or_default();
413
414 if let Some((_, lines)) =
415 file_list.iter_mut().find(|(f, _)| f == &report.file_path)
416 {
417 lines.push(issue.line);
418 } else {
419 file_list.push((report.file_path.clone(), vec![issue.line]));
420 }
421 }
422 }
423 }
424
425 if message_map.is_empty() {
426 return String::new();
427 }
428
429 let rendered = render_analyzer_block(analyzer_name, &message_map, color);
430 let mut output = String::new();
431
432 for line in &rendered.lines {
433 output.push_str(line);
434 output.push('\n');
435 }
436
437 output.push('\n');
438
439 if color {
440 output.push_str(&format!(
441 "{}: {}\n",
442 "Total issues".green().bold(),
443 self.total_issues().to_string().green().bold()
444 ));
445 output.push_str(&format!(
446 "{}: {}\n",
447 "Fixable".green().bold(),
448 self.total_fixable().to_string().green().bold()
449 ));
450 } else {
451 output.push_str(&format!("Total issues: {}\n", self.total_issues()));
452 output.push_str(&format!("Fixable: {}\n", self.total_fixable()));
453 }
454
455 output
456 }
457
458 pub fn display_verbose(&self, color: bool) -> String {
463 type FileLines = Vec<(String, Vec<usize>)>;
464 type MessageGroups = HashMap<String, FileLines>;
465 type AnalyzerGroups = HashMap<String, MessageGroups>;
466
467 let mut analyzer_groups: AnalyzerGroups = HashMap::new();
468
469 for report in &self.reports {
470 for (analyzer_name, result) in &report.results {
471 if result.issues.is_empty() {
472 continue;
473 }
474
475 let message_map = analyzer_groups.entry(analyzer_name.clone()).or_default();
476
477 for issue in &result.issues {
478 let file_list = message_map.entry(issue.message.clone()).or_default();
479
480 if let Some((_, lines)) =
481 file_list.iter_mut().find(|(f, _)| f == &report.file_path)
482 {
483 lines.push(issue.line);
484 } else {
485 file_list.push((report.file_path.clone(), vec![issue.line]));
486 }
487 }
488 }
489 }
490
491 let mut analyzer_names: Vec<_> = analyzer_groups.keys().cloned().collect();
492 analyzer_names.sort();
493
494 let rendered_analyzers: Vec<RenderedAnalyzer> = analyzer_names
495 .iter()
496 .map(|name| {
497 let message_map = &analyzer_groups[name];
498 render_analyzer_block(name, message_map, color)
499 })
500 .collect();
501
502 let term_width = terminal_size()
503 .map(|(Width(w), _)| w as usize)
504 .unwrap_or(170);
505
506 let columns = calculate_columns(&rendered_analyzers, term_width);
507
508 let mut output = render_grid(&rendered_analyzers, columns);
509
510 if color {
511 output.push_str(&format!(
512 "\n{}: {}\n",
513 "Total issues".green().bold(),
514 self.total_issues().to_string().green().bold()
515 ));
516 output.push_str(&format!(
517 "{}: {}\n",
518 "Fixable".green().bold(),
519 self.total_fixable().to_string().green().bold()
520 ));
521 } else {
522 output.push_str(&format!("\nTotal issues: {}\n", self.total_issues()));
523 output.push_str(&format!("Fixable: {}\n", self.total_fixable()));
524 }
525
526 output
527 }
528}
529
530impl Default for GlobalReport {
531 fn default() -> Self {
532 Self::new()
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539 use crate::analyzer::Issue;
540
541 #[test]
542 fn test_report_creation() {
543 let report = Report::new("test.rs".to_string());
544 assert_eq!(report.file_path, "test.rs");
545 assert_eq!(report.results.len(), 0);
546 }
547
548 #[test]
549 fn test_report_add_result() {
550 let mut report = Report::new("test.rs".to_string());
551 let result = AnalysisResult {
552 issues: vec![],
553 fixable_count: 0
554 };
555
556 report.add_result("test_analyzer".to_string(), result);
557 assert_eq!(report.results.len(), 1);
558 }
559
560 #[test]
561 fn test_report_total_issues() {
562 let mut report = Report::new("test.rs".to_string());
563
564 let issue = Issue {
565 line: 1,
566 column: 1,
567 message: "Test".to_string(),
568 fix: crate::analyzer::Fix::None
569 };
570
571 let result = AnalysisResult {
572 issues: vec![issue],
573 fixable_count: 1
574 };
575
576 report.add_result("analyzer1".to_string(), result);
577 assert_eq!(report.total_issues(), 1);
578 assert_eq!(report.total_fixable(), 1);
579 }
580
581 #[test]
582 fn test_report_display_with_issues() {
583 let mut report = Report::new("test.rs".to_string());
584
585 let issue = Issue {
586 line: 42,
587 column: 15,
588 message: "Test issue".to_string(),
589 fix: crate::analyzer::Fix::Simple("Fix suggestion".to_string())
590 };
591
592 let result = AnalysisResult {
593 issues: vec![issue],
594 fixable_count: 1
595 };
596
597 report.add_result("test_analyzer".to_string(), result);
598
599 let output = format!("{}", report);
600 assert!(output.contains("Quality report for: test.rs"));
601 assert!(output.contains("test_analyzer"));
602 assert!(output.contains("42:15 - Test issue"));
603 assert!(output.contains("Fix: Fix suggestion"));
604 assert!(output.contains("Total issues: 1"));
605 assert!(output.contains("Fixable: 1"));
606 }
607
608 #[test]
609 fn test_report_display_without_issues() {
610 let mut report = Report::new("test.rs".to_string());
611
612 let result = AnalysisResult {
613 issues: vec![],
614 fixable_count: 0
615 };
616
617 report.add_result("empty_analyzer".to_string(), result);
618
619 let output = format!("{}", report);
620 assert!(output.contains("Quality report for: test.rs"));
621 assert!(!output.contains("empty_analyzer"));
622 assert!(output.contains("Total issues: 0"));
623 assert!(output.contains("Fixable: 0"));
624 }
625
626 #[test]
627 fn test_report_display_issue_without_suggestion() {
628 let mut report = Report::new("file.rs".to_string());
629
630 let issue = Issue {
631 line: 10,
632 column: 5,
633 message: "Warning message".to_string(),
634 fix: crate::analyzer::Fix::None
635 };
636
637 let result = AnalysisResult {
638 issues: vec![issue],
639 fixable_count: 0
640 };
641
642 report.add_result("warn_analyzer".to_string(), result);
643
644 let output = format!("{}", report);
645 assert!(output.contains("10:5 - Warning message"));
646 assert!(!output.contains("Fix:"));
647 }
648
649 #[test]
650 fn test_report_multiple_analyzers() {
651 let mut report = Report::new("code.rs".to_string());
652
653 let issue1 = Issue {
654 line: 1,
655 column: 1,
656 message: "Issue 1".to_string(),
657 fix: crate::analyzer::Fix::Simple("Fix 1".to_string())
658 };
659
660 let issue2 = Issue {
661 line: 2,
662 column: 2,
663 message: "Issue 2".to_string(),
664 fix: crate::analyzer::Fix::None
665 };
666
667 report.add_result(
668 "analyzer1".to_string(),
669 AnalysisResult {
670 issues: vec![issue1],
671 fixable_count: 1
672 }
673 );
674
675 report.add_result(
676 "analyzer2".to_string(),
677 AnalysisResult {
678 issues: vec![issue2],
679 fixable_count: 0
680 }
681 );
682
683 assert_eq!(report.total_issues(), 2);
684 assert_eq!(report.total_fixable(), 1);
685
686 let output = format!("{}", report);
687 assert!(output.contains("analyzer1"));
688 assert!(output.contains("analyzer2"));
689 assert!(output.contains("Total issues: 2"));
690 }
691
692 #[test]
693 fn test_report_total_fixable() {
694 let mut report = Report::new("test.rs".to_string());
695
696 report.add_result(
697 "analyzer1".to_string(),
698 AnalysisResult {
699 issues: vec![],
700 fixable_count: 3
701 }
702 );
703
704 report.add_result(
705 "analyzer2".to_string(),
706 AnalysisResult {
707 issues: vec![],
708 fixable_count: 2
709 }
710 );
711
712 assert_eq!(report.total_fixable(), 5);
713 }
714}