use serde::{Deserialize, Serialize};
pub const SCHEMA_VERSION: &str = "0.1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Confidence {
Certain,
Likely,
Uncertain,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Error,
Warn,
Off,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Attribution {
Introduced,
Inherited,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Category {
DeadCode,
Duplication,
CircularDependency,
Complexity,
Architecture,
DependencyHygiene,
TypeHealth,
Security,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Location {
pub path: camino::Utf8PathBuf,
pub line: u32,
#[serde(default, skip_serializing_if = "is_zero")]
pub column: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_line: Option<u32>,
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Action {
#[serde(rename = "type")]
pub kind: String,
pub description: String,
pub auto_fixable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suppression_comment: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Finding {
pub fingerprint: String,
pub rule: String,
pub category: Category,
pub severity: Severity,
pub confidence: Confidence,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attribution: Option<Attribution>,
pub reason: String,
pub location: Location,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub actions: Vec<Action>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum Report {
Audit(AuditReport),
DeadCode(FindingsReport),
Deps(FindingsReport),
Arch(FindingsReport),
Complexity(FindingsReport),
Dupes(FindingsReport),
Types(FindingsReport),
Security(FindingsReport),
Coverage(FindingsReport),
Metrics(MetricsReport),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MetricsReport {
pub schema_version: String,
pub files: Vec<FileMetrics>,
pub totals: MetricsTotals,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FileMetrics {
pub path: camino::Utf8PathBuf,
pub loc: u32,
pub sloc: u32,
pub comment_lines: u32,
pub blank_lines: u32,
pub functions: u32,
pub total_cyclomatic: u32,
pub max_cyclomatic: u32,
pub maintainability_index: f64,
pub mi_rank: char,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct MetricsTotals {
pub files: usize,
pub loc: u32,
pub sloc: u32,
pub functions: u32,
pub mean_maintainability_index: f64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FindingsReport {
pub schema_version: String,
pub summary: Summary,
pub findings: Vec<Finding>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditReport {
pub schema_version: String,
pub quality_score: u8,
pub summary: Summary,
pub findings: Vec<Finding>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Summary {
pub total: usize,
pub errors: usize,
pub warnings: usize,
pub files_analyzed: usize,
#[serde(default, skip_serializing_if = "is_usize_zero")]
pub introduced: usize,
}
fn is_usize_zero(n: &usize) -> bool {
*n == 0
}
impl Summary {
pub fn from_findings(findings: &[Finding], files_analyzed: usize) -> Self {
let mut s = Summary {
total: findings.len(),
files_analyzed,
..Default::default()
};
for f in findings {
match f.severity {
Severity::Error => s.errors += 1,
Severity::Warn => s.warnings += 1,
Severity::Off => {}
}
if f.attribution == Some(Attribution::Introduced) {
s.introduced += 1;
}
}
s
}
}
pub fn sort_findings(findings: &mut [Finding]) {
findings.sort_by(|a, b| {
a.location
.path
.cmp(&b.location.path)
.then(a.location.line.cmp(&b.location.line))
.then(a.rule.cmp(&b.rule))
.then(a.fingerprint.cmp(&b.fingerprint))
});
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_finding(path: &str, line: u32, rule: &str) -> Finding {
Finding {
fingerprint: format!("{rule}:0000"),
rule: rule.to_string(),
category: Category::DeadCode,
severity: Severity::Error,
confidence: Confidence::Certain,
attribution: None,
reason: "test".into(),
location: Location {
path: path.into(),
line,
column: 0,
end_line: None,
},
actions: vec![],
}
}
#[test]
fn envelope_has_kind_discriminator() {
let report = Report::DeadCode(FindingsReport {
schema_version: SCHEMA_VERSION.into(),
summary: Summary::default(),
findings: vec![],
});
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"kind\":\"dead-code\""));
}
#[test]
fn confidence_serializes_snake_case() {
assert_eq!(
serde_json::to_string(&Confidence::Uncertain).unwrap(),
"\"uncertain\""
);
}
#[test]
fn sort_is_deterministic() {
let mut a = vec![
sample_finding("b.py", 1, "x"),
sample_finding("a.py", 9, "x"),
sample_finding("a.py", 2, "y"),
];
sort_findings(&mut a);
assert_eq!(a[0].location.path, "a.py");
assert_eq!(a[0].location.line, 2);
assert_eq!(a[2].location.path, "b.py");
}
#[test]
fn summary_counts_severities() {
let mut f = sample_finding("a.py", 1, "x");
f.severity = Severity::Warn;
let s = Summary::from_findings(&[sample_finding("a.py", 1, "x"), f], 1);
assert_eq!(s.total, 2);
assert_eq!(s.errors, 1);
assert_eq!(s.warnings, 1);
}
}