1use serde::Serialize;
2use serde_json::Value;
3
4#[derive(Debug, Clone, Serialize)]
8#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
9#[cfg_attr(
10 feature = "schema",
11 schemars(title = "fallow --format codeclimate / gitlab-codequality")
12)]
13#[serde(transparent)]
14#[allow(
15 dead_code,
16 reason = "schema-source-of-truth wrapper: runtime emits a Vec<CodeClimateIssue> directly; this newtype exists so schemars can title and document the bare-array shape for the drift gate."
17)]
18pub struct CodeClimateOutput(pub Vec<CodeClimateIssue>);
19
20#[derive(Debug, Clone, Serialize)]
22#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
23pub struct CodeClimateIssue {
24 #[serde(rename = "type")]
25 pub kind: CodeClimateIssueKind,
26 pub check_name: String,
27 pub description: String,
28 pub categories: Vec<String>,
29 pub severity: CodeClimateSeverity,
30 pub fingerprint: String,
31 pub location: CodeClimateLocation,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub owner: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub group: Option<String>,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
44#[serde(rename_all = "lowercase")]
45pub enum CodeClimateIssueKind {
46 Issue,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
52#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
53#[serde(rename_all = "lowercase")]
54pub enum CodeClimateSeverity {
55 #[allow(
60 dead_code,
61 reason = "schema-source-of-truth: documents the full CodeClimate severity spec; runtime never produces this variant today."
62 )]
63 Info,
64 Minor,
66 Major,
68 Critical,
70 #[allow(
73 dead_code,
74 reason = "schema-source-of-truth: documents the full CodeClimate severity spec; runtime never produces this variant today."
75 )]
76 Blocker,
77}
78
79#[derive(Debug, Clone, Serialize)]
81#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
82pub struct CodeClimateLocation {
83 pub path: String,
85 pub lines: CodeClimateLines,
88}
89
90#[derive(Debug, Clone, Copy, Serialize)]
92#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
93pub struct CodeClimateLines {
94 pub begin: u32,
96}
97
98#[derive(Debug, Clone, Copy)]
103pub struct CodeClimateIssueInput<'a> {
104 pub check_name: &'a str,
105 pub description: &'a str,
106 pub severity: CodeClimateSeverity,
107 pub category: &'a str,
108 pub path: &'a str,
109 pub begin_line: Option<u32>,
110 pub fingerprint: &'a str,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum CodeClimateAnnotationField {
116 Owner,
118 Group,
121}
122
123#[must_use]
128pub fn codeclimate_fingerprint_hash(parts: &[&str]) -> String {
129 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
130 for part in parts {
131 for byte in part.bytes() {
132 hash ^= u64::from(byte);
133 hash = hash.wrapping_mul(0x0100_0000_01b3);
134 }
135 hash ^= 0xff;
136 hash = hash.wrapping_mul(0x0100_0000_01b3);
137 }
138 format!("{hash:016x}")
139}
140
141#[must_use]
143pub fn build_codeclimate_issue(input: CodeClimateIssueInput<'_>) -> CodeClimateIssue {
144 CodeClimateIssue {
145 kind: CodeClimateIssueKind::Issue,
146 check_name: input.check_name.to_string(),
147 description: input.description.to_string(),
148 categories: vec![input.category.to_string()],
149 severity: input.severity,
150 fingerprint: input.fingerprint.to_string(),
151 location: CodeClimateLocation {
152 path: input.path.to_string(),
153 lines: CodeClimateLines {
154 begin: input.begin_line.unwrap_or(1),
155 },
156 },
157 owner: None,
158 group: None,
159 }
160}
161
162#[must_use]
167#[expect(
168 clippy::expect_used,
169 reason = "CodeClimateIssue contains only infallibly serializable fields"
170)]
171pub fn codeclimate_issues_to_value(issues: &[CodeClimateIssue]) -> Value {
172 serde_json::to_value(issues).expect("CodeClimateIssue serializes infallibly")
173}
174
175pub fn annotate_codeclimate_issues(
180 issues: &mut [CodeClimateIssue],
181 field: CodeClimateAnnotationField,
182 mut value_for_path: impl FnMut(&str) -> String,
183) {
184 for issue in issues {
185 let value = value_for_path(&issue.location.path);
186 match field {
187 CodeClimateAnnotationField::Owner => issue.owner = Some(value),
188 CodeClimateAnnotationField::Group => issue.group = Some(value),
189 }
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
198 fn codeclimate_issue_serializes_spec_shape() {
199 let issue = build_codeclimate_issue(CodeClimateIssueInput {
200 check_name: "fallow/test",
201 description: "description",
202 category: "Bug Risk",
203 severity: CodeClimateSeverity::Major,
204 fingerprint: "abc123",
205 path: "src/app.ts",
206 begin_line: Some(7),
207 });
208
209 let value = serde_json::to_value(issue).expect("CodeClimate issue serializes");
210 assert_eq!(value["type"], "issue");
211 assert_eq!(value["severity"], "major");
212 assert_eq!(value["location"]["lines"]["begin"], 7);
213 }
214
215 #[test]
216 fn output_serializes_as_bare_array() {
217 let output = CodeClimateOutput(Vec::new());
218 let value = serde_json::to_value(output).expect("CodeClimate output serializes");
219 assert!(value.is_array());
220 }
221
222 #[test]
223 fn codeclimate_issues_to_value_serializes_bare_array() {
224 let value = codeclimate_issues_to_value(&[]);
225 assert!(value.is_array());
226 }
227
228 #[test]
229 fn build_codeclimate_issue_defaults_missing_line_to_one() {
230 let issue = build_codeclimate_issue(CodeClimateIssueInput {
231 check_name: "fallow/test",
232 description: "description",
233 category: "Bug Risk",
234 severity: CodeClimateSeverity::Minor,
235 fingerprint: "abc123",
236 path: "src/app.ts",
237 begin_line: None,
238 });
239
240 assert_eq!(issue.location.lines.begin, 1);
241 }
242
243 #[test]
244 fn codeclimate_fingerprint_parts_are_separated() {
245 assert_ne!(
246 codeclimate_fingerprint_hash(&["ab", "c"]),
247 codeclimate_fingerprint_hash(&["a", "bc"])
248 );
249 }
250
251 #[test]
252 fn annotate_codeclimate_issues_adds_owner_from_location_path() {
253 let mut issues = vec![build_codeclimate_issue(CodeClimateIssueInput {
254 check_name: "fallow/test",
255 description: "description",
256 category: "Bug Risk",
257 severity: CodeClimateSeverity::Minor,
258 fingerprint: "abc123",
259 path: "src/app.ts",
260 begin_line: Some(3),
261 })];
262
263 annotate_codeclimate_issues(&mut issues, CodeClimateAnnotationField::Owner, |path| {
264 format!("team:{path}")
265 });
266 let value = codeclimate_issues_to_value(&issues);
267
268 assert_eq!(value[0]["owner"], "team:src/app.ts");
269 }
270
271 #[test]
272 fn annotate_codeclimate_issues_adds_group_from_location_path() {
273 let mut issues = vec![build_codeclimate_issue(CodeClimateIssueInput {
274 check_name: "fallow/test",
275 description: "description",
276 category: "Bug Risk",
277 severity: CodeClimateSeverity::Minor,
278 fingerprint: "abc123",
279 path: "src/app.ts",
280 begin_line: Some(3),
281 })];
282
283 annotate_codeclimate_issues(&mut issues, CodeClimateAnnotationField::Group, |path| {
284 format!("group:{path}")
285 });
286 let value = codeclimate_issues_to_value(&issues);
287
288 assert_eq!(value[0]["group"], "group:src/app.ts");
289 assert!(value[0].get("owner").is_none());
290 }
291}