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 = "Option::is_none")]
41 pub owner: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub group: Option<String>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
50#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
51#[serde(rename_all = "lowercase")]
52pub enum CodeClimateIssueKind {
53 Issue,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60#[serde(rename_all = "lowercase")]
61pub enum CodeClimateSeverity {
62 #[allow(
67 dead_code,
68 reason = "schema-source-of-truth: documents the full CodeClimate severity spec; runtime never produces this variant today."
69 )]
70 Info,
71 Minor,
73 Major,
75 Critical,
77 #[allow(
80 dead_code,
81 reason = "schema-source-of-truth: documents the full CodeClimate severity spec; runtime never produces this variant today."
82 )]
83 Blocker,
84}
85
86#[derive(Debug, Clone, Serialize)]
88#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
89pub struct CodeClimateLocation {
90 pub path: String,
92 pub lines: CodeClimateLines,
95}
96
97#[derive(Debug, Clone, Copy, Serialize)]
99#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
100pub struct CodeClimateLines {
101 pub begin: u32,
103}
104
105#[derive(Debug, Clone, Copy)]
110pub struct CodeClimateIssueInput<'a> {
111 pub check_name: &'a str,
113 pub description: &'a str,
115 pub severity: CodeClimateSeverity,
117 pub category: &'a str,
119 pub path: &'a str,
121 pub begin_line: Option<u32>,
123 pub fingerprint: &'a str,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum CodeClimateAnnotationField {
130 Owner,
132 Group,
135}
136
137#[must_use]
142pub fn codeclimate_fingerprint_hash(parts: &[&str]) -> String {
143 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
144 for part in parts {
145 for byte in part.bytes() {
146 hash ^= u64::from(byte);
147 hash = hash.wrapping_mul(0x0100_0000_01b3);
148 }
149 hash ^= 0xff;
150 hash = hash.wrapping_mul(0x0100_0000_01b3);
151 }
152 format!("{hash:016x}")
153}
154
155#[must_use]
157pub fn build_codeclimate_issue(input: CodeClimateIssueInput<'_>) -> CodeClimateIssue {
158 CodeClimateIssue {
159 kind: CodeClimateIssueKind::Issue,
160 check_name: input.check_name.to_string(),
161 description: input.description.to_string(),
162 categories: vec![input.category.to_string()],
163 severity: input.severity,
164 fingerprint: input.fingerprint.to_string(),
165 location: CodeClimateLocation {
166 path: input.path.to_string(),
167 lines: CodeClimateLines {
168 begin: input.begin_line.unwrap_or(1),
169 },
170 },
171 owner: None,
172 group: None,
173 }
174}
175
176#[must_use]
181#[expect(
182 clippy::expect_used,
183 reason = "CodeClimateIssue contains only infallibly serializable fields"
184)]
185pub fn codeclimate_issues_to_value(issues: &[CodeClimateIssue]) -> Value {
186 serde_json::to_value(issues).expect("CodeClimateIssue serializes infallibly")
187}
188
189pub fn annotate_codeclimate_issues(
194 issues: &mut [CodeClimateIssue],
195 field: CodeClimateAnnotationField,
196 mut value_for_path: impl FnMut(&str) -> String,
197) {
198 for issue in issues {
199 let value = value_for_path(&issue.location.path);
200 match field {
201 CodeClimateAnnotationField::Owner => issue.owner = Some(value),
202 CodeClimateAnnotationField::Group => issue.group = Some(value),
203 }
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn codeclimate_issue_serializes_spec_shape() {
213 let issue = build_codeclimate_issue(CodeClimateIssueInput {
214 check_name: "fallow/test",
215 description: "description",
216 category: "Bug Risk",
217 severity: CodeClimateSeverity::Major,
218 fingerprint: "abc123",
219 path: "src/app.ts",
220 begin_line: Some(7),
221 });
222
223 let value = serde_json::to_value(issue).expect("CodeClimate issue serializes");
224 assert_eq!(value["type"], "issue");
225 assert_eq!(value["severity"], "major");
226 assert_eq!(value["location"]["lines"]["begin"], 7);
227 }
228
229 #[test]
230 fn output_serializes_as_bare_array() {
231 let output = CodeClimateOutput(Vec::new());
232 let value = serde_json::to_value(output).expect("CodeClimate output serializes");
233 assert!(value.is_array());
234 }
235
236 #[test]
237 fn codeclimate_issues_to_value_serializes_bare_array() {
238 let value = codeclimate_issues_to_value(&[]);
239 assert!(value.is_array());
240 }
241
242 #[test]
243 fn build_codeclimate_issue_defaults_missing_line_to_one() {
244 let issue = build_codeclimate_issue(CodeClimateIssueInput {
245 check_name: "fallow/test",
246 description: "description",
247 category: "Bug Risk",
248 severity: CodeClimateSeverity::Minor,
249 fingerprint: "abc123",
250 path: "src/app.ts",
251 begin_line: None,
252 });
253
254 assert_eq!(issue.location.lines.begin, 1);
255 }
256
257 #[test]
258 fn codeclimate_fingerprint_parts_are_separated() {
259 assert_ne!(
260 codeclimate_fingerprint_hash(&["ab", "c"]),
261 codeclimate_fingerprint_hash(&["a", "bc"])
262 );
263 }
264
265 #[test]
266 fn annotate_codeclimate_issues_adds_owner_from_location_path() {
267 let mut issues = vec![build_codeclimate_issue(CodeClimateIssueInput {
268 check_name: "fallow/test",
269 description: "description",
270 category: "Bug Risk",
271 severity: CodeClimateSeverity::Minor,
272 fingerprint: "abc123",
273 path: "src/app.ts",
274 begin_line: Some(3),
275 })];
276
277 annotate_codeclimate_issues(&mut issues, CodeClimateAnnotationField::Owner, |path| {
278 format!("team:{path}")
279 });
280 let value = codeclimate_issues_to_value(&issues);
281
282 assert_eq!(value[0]["owner"], "team:src/app.ts");
283 }
284
285 #[test]
286 fn annotate_codeclimate_issues_adds_group_from_location_path() {
287 let mut issues = vec![build_codeclimate_issue(CodeClimateIssueInput {
288 check_name: "fallow/test",
289 description: "description",
290 category: "Bug Risk",
291 severity: CodeClimateSeverity::Minor,
292 fingerprint: "abc123",
293 path: "src/app.ts",
294 begin_line: Some(3),
295 })];
296
297 annotate_codeclimate_issues(&mut issues, CodeClimateAnnotationField::Group, |path| {
298 format!("group:{path}")
299 });
300 let value = codeclimate_issues_to_value(&issues);
301
302 assert_eq!(value[0]["group"], "group:src/app.ts");
303 assert!(value[0].get("owner").is_none());
304 }
305}