1use serde::{Deserialize, Serialize};
16
17pub const SCHEMA_VERSION: &str = "0.1";
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum Confidence {
27 Certain,
30 Likely,
32 Uncertain,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum Severity {
40 Error,
42 Warn,
44 Off,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum Attribution {
53 Introduced,
54 Inherited,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
60#[serde(rename_all = "kebab-case")]
61pub enum Category {
62 DeadCode,
63 Duplication,
64 CircularDependency,
65 Complexity,
66 Architecture,
67 DependencyHygiene,
68 TypeHealth,
70 Security,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
76pub struct Location {
77 pub path: camino::Utf8PathBuf,
78 pub line: u32,
79 #[serde(default, skip_serializing_if = "is_zero")]
80 pub column: u32,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub end_line: Option<u32>,
83}
84
85fn is_zero(n: &u32) -> bool {
86 *n == 0
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct Action {
93 #[serde(rename = "type")]
95 pub kind: String,
96 pub description: String,
97 pub auto_fixable: bool,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub suppression_comment: Option<String>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct Finding {
107 pub fingerprint: String,
110 pub rule: String,
112 pub category: Category,
113 pub severity: Severity,
114 pub confidence: Confidence,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub attribution: Option<Attribution>,
117 pub reason: String,
119 pub location: Location,
120 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub actions: Vec<Action>,
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128#[serde(tag = "kind", rename_all = "kebab-case")]
129pub enum Report {
130 Audit(AuditReport),
132 DeadCode(FindingsReport),
134 Deps(FindingsReport),
136 Arch(FindingsReport),
138 Complexity(FindingsReport),
140 Dupes(FindingsReport),
142 Types(FindingsReport),
144 Security(FindingsReport),
146 Coverage(FindingsReport),
148 Metrics(MetricsReport),
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct MetricsReport {
155 pub schema_version: String,
156 pub files: Vec<FileMetrics>,
157 pub totals: MetricsTotals,
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162pub struct FileMetrics {
163 pub path: camino::Utf8PathBuf,
164 pub loc: u32,
166 pub sloc: u32,
168 pub comment_lines: u32,
169 pub blank_lines: u32,
170 pub functions: u32,
171 pub total_cyclomatic: u32,
173 pub max_cyclomatic: u32,
174 pub maintainability_index: f64,
176 pub mi_rank: char,
178}
179
180#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
182pub struct MetricsTotals {
183 pub files: usize,
184 pub loc: u32,
185 pub sloc: u32,
186 pub functions: u32,
187 pub mean_maintainability_index: f64,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct FindingsReport {
194 pub schema_version: String,
195 pub summary: Summary,
196 pub findings: Vec<Finding>,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct AuditReport {
202 pub schema_version: String,
203 pub quality_score: u8,
205 pub summary: Summary,
206 pub findings: Vec<Finding>,
207}
208
209#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
211pub struct Summary {
212 pub total: usize,
213 pub errors: usize,
214 pub warnings: usize,
215 pub files_analyzed: usize,
216 #[serde(default, skip_serializing_if = "is_usize_zero")]
217 pub introduced: usize,
218}
219
220fn is_usize_zero(n: &usize) -> bool {
221 *n == 0
222}
223
224impl Summary {
225 pub fn from_findings(findings: &[Finding], files_analyzed: usize) -> Self {
227 let mut s = Summary {
228 total: findings.len(),
229 files_analyzed,
230 ..Default::default()
231 };
232 for f in findings {
233 match f.severity {
234 Severity::Error => s.errors += 1,
235 Severity::Warn => s.warnings += 1,
236 Severity::Off => {}
237 }
238 if f.attribution == Some(Attribution::Introduced) {
239 s.introduced += 1;
240 }
241 }
242 s
243 }
244}
245
246pub fn sort_findings(findings: &mut [Finding]) {
249 findings.sort_by(|a, b| {
250 a.location
251 .path
252 .cmp(&b.location.path)
253 .then(a.location.line.cmp(&b.location.line))
254 .then(a.rule.cmp(&b.rule))
255 .then(a.fingerprint.cmp(&b.fingerprint))
256 });
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 fn sample_finding(path: &str, line: u32, rule: &str) -> Finding {
264 Finding {
265 fingerprint: format!("{rule}:0000"),
266 rule: rule.to_string(),
267 category: Category::DeadCode,
268 severity: Severity::Error,
269 confidence: Confidence::Certain,
270 attribution: None,
271 reason: "test".into(),
272 location: Location {
273 path: path.into(),
274 line,
275 column: 0,
276 end_line: None,
277 },
278 actions: vec![],
279 }
280 }
281
282 #[test]
283 fn envelope_has_kind_discriminator() {
284 let report = Report::DeadCode(FindingsReport {
285 schema_version: SCHEMA_VERSION.into(),
286 summary: Summary::default(),
287 findings: vec![],
288 });
289 let json = serde_json::to_string(&report).unwrap();
290 assert!(json.contains("\"kind\":\"dead-code\""));
291 }
292
293 #[test]
294 fn confidence_serializes_snake_case() {
295 assert_eq!(
296 serde_json::to_string(&Confidence::Uncertain).unwrap(),
297 "\"uncertain\""
298 );
299 }
300
301 #[test]
302 fn sort_is_deterministic() {
303 let mut a = vec![
304 sample_finding("b.py", 1, "x"),
305 sample_finding("a.py", 9, "x"),
306 sample_finding("a.py", 2, "y"),
307 ];
308 sort_findings(&mut a);
309 assert_eq!(a[0].location.path, "a.py");
310 assert_eq!(a[0].location.line, 2);
311 assert_eq!(a[2].location.path, "b.py");
312 }
313
314 #[test]
315 fn summary_counts_severities() {
316 let mut f = sample_finding("a.py", 1, "x");
317 f.severity = Severity::Warn;
318 let s = Summary::from_findings(&[sample_finding("a.py", 1, "x"), f], 1);
319 assert_eq!(s.total, 2);
320 assert_eq!(s.errors, 1);
321 assert_eq!(s.warnings, 1);
322 }
323}