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            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
172/// Calculate optimal number of columns for grid layout.
173fn 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
196/// Renders analyzers in grid layout.
197fn 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
255/// Report formatter for analysis results.
256///
257/// Aggregates results from multiple analyzers for a single file and
258/// provides formatted output with issue counts and suggestions.
259pub struct Report {
260    /// File path being analyzed
261    pub file_path: String,
262    /// Analysis results grouped by analyzer name
263    pub results:   Vec<(String, AnalysisResult)>
264}
265
266impl Report {
267    /// Create new report for a file.
268    ///
269    /// # Arguments
270    ///
271    /// * `file_path` - Path to file being analyzed
272    ///
273    /// # Returns
274    ///
275    /// Empty report ready to accumulate results
276    pub fn new(file_path: String) -> Self {
277        Self {
278            file_path,
279            results: Vec::new()
280        }
281    }
282
283    /// Add analysis result from an analyzer.
284    ///
285    /// # Arguments
286    ///
287    /// * `analyzer_name` - Name of analyzer that produced results
288    /// * `result` - Analysis result to add
289    pub fn add_result(&mut self, analyzer_name: String, result: AnalysisResult) {
290        self.results.push((analyzer_name, result));
291    }
292
293    /// Calculate total issues across all analyzers.
294    ///
295    /// # Returns
296    ///
297    /// Sum of all issues found
298    pub fn total_issues(&self) -> usize {
299        self.results.iter().map(|(_, r)| r.issues.len()).sum()
300    }
301
302    /// Calculate total fixable issues across all analyzers.
303    ///
304    /// # Returns
305    ///
306    /// Sum of all fixable issues
307    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
344/// Global report aggregator across multiple files.
345///
346/// Collects reports from multiple files and provides globally grouped output.
347pub struct GlobalReport {
348    /// Collection of per-file reports
349    pub reports: Vec<Report>
350}
351
352impl GlobalReport {
353    /// Create new global report.
354    pub fn new() -> Self {
355        Self {
356            reports: Vec::new()
357        }
358    }
359
360    /// Add a file report to the global collection.
361    pub fn add_report(&mut self, report: Report) {
362        self.reports.push(report);
363    }
364
365    /// Calculate total issues across all files.
366    pub fn total_issues(&self) -> usize {
367        self.reports.iter().map(|r| r.total_issues()).sum()
368    }
369
370    /// Calculate total fixable issues across all files.
371    pub fn total_fixable(&self) -> usize {
372        self.reports.iter().map(|r| r.total_fixable()).sum()
373    }
374
375    /// Display summary only (total issues and fixable count).
376    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    /// Display details for a specific analyzer only.
399    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    /// Display detailed report with grid layout (verbose mode).
459    ///
460    /// Groups issues by analyzer and message across all files,
461    /// then shows which files have each issue in grid layout.
462    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}