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