use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum DiffRiskRule {
WeakenedTests,
AddedSuppression,
TestOnlyBranch,
DivergedCloneSibling,
AddedDependency,
}
impl DiffRiskRule {
pub const ALL: [Self; 5] = [
Self::WeakenedTests,
Self::AddedSuppression,
Self::TestOnlyBranch,
Self::DivergedCloneSibling,
Self::AddedDependency,
];
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::WeakenedTests => "weakened-tests",
Self::AddedSuppression => "added-suppression",
Self::TestOnlyBranch => "test-only-branch",
Self::DivergedCloneSibling => "diverged-clone-sibling",
Self::AddedDependency => "added-dependency",
}
}
#[must_use]
pub fn sarif_rule_id(self) -> String {
format!("fallow/{}", self.code())
}
#[must_use]
pub const fn description(self) -> &'static str {
match self {
Self::WeakenedTests => "The change weakens tests or a coverage threshold",
Self::AddedSuppression => "The change adds a suppression comment",
Self::TestOnlyBranch => "The change adds a test-environment branch to source code",
Self::DivergedCloneSibling => {
"The change edits one copy of duplicated code but not its siblings"
}
Self::AddedDependency => "The change adds a dependency",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum WeakenedTestsKind {
SkipOnly,
RemovedTests,
AssertionDrop,
CoverageThreshold,
}
impl WeakenedTestsKind {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::SkipOnly => "skip-only",
Self::RemovedTests => "removed-tests",
Self::AssertionDrop => "assertion-drop",
Self::CoverageThreshold => "coverage-threshold",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum DiffRiskSeverity {
Error,
Warn,
}
impl DiffRiskSeverity {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::Error => "error",
Self::Warn => "warn",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DiffRiskLocation {
pub path: String,
pub line: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum DiffRiskActionType {
SuppressLine,
SuppressFile,
SuppressInConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DiffRiskAction {
#[serde(rename = "type")]
pub kind: DiffRiskActionType,
pub auto_fixable: bool,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DiffRiskFinding {
pub rule: DiffRiskRule,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<WeakenedTestsKind>,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<u32>,
pub message: String,
pub evidence: String,
pub severity: DiffRiskSeverity,
pub introduced: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub related: Vec<DiffRiskLocation>,
pub actions: Vec<DiffRiskAction>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DiffRiskReport {
pub findings: Vec<DiffRiskFinding>,
}
impl DiffRiskReport {
#[must_use]
pub fn is_empty(&self) -> bool {
self.findings.is_empty()
}
#[must_use]
pub fn has_errors(&self) -> bool {
self.findings
.iter()
.any(|finding| finding.severity == DiffRiskSeverity::Error)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codes_match_the_serialized_values() {
for kind in [
WeakenedTestsKind::SkipOnly,
WeakenedTestsKind::RemovedTests,
WeakenedTestsKind::AssertionDrop,
WeakenedTestsKind::CoverageThreshold,
] {
assert_eq!(serde_json::json!(kind), kind.code());
}
for severity in [DiffRiskSeverity::Error, DiffRiskSeverity::Warn] {
assert_eq!(serde_json::json!(severity), severity.code());
}
}
#[test]
fn finding_serializes_with_kebab_case_and_skips_empty_fields() {
let finding = DiffRiskFinding {
rule: DiffRiskRule::WeakenedTests,
kind: Some(WeakenedTestsKind::SkipOnly),
path: "src/a.test.ts".to_owned(),
line: Some(3),
message: "m".to_owned(),
evidence: "it.skip".to_owned(),
severity: DiffRiskSeverity::Warn,
introduced: true,
related: Vec::new(),
actions: vec![DiffRiskAction {
kind: DiffRiskActionType::SuppressLine,
auto_fixable: false,
description: "d".to_owned(),
comment: Some("// fallow-ignore-next-line weakened-tests".to_owned()),
}],
};
let value = serde_json::to_value(&finding).expect("serialize");
assert_eq!(value["rule"], "weakened-tests");
assert_eq!(value["kind"], "skip-only");
assert_eq!(value["severity"], "warn");
assert_eq!(value["actions"][0]["type"], "suppress-line");
assert!(value.get("related").is_none());
}
#[test]
fn rule_codes_match_sarif_ids() {
for rule in DiffRiskRule::ALL {
assert_eq!(rule.sarif_rule_id(), format!("fallow/{}", rule.code()));
let value = serde_json::to_value(rule).expect("serialize");
assert_eq!(value, rule.code());
}
}
}