Skip to main content

covy_core/
diagnostics.rs

1use serde::{Deserialize, Serialize};
2use std::collections::{BTreeMap, HashSet};
3
4use crate::model::FileDiff;
5
6/// Severity of a diagnostic issue.
7#[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/// A single diagnostic issue (lint warning, type error, etc.).
25#[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/// Source format of diagnostics data.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42pub enum DiagnosticsFormat {
43    Sarif,
44}
45
46/// Aggregated diagnostics data from one or more reports.
47#[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    /// Merge another DiagnosticsData into this one, deduplicating by fingerprint.
82    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    /// Return issues that fall on changed lines in the given diffs.
97    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"), // duplicate
150                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}