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")]
26 pub kind: CodeClimateIssueKind,
27 pub check_name: String,
29 pub description: String,
31 pub categories: Vec<String>,
33 pub severity: CodeClimateSeverity,
35 pub fingerprint: String,
37 pub location: CodeClimateLocation,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
43 pub other_locations: Vec<CodeClimateLocation>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub owner: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub group: Option<String>,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
55#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56#[serde(rename_all = "lowercase")]
57pub enum CodeClimateIssueKind {
58 Issue,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
64#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
65#[serde(rename_all = "lowercase")]
66pub enum CodeClimateSeverity {
67 #[allow(
72 dead_code,
73 reason = "schema-source-of-truth: documents the full CodeClimate severity spec; runtime never produces this variant today."
74 )]
75 Info,
76 Minor,
78 Major,
80 Critical,
82 #[allow(
85 dead_code,
86 reason = "schema-source-of-truth: documents the full CodeClimate severity spec; runtime never produces this variant today."
87 )]
88 Blocker,
89}
90
91#[derive(Debug, Clone, Serialize)]
93#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
94pub struct CodeClimateLocation {
95 pub path: String,
97 pub lines: CodeClimateLines,
100}
101
102#[derive(Debug, Clone, Copy, Serialize)]
104#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
105pub struct CodeClimateLines {
106 pub begin: u32,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub end: Option<u32>,
111}
112
113#[derive(Debug, Clone, Copy)]
118pub struct CodeClimateIssueInput<'a> {
119 pub check_name: &'a str,
121 pub description: &'a str,
123 pub severity: CodeClimateSeverity,
125 pub category: &'a str,
127 pub path: &'a str,
129 pub begin_line: Option<u32>,
131 pub fingerprint: &'a str,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum CodeClimateAnnotationField {
138 Owner,
140 Group,
143}
144
145#[must_use]
150pub fn codeclimate_fingerprint_hash(parts: &[&str]) -> String {
151 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
152 for part in parts {
153 for byte in part.bytes() {
154 hash ^= u64::from(byte);
155 hash = hash.wrapping_mul(0x0100_0000_01b3);
156 }
157 hash ^= 0xff;
158 hash = hash.wrapping_mul(0x0100_0000_01b3);
159 }
160 format!("{hash:016x}")
161}
162
163#[must_use]
165pub fn build_codeclimate_issue(input: CodeClimateIssueInput<'_>) -> CodeClimateIssue {
166 CodeClimateIssue {
167 kind: CodeClimateIssueKind::Issue,
168 check_name: input.check_name.to_string(),
169 description: input.description.to_string(),
170 categories: vec![input.category.to_string()],
171 severity: input.severity,
172 fingerprint: input.fingerprint.to_string(),
173 location: CodeClimateLocation {
174 path: input.path.to_string(),
175 lines: CodeClimateLines {
176 begin: input.begin_line.unwrap_or(1),
177 end: None,
178 },
179 },
180 other_locations: Vec::new(),
181 owner: None,
182 group: None,
183 }
184}
185
186#[must_use]
191#[expect(
192 clippy::expect_used,
193 reason = "CodeClimateIssue contains only infallibly serializable fields"
194)]
195pub fn codeclimate_issues_to_value(issues: &[CodeClimateIssue]) -> Value {
196 serde_json::to_value(issues).expect("CodeClimateIssue serializes infallibly")
197}
198
199pub fn annotate_codeclimate_issues(
204 issues: &mut [CodeClimateIssue],
205 field: CodeClimateAnnotationField,
206 mut value_for_path: impl FnMut(&str) -> String,
207) {
208 for issue in issues {
209 let value = value_for_path(&issue.location.path);
210 match field {
211 CodeClimateAnnotationField::Owner => issue.owner = Some(value),
212 CodeClimateAnnotationField::Group => issue.group = Some(value),
213 }
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn codeclimate_issue_serializes_spec_shape() {
223 let issue = build_codeclimate_issue(CodeClimateIssueInput {
224 check_name: "fallow/test",
225 description: "description",
226 category: "Bug Risk",
227 severity: CodeClimateSeverity::Major,
228 fingerprint: "abc123",
229 path: "src/app.ts",
230 begin_line: Some(7),
231 });
232
233 let value = serde_json::to_value(issue).expect("CodeClimate issue serializes");
234 assert_eq!(value["type"], "issue");
235 assert_eq!(value["severity"], "major");
236 assert_eq!(value["location"]["lines"]["begin"], 7);
237 assert!(value["location"]["lines"].get("end").is_none());
238 assert!(value.get("other_locations").is_none());
239 }
240
241 #[test]
242 fn output_serializes_as_bare_array() {
243 let output = CodeClimateOutput(Vec::new());
244 let value = serde_json::to_value(output).expect("CodeClimate output serializes");
245 assert!(value.is_array());
246 }
247
248 #[test]
249 fn codeclimate_issues_to_value_serializes_bare_array() {
250 let value = codeclimate_issues_to_value(&[]);
251 assert!(value.is_array());
252 }
253
254 #[test]
255 fn build_codeclimate_issue_defaults_missing_line_to_one() {
256 let issue = build_codeclimate_issue(CodeClimateIssueInput {
257 check_name: "fallow/test",
258 description: "description",
259 category: "Bug Risk",
260 severity: CodeClimateSeverity::Minor,
261 fingerprint: "abc123",
262 path: "src/app.ts",
263 begin_line: None,
264 });
265
266 assert_eq!(issue.location.lines.begin, 1);
267 }
268
269 #[test]
270 fn codeclimate_fingerprint_hash_is_deterministic_16_hex() {
271 let a = codeclimate_fingerprint_hash(&["src/index.ts", "FEATURE_X", "3"]);
272 let b = codeclimate_fingerprint_hash(&["src/index.ts", "FEATURE_X", "3"]);
273 assert_eq!(a, b);
274 assert_eq!(a.len(), 16);
275 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
276 assert_ne!(
278 a,
279 codeclimate_fingerprint_hash(&["FEATURE_X", "src/index.ts", "3"])
280 );
281 }
282
283 #[test]
284 fn codeclimate_fingerprint_parts_are_separated() {
285 assert_ne!(
286 codeclimate_fingerprint_hash(&["ab", "c"]),
287 codeclimate_fingerprint_hash(&["a", "bc"])
288 );
289 }
290
291 #[test]
292 fn annotate_codeclimate_issues_adds_owner_from_location_path() {
293 let mut issues = vec![build_codeclimate_issue(CodeClimateIssueInput {
294 check_name: "fallow/test",
295 description: "description",
296 category: "Bug Risk",
297 severity: CodeClimateSeverity::Minor,
298 fingerprint: "abc123",
299 path: "src/app.ts",
300 begin_line: Some(3),
301 })];
302
303 annotate_codeclimate_issues(&mut issues, CodeClimateAnnotationField::Owner, |path| {
304 format!("team:{path}")
305 });
306 let value = codeclimate_issues_to_value(&issues);
307
308 assert_eq!(value[0]["owner"], "team:src/app.ts");
309 }
310
311 #[test]
312 fn annotate_codeclimate_issues_adds_group_from_location_path() {
313 let mut issues = vec![build_codeclimate_issue(CodeClimateIssueInput {
314 check_name: "fallow/test",
315 description: "description",
316 category: "Bug Risk",
317 severity: CodeClimateSeverity::Minor,
318 fingerprint: "abc123",
319 path: "src/app.ts",
320 begin_line: Some(3),
321 })];
322
323 annotate_codeclimate_issues(&mut issues, CodeClimateAnnotationField::Group, |path| {
324 format!("group:{path}")
325 });
326 let value = codeclimate_issues_to_value(&issues);
327
328 assert_eq!(value[0]["group"], "group:src/app.ts");
329 assert!(value[0].get("owner").is_none());
330 }
331}