1use serde::{Deserialize, Serialize};
2use std::collections::{BTreeMap, HashSet};
3
4use crate::model::FileDiff;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
8pub enum Severity {
9 Error,
10 Warning,
11 Note,
12}
13
14impl std::fmt::Display for Severity {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 match self {
17 Severity::Error => write!(f, "error"),
18 Severity::Warning => write!(f, "warning"),
19 Severity::Note => write!(f, "note"),
20 }
21 }
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Issue {
27 pub path: String,
28 pub line: u32,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub column: Option<u32>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub end_line: Option<u32>,
33 pub severity: Severity,
34 pub rule_id: String,
35 pub message: String,
36 pub source: String,
37 pub fingerprint: String,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42pub enum DiagnosticsFormat {
43 Sarif,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct DiagnosticsData {
49 pub issues_by_file: BTreeMap<String, Vec<Issue>>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub format: Option<DiagnosticsFormat>,
52 pub timestamp: u64,
53}
54
55impl DiagnosticsData {
56 pub fn new() -> Self {
57 Self {
58 issues_by_file: BTreeMap::new(),
59 format: None,
60 timestamp: std::time::SystemTime::now()
61 .duration_since(std::time::UNIX_EPOCH)
62 .unwrap_or_default()
63 .as_secs(),
64 }
65 }
66
67 pub fn total_issues(&self) -> usize {
68 self.issues_by_file.values().map(|v| v.len()).sum()
69 }
70
71 pub fn count_by_severity(&self) -> BTreeMap<Severity, usize> {
72 let mut counts = BTreeMap::new();
73 for issues in self.issues_by_file.values() {
74 for issue in issues {
75 *counts.entry(issue.severity).or_insert(0) += 1;
76 }
77 }
78 counts
79 }
80
81 pub fn merge(&mut self, other: &DiagnosticsData) {
83 for (path, issues) in &other.issues_by_file {
84 let existing = self.issues_by_file.entry(path.clone()).or_default();
85 let mut seen: HashSet<String> =
86 HashSet::with_capacity(existing.len().saturating_add(issues.len()));
87 seen.extend(existing.iter().map(|issue| issue.fingerprint.clone()));
88 for issue in issues {
89 if seen.insert(issue.fingerprint.clone()) {
90 existing.push(issue.clone());
91 }
92 }
93 }
94 }
95
96 pub fn issues_on_changed_lines(&self, diffs: &[FileDiff]) -> Vec<&Issue> {
98 let mut result = Vec::new();
99 for diff in diffs {
100 if let Some(issues) = self.issues_by_file.get(&diff.path) {
101 for issue in issues {
102 if diff.changed_lines.contains(issue.line) {
103 result.push(issue);
104 }
105 }
106 }
107 }
108 result
109 }
110}
111
112impl Default for DiagnosticsData {
113 fn default() -> Self {
114 Self::new()
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use roaring::RoaringBitmap;
122
123 fn make_issue(path: &str, line: u32, severity: Severity, fingerprint: &str) -> Issue {
124 Issue {
125 path: path.to_string(),
126 line,
127 column: None,
128 end_line: None,
129 severity,
130 rule_id: "test-rule".to_string(),
131 message: "test message".to_string(),
132 source: "test-tool".to_string(),
133 fingerprint: fingerprint.to_string(),
134 }
135 }
136
137 #[test]
138 fn test_merge_dedup() {
139 let mut d1 = DiagnosticsData::new();
140 d1.issues_by_file.insert(
141 "src/main.rs".to_string(),
142 vec![make_issue("src/main.rs", 10, Severity::Error, "fp1")],
143 );
144
145 let mut d2 = DiagnosticsData::new();
146 d2.issues_by_file.insert(
147 "src/main.rs".to_string(),
148 vec![
149 make_issue("src/main.rs", 10, Severity::Error, "fp1"), make_issue("src/main.rs", 20, Severity::Warning, "fp2"),
151 ],
152 );
153
154 d1.merge(&d2);
155 assert_eq!(d1.issues_by_file["src/main.rs"].len(), 2);
156 }
157
158 #[test]
159 fn test_count_by_severity() {
160 let mut data = DiagnosticsData::new();
161 data.issues_by_file.insert(
162 "a.rs".to_string(),
163 vec![
164 make_issue("a.rs", 1, Severity::Error, "fp1"),
165 make_issue("a.rs", 2, Severity::Error, "fp2"),
166 make_issue("a.rs", 3, Severity::Warning, "fp3"),
167 ],
168 );
169 data.issues_by_file.insert(
170 "b.rs".to_string(),
171 vec![make_issue("b.rs", 1, Severity::Note, "fp4")],
172 );
173
174 let counts = data.count_by_severity();
175 assert_eq!(counts[&Severity::Error], 2);
176 assert_eq!(counts[&Severity::Warning], 1);
177 assert_eq!(counts[&Severity::Note], 1);
178 }
179
180 #[test]
181 fn test_issues_on_changed_lines() {
182 let mut data = DiagnosticsData::new();
183 data.issues_by_file.insert(
184 "src/main.rs".to_string(),
185 vec![
186 make_issue("src/main.rs", 5, Severity::Error, "fp1"),
187 make_issue("src/main.rs", 10, Severity::Warning, "fp2"),
188 make_issue("src/main.rs", 20, Severity::Note, "fp3"),
189 ],
190 );
191
192 let mut changed = RoaringBitmap::new();
193 changed.insert(5);
194 changed.insert(10);
195 let diffs = vec![crate::model::FileDiff {
196 path: "src/main.rs".to_string(),
197 old_path: None,
198 status: crate::model::DiffStatus::Modified,
199 changed_lines: changed,
200 }];
201
202 let on_changed = data.issues_on_changed_lines(&diffs);
203 assert_eq!(on_changed.len(), 2);
204 assert_eq!(on_changed[0].line, 5);
205 assert_eq!(on_changed[1].line, 10);
206 }
207}