1use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
13#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
14#[serde(rename_all = "kebab-case")]
15pub enum DiffRiskRule {
16 WeakenedTests,
18 AddedSuppression,
20 TestOnlyBranch,
22 DivergedCloneSibling,
24 AddedDependency,
26}
27
28impl DiffRiskRule {
29 pub const ALL: [Self; 5] = [
31 Self::WeakenedTests,
32 Self::AddedSuppression,
33 Self::TestOnlyBranch,
34 Self::DivergedCloneSibling,
35 Self::AddedDependency,
36 ];
37
38 #[must_use]
41 pub const fn code(self) -> &'static str {
42 match self {
43 Self::WeakenedTests => "weakened-tests",
44 Self::AddedSuppression => "added-suppression",
45 Self::TestOnlyBranch => "test-only-branch",
46 Self::DivergedCloneSibling => "diverged-clone-sibling",
47 Self::AddedDependency => "added-dependency",
48 }
49 }
50
51 #[must_use]
53 pub fn sarif_rule_id(self) -> String {
54 format!("fallow/{}", self.code())
55 }
56
57 #[must_use]
59 pub const fn description(self) -> &'static str {
60 match self {
61 Self::WeakenedTests => "The change weakens tests or a coverage threshold",
62 Self::AddedSuppression => "The change adds a suppression comment",
63 Self::TestOnlyBranch => "The change adds a test-environment branch to source code",
64 Self::DivergedCloneSibling => {
65 "The change edits one copy of duplicated code but not its siblings"
66 }
67 Self::AddedDependency => "The change adds a dependency",
68 }
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
74#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
75#[serde(rename_all = "kebab-case")]
76pub enum WeakenedTestsKind {
77 SkipOnly,
79 RemovedTests,
81 AssertionDrop,
83 CoverageThreshold,
85}
86
87impl WeakenedTestsKind {
88 #[must_use]
90 pub const fn code(self) -> &'static str {
91 match self {
92 Self::SkipOnly => "skip-only",
93 Self::RemovedTests => "removed-tests",
94 Self::AssertionDrop => "assertion-drop",
95 Self::CoverageThreshold => "coverage-threshold",
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
103#[serde(rename_all = "kebab-case")]
104pub enum DiffRiskSeverity {
105 Error,
107 Warn,
109}
110
111impl DiffRiskSeverity {
112 #[must_use]
114 pub const fn code(self) -> &'static str {
115 match self {
116 Self::Error => "error",
117 Self::Warn => "warn",
118 }
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
126pub struct DiffRiskLocation {
127 pub path: String,
129 pub line: u32,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
136#[serde(rename_all = "kebab-case")]
137pub enum DiffRiskActionType {
138 SuppressLine,
140 SuppressFile,
142 SuppressInConfig,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
150pub struct DiffRiskAction {
151 #[serde(rename = "type")]
153 pub kind: DiffRiskActionType,
154 pub auto_fixable: bool,
156 pub description: String,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub comment: Option<String>,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
166pub struct DiffRiskFinding {
167 pub rule: DiffRiskRule,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub kind: Option<WeakenedTestsKind>,
172 pub path: String,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub line: Option<u32>,
178 pub message: String,
180 pub evidence: String,
183 pub severity: DiffRiskSeverity,
185 pub introduced: bool,
187 #[serde(default, skip_serializing_if = "Vec::is_empty")]
189 pub related: Vec<DiffRiskLocation>,
190 pub actions: Vec<DiffRiskAction>,
192}
193
194#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
196#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
197pub struct DiffRiskReport {
198 pub findings: Vec<DiffRiskFinding>,
200}
201
202impl DiffRiskReport {
203 #[must_use]
205 pub fn is_empty(&self) -> bool {
206 self.findings.is_empty()
207 }
208
209 #[must_use]
211 pub fn has_errors(&self) -> bool {
212 self.findings
213 .iter()
214 .any(|finding| finding.severity == DiffRiskSeverity::Error)
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn codes_match_the_serialized_values() {
224 for kind in [
225 WeakenedTestsKind::SkipOnly,
226 WeakenedTestsKind::RemovedTests,
227 WeakenedTestsKind::AssertionDrop,
228 WeakenedTestsKind::CoverageThreshold,
229 ] {
230 assert_eq!(serde_json::json!(kind), kind.code());
231 }
232 for severity in [DiffRiskSeverity::Error, DiffRiskSeverity::Warn] {
233 assert_eq!(serde_json::json!(severity), severity.code());
234 }
235 }
236
237 #[test]
238 fn finding_serializes_with_kebab_case_and_skips_empty_fields() {
239 let finding = DiffRiskFinding {
240 rule: DiffRiskRule::WeakenedTests,
241 kind: Some(WeakenedTestsKind::SkipOnly),
242 path: "src/a.test.ts".to_owned(),
243 line: Some(3),
244 message: "m".to_owned(),
245 evidence: "it.skip".to_owned(),
246 severity: DiffRiskSeverity::Warn,
247 introduced: true,
248 related: Vec::new(),
249 actions: vec![DiffRiskAction {
250 kind: DiffRiskActionType::SuppressLine,
251 auto_fixable: false,
252 description: "d".to_owned(),
253 comment: Some("// fallow-ignore-next-line weakened-tests".to_owned()),
254 }],
255 };
256 let value = serde_json::to_value(&finding).expect("serialize");
257 assert_eq!(value["rule"], "weakened-tests");
258 assert_eq!(value["kind"], "skip-only");
259 assert_eq!(value["severity"], "warn");
260 assert_eq!(value["actions"][0]["type"], "suppress-line");
261 assert!(value.get("related").is_none());
262 }
263
264 #[test]
265 fn rule_codes_match_sarif_ids() {
266 for rule in DiffRiskRule::ALL {
267 assert_eq!(rule.sarif_rule_id(), format!("fallow/{}", rule.code()));
268 let value = serde_json::to_value(rule).expect("serialize");
269 assert_eq!(value, rule.code());
270 }
271 }
272}