Skip to main content

cargo_quality/
report.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4//! Report formatting for analysis results.
5//!
6//! Provides structured output of quality issues found during analysis,
7//! grouping results by analyzer and file.
8
9use 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
17/// Minimum space between columns in grid layout.
18const COLUMN_GAP: usize = 4;
19
20/// Minimum width for an analyzer column.
21const MIN_ANALYZER_WIDTH: usize = 40;
22
23/// Maximum width for an analyzer column to enable multi-column layout.
24const MAX_ANALYZER_WIDTH: usize = 80;
25
26/// Rendered analyzer block for grid layout.
27struct RenderedAnalyzer {
28    lines: Vec<String>,
29    width: usize
30}
31
32/// Renders a single analyzer block with issues.
33fn 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
177/// Calculate optimal number of columns for grid layout.
178fn 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
201/// Renders analyzers in grid layout.
202fn 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
260/// Report formatter for analysis results.
261///
262/// Aggregates results from multiple analyzers for a single file and
263/// provides formatted output with issue counts and suggestions.
264pub struct Report {
265    /// File path being analyzed
266    pub file_path: String,
267    /// Analysis results grouped by analyzer name
268    pub results:   Vec<(String, AnalysisResult)>
269}
270
271impl Report {
272    /// Create new report for a file.
273    ///
274    /// # Arguments
275    ///
276    /// * `file_path` - Path to file being analyzed
277    ///
278    /// # Returns
279    ///
280    /// Empty report ready to accumulate results
281    pub fn new(file_path: String) -> Self {
282        Self {
283            file_path,
284            results: Vec::new()
285        }
286    }
287
288    /// Add analysis result from an analyzer.
289    ///
290    /// # Arguments
291    ///
292    /// * `analyzer_name` - Name of analyzer that produced results
293    /// * `result` - Analysis result to add
294    pub fn add_result(&mut self, analyzer_name: String, result: AnalysisResult) {
295        self.results.push((analyzer_name, result));
296    }
297
298    /// Calculate total issues across all analyzers.
299    ///
300    /// # Returns
301    ///
302    /// Sum of all issues found
303    pub fn total_issues(&self) -> usize {
304        self.results.iter().map(|(_, r)| r.issues.len()).sum()
305    }
306
307    /// Calculate total fixable issues across all analyzers.
308    ///
309    /// # Returns
310    ///
311    /// Sum of all fixable issues
312    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
349/// Global report aggregator across multiple files.
350///
351/// Collects reports from multiple files and provides globally grouped output.
352pub struct GlobalReport {
353    /// Collection of per-file reports
354    pub reports: Vec<Report>
355}
356
357impl GlobalReport {
358    /// Create new global report.
359    pub fn new() -> Self {
360        Self {
361            reports: Vec::new()
362        }
363    }
364
365    /// Add a file report to the global collection.
366    pub fn add_report(&mut self, report: Report) {
367        self.reports.push(report);
368    }
369
370    /// Calculate total issues across all files.
371    pub fn total_issues(&self) -> usize {
372        self.reports.iter().map(|r| r.total_issues()).sum()
373    }
374
375    /// Calculate total fixable issues across all files.
376    pub fn total_fixable(&self) -> usize {
377        self.reports.iter().map(|r| r.total_fixable()).sum()
378    }
379
380    /// Display summary only (total issues and fixable count).
381    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    /// Display details for a specific analyzer only.
404    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    /// Display detailed report with grid layout (verbose mode).
464    ///
465    /// Groups issues by analyzer and message across all files,
466    /// then shows which files have each issue in grid layout.
467    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}