Skip to main content

linthis/reports/
statistics.rs

1// Copyright 2024 zhlinh and linthis Project Authors. All rights reserved.
2// Use of this source code is governed by a MIT-style
3// license that can be found at
4//
5// https://opensource.org/license/MIT
6//
7// The above copyright notice and this permission
8// notice shall be included in all copies or
9// substantial portions of the Software.
10
11//! Issue classification statistics for linthis reports.
12
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16use crate::utils::types::{RunResult, Severity};
17
18/// Aggregated statistics for a lint run.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct ReportStatistics {
21    /// Total issue counts by severity.
22    pub severity_counts: SeverityCounts,
23    /// Issues grouped by language.
24    pub by_language: HashMap<String, usize>,
25    /// Issues grouped by linter tool.
26    pub by_tool: HashMap<String, usize>,
27    /// Issues grouped by rule code.
28    pub by_rule: HashMap<String, RuleStats>,
29    /// Top N files with most issues.
30    pub top_files: Vec<FileStats>,
31    /// Summary metrics.
32    pub summary: SummaryMetrics,
33}
34
35/// Issue counts by severity level.
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37pub struct SeverityCounts {
38    pub errors: usize,
39    pub warnings: usize,
40    pub info: usize,
41}
42
43impl SeverityCounts {
44    /// Total number of issues.
45    pub fn total(&self) -> usize {
46        self.errors + self.warnings + self.info
47    }
48}
49
50/// Statistics for a specific rule code.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct RuleStats {
53    pub code: String,
54    pub count: usize,
55    pub severity: String,
56    pub example_message: String,
57}
58
59/// Statistics for a specific file.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct FileStats {
62    pub path: String,
63    pub issue_count: usize,
64    pub error_count: usize,
65    pub warning_count: usize,
66    pub info_count: usize,
67}
68
69/// Summary metrics for the report.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct SummaryMetrics {
72    pub total_files: usize,
73    pub files_with_issues: usize,
74    pub clean_file_percentage: f64,
75    pub issues_per_file: f64,
76    pub most_common_rule: Option<String>,
77    pub most_problematic_language: Option<String>,
78}
79
80impl ReportStatistics {
81    /// Compute statistics from a RunResult.
82    pub fn from_run_result(result: &RunResult) -> Self {
83        let mut severity_counts = SeverityCounts::default();
84        let mut by_language: HashMap<String, usize> = HashMap::new();
85        let mut by_tool: HashMap<String, usize> = HashMap::new();
86        let mut by_rule: HashMap<String, RuleStats> = HashMap::new();
87        let mut file_stats: HashMap<String, FileStats> = HashMap::new();
88
89        // Process each issue
90        for issue in &result.issues {
91            // Count by severity
92            match issue.severity {
93                Severity::Error => severity_counts.errors += 1,
94                Severity::Warning => severity_counts.warnings += 1,
95                Severity::Info => severity_counts.info += 1,
96            }
97
98            // Count by language
99            if let Some(ref lang) = issue.language {
100                *by_language.entry(lang.name().to_string()).or_insert(0) += 1;
101            }
102
103            // Count by tool
104            if let Some(ref source) = issue.source {
105                *by_tool.entry(source.clone()).or_insert(0) += 1;
106            }
107
108            // Count by rule code
109            if let Some(ref code) = issue.code {
110                by_rule
111                    .entry(code.clone())
112                    .and_modify(|stats| stats.count += 1)
113                    .or_insert(RuleStats {
114                        code: code.clone(),
115                        count: 1,
116                        severity: format!("{}", issue.severity),
117                        example_message: issue.message.clone(),
118                    });
119            }
120
121            // Track file statistics
122            let path_str = issue.file_path.to_string_lossy().to_string();
123            let file_stat = file_stats.entry(path_str.clone()).or_insert(FileStats {
124                path: path_str,
125                issue_count: 0,
126                error_count: 0,
127                warning_count: 0,
128                info_count: 0,
129            });
130            file_stat.issue_count += 1;
131            match issue.severity {
132                Severity::Error => file_stat.error_count += 1,
133                Severity::Warning => file_stat.warning_count += 1,
134                Severity::Info => file_stat.info_count += 1,
135            }
136        }
137
138        // Get top files by issue count
139        let mut top_files: Vec<FileStats> = file_stats.into_values().collect();
140        top_files.sort_by(|a, b| b.issue_count.cmp(&a.issue_count));
141        top_files.truncate(10); // Top 10 files
142
143        // Find most common rule
144        let most_common_rule = by_rule
145            .iter()
146            .max_by_key(|(_, stats)| stats.count)
147            .map(|(code, _)| code.clone());
148
149        // Find most problematic language
150        let most_problematic_language = by_language
151            .iter()
152            .max_by_key(|(_, count)| *count)
153            .map(|(lang, _)| lang.clone());
154
155        // Calculate summary metrics
156        let total_issues = severity_counts.total();
157        let clean_files = result.total_files.saturating_sub(result.files_with_issues);
158        let clean_file_percentage = if result.total_files > 0 {
159            (clean_files as f64 / result.total_files as f64) * 100.0
160        } else {
161            100.0
162        };
163        let issues_per_file = if result.files_with_issues > 0 {
164            total_issues as f64 / result.files_with_issues as f64
165        } else {
166            0.0
167        };
168
169        let summary = SummaryMetrics {
170            total_files: result.total_files,
171            files_with_issues: result.files_with_issues,
172            clean_file_percentage,
173            issues_per_file,
174            most_common_rule,
175            most_problematic_language,
176        };
177
178        Self {
179            severity_counts,
180            by_language,
181            by_tool,
182            by_rule,
183            top_files,
184            summary,
185        }
186    }
187
188    /// Format statistics as human-readable text.
189    pub fn format_human(&self) -> String {
190        let mut output = String::new();
191
192        // Summary header
193        output.push_str("=== Lint Statistics ===\n\n");
194
195        // Severity counts
196        output.push_str("Severity Breakdown:\n");
197        output.push_str(&format!("  Errors:   {}\n", self.severity_counts.errors));
198        output.push_str(&format!("  Warnings: {}\n", self.severity_counts.warnings));
199        output.push_str(&format!("  Info:     {}\n", self.severity_counts.info));
200        output.push_str(&format!("  Total:    {}\n\n", self.severity_counts.total()));
201
202        // File summary
203        output.push_str("File Summary:\n");
204        output.push_str(&format!(
205            "  Total files:       {}\n",
206            self.summary.total_files
207        ));
208        output.push_str(&format!(
209            "  Files with issues: {}\n",
210            self.summary.files_with_issues
211        ));
212        output.push_str(&format!(
213            "  Clean files:       {:.1}%\n",
214            self.summary.clean_file_percentage
215        ));
216        output.push_str(&format!(
217            "  Issues per file:   {:.1}\n\n",
218            self.summary.issues_per_file
219        ));
220
221        // By language
222        if !self.by_language.is_empty() {
223            output.push_str("Issues by Language:\n");
224            let mut langs: Vec<_> = self.by_language.iter().collect();
225            langs.sort_by(|a, b| b.1.cmp(a.1));
226            for (lang, count) in langs {
227                output.push_str(&format!("  {}: {}\n", lang, count));
228            }
229            output.push('\n');
230        }
231
232        // By tool
233        if !self.by_tool.is_empty() {
234            output.push_str("Issues by Tool:\n");
235            let mut tools: Vec<_> = self.by_tool.iter().collect();
236            tools.sort_by(|a, b| b.1.cmp(a.1));
237            for (tool, count) in tools {
238                output.push_str(&format!("  {}: {}\n", tool, count));
239            }
240            output.push('\n');
241        }
242
243        // Top rules
244        if !self.by_rule.is_empty() {
245            output.push_str("Top Rule Violations:\n");
246            let mut rules: Vec<_> = self.by_rule.values().collect();
247            rules.sort_by(|a, b| b.count.cmp(&a.count));
248            for rule in rules.iter().take(5) {
249                output.push_str(&format!(
250                    "  {} ({}): {} occurrences\n",
251                    rule.code, rule.severity, rule.count
252                ));
253            }
254            output.push('\n');
255        }
256
257        // Top problematic files
258        if !self.top_files.is_empty() {
259            output.push_str("Top Problematic Files:\n");
260            for file in self.top_files.iter().take(5) {
261                output.push_str(&format!(
262                    "  {} - {} issues ({} errors, {} warnings)\n",
263                    file.path, file.issue_count, file.error_count, file.warning_count
264                ));
265            }
266        }
267
268        output
269    }
270
271    /// Format statistics as JSON.
272    pub fn format_json(&self) -> String {
273        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::utils::types::LintIssue;
281    use std::path::PathBuf;
282
283    fn make_issue(severity: Severity, lang: &str, tool: &str, code: &str) -> LintIssue {
284        use crate::Language;
285        let mut issue = LintIssue::new(
286            PathBuf::from("test.rs"),
287            1,
288            "Test message".to_string(),
289            severity,
290        );
291        issue.language = Language::from_name(lang);
292        issue.source = Some(tool.to_string());
293        issue.code = Some(code.to_string());
294        issue
295    }
296
297    #[test]
298    fn test_statistics_from_run_result() {
299        let mut result = RunResult::new();
300        result.total_files = 10;
301        result.files_with_issues = 3;
302        result.add_issue(make_issue(Severity::Error, "rust", "clippy", "E0001"));
303        result.add_issue(make_issue(Severity::Warning, "rust", "clippy", "W0001"));
304        result.add_issue(make_issue(Severity::Warning, "python", "ruff", "W0001"));
305        result.add_issue(make_issue(Severity::Info, "python", "ruff", "I0001"));
306
307        let stats = ReportStatistics::from_run_result(&result);
308
309        assert_eq!(stats.severity_counts.errors, 1);
310        assert_eq!(stats.severity_counts.warnings, 2);
311        assert_eq!(stats.severity_counts.info, 1);
312        assert_eq!(stats.by_language.get("rust"), Some(&2));
313        assert_eq!(stats.by_language.get("python"), Some(&2));
314        assert_eq!(stats.by_tool.get("clippy"), Some(&2));
315        assert_eq!(stats.by_tool.get("ruff"), Some(&2));
316        assert_eq!(stats.summary.total_files, 10);
317        assert_eq!(stats.summary.files_with_issues, 3);
318    }
319
320    #[test]
321    fn test_severity_counts_total() {
322        let counts = SeverityCounts {
323            errors: 5,
324            warnings: 10,
325            info: 3,
326        };
327        assert_eq!(counts.total(), 18);
328    }
329
330    #[test]
331    fn test_format_human() {
332        let stats = ReportStatistics {
333            severity_counts: SeverityCounts {
334                errors: 2,
335                warnings: 5,
336                info: 1,
337            },
338            by_language: [("rust".to_string(), 5), ("python".to_string(), 3)]
339                .into_iter()
340                .collect(),
341            by_tool: [("clippy".to_string(), 5), ("ruff".to_string(), 3)]
342                .into_iter()
343                .collect(),
344            by_rule: HashMap::new(),
345            top_files: vec![],
346            summary: SummaryMetrics {
347                total_files: 10,
348                files_with_issues: 3,
349                clean_file_percentage: 70.0,
350                issues_per_file: 2.7,
351                most_common_rule: None,
352                most_problematic_language: Some("rust".to_string()),
353            },
354        };
355
356        let output = stats.format_human();
357        assert!(output.contains("Errors:   2"));
358        assert!(output.contains("Warnings: 5"));
359        assert!(output.contains("rust: 5"));
360    }
361}