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 #[serde(skip)]
56 #[cfg_attr(feature = "schema", schemars(skip))]
57 pub legacy_fingerprint: Option<String>,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
62#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
63#[serde(rename_all = "lowercase")]
64pub enum CodeClimateIssueKind {
65 Issue,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
72#[serde(rename_all = "lowercase")]
73pub enum CodeClimateSeverity {
74 #[allow(
79 dead_code,
80 reason = "schema-source-of-truth: documents the full CodeClimate severity spec; runtime never produces this variant today."
81 )]
82 Info,
83 Minor,
85 Major,
87 Critical,
89 #[allow(
92 dead_code,
93 reason = "schema-source-of-truth: documents the full CodeClimate severity spec; runtime never produces this variant today."
94 )]
95 Blocker,
96}
97
98#[derive(Debug, Clone, Serialize)]
100#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
101pub struct CodeClimateLocation {
102 pub path: String,
104 pub lines: CodeClimateLines,
107}
108
109#[derive(Debug, Clone, Copy, Serialize)]
111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
112pub struct CodeClimateLines {
113 pub begin: u32,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub end: Option<u32>,
118}
119
120#[derive(Debug, Clone, Copy)]
125pub struct CodeClimateIssueInput<'a> {
126 pub check_name: &'a str,
128 pub description: &'a str,
130 pub severity: CodeClimateSeverity,
132 pub category: &'a str,
134 pub path: &'a str,
136 pub begin_line: Option<u32>,
138 pub fingerprint: &'a str,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum CodeClimateAnnotationField {
145 Owner,
147 Group,
150}
151
152#[must_use]
157pub fn codeclimate_fingerprint_hash(parts: &[&str]) -> String {
158 fallow_types::identity::fnv1a64_parts(parts)
159}
160
161#[must_use]
163pub fn build_codeclimate_issue(input: CodeClimateIssueInput<'_>) -> CodeClimateIssue {
164 CodeClimateIssue {
165 kind: CodeClimateIssueKind::Issue,
166 check_name: input.check_name.to_string(),
167 description: input.description.to_string(),
168 categories: vec![input.category.to_string()],
169 severity: input.severity,
170 fingerprint: input.fingerprint.to_string(),
171 location: CodeClimateLocation {
172 path: input.path.to_string(),
173 lines: CodeClimateLines {
174 begin: input.begin_line.unwrap_or(1),
175 end: None,
176 },
177 },
178 other_locations: Vec::new(),
179 owner: None,
180 group: None,
181 legacy_fingerprint: None,
182 }
183}
184
185#[must_use]
195#[expect(
196 clippy::expect_used,
197 reason = "CodeClimateIssue contains only infallibly serializable fields"
198)]
199pub fn codeclimate_issues_to_value(issues: &[CodeClimateIssue]) -> Value {
200 serde_json::to_value(issues).expect("CodeClimateIssue serializes infallibly")
201}
202
203pub fn annotate_codeclimate_issues(
208 issues: &mut [CodeClimateIssue],
209 field: CodeClimateAnnotationField,
210 mut value_for_path: impl FnMut(&str) -> String,
211) {
212 for issue in issues {
213 let value = value_for_path(&issue.location.path);
214 match field {
215 CodeClimateAnnotationField::Owner => issue.owner = Some(value),
216 CodeClimateAnnotationField::Group => issue.group = Some(value),
217 }
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn codeclimate_issue_serializes_spec_shape() {
227 let issue = build_codeclimate_issue(CodeClimateIssueInput {
228 check_name: "fallow/test",
229 description: "description",
230 category: "Bug Risk",
231 severity: CodeClimateSeverity::Major,
232 fingerprint: "abc123",
233 path: "src/app.ts",
234 begin_line: Some(7),
235 });
236
237 let value = serde_json::to_value(issue).expect("CodeClimate issue serializes");
238 assert_eq!(value["type"], "issue");
239 assert_eq!(value["severity"], "major");
240 assert_eq!(value["location"]["lines"]["begin"], 7);
241 assert!(value["location"]["lines"].get("end").is_none());
242 assert!(value.get("other_locations").is_none());
243 }
244
245 #[test]
246 fn output_serializes_as_bare_array() {
247 let output = CodeClimateOutput(Vec::new());
248 let value = serde_json::to_value(output).expect("CodeClimate output serializes");
249 assert!(value.is_array());
250 }
251
252 #[test]
253 fn codeclimate_issues_to_value_serializes_bare_array() {
254 let value = codeclimate_issues_to_value(&[]);
255 assert!(value.is_array());
256 }
257
258 #[test]
259 fn build_codeclimate_issue_defaults_missing_line_to_one() {
260 let issue = build_codeclimate_issue(CodeClimateIssueInput {
261 check_name: "fallow/test",
262 description: "description",
263 category: "Bug Risk",
264 severity: CodeClimateSeverity::Minor,
265 fingerprint: "abc123",
266 path: "src/app.ts",
267 begin_line: None,
268 });
269
270 assert_eq!(issue.location.lines.begin, 1);
271 }
272
273 #[test]
274 fn codeclimate_fingerprint_hash_is_deterministic_16_hex() {
275 let a = codeclimate_fingerprint_hash(&["src/index.ts", "FEATURE_X", "3"]);
276 let b = codeclimate_fingerprint_hash(&["src/index.ts", "FEATURE_X", "3"]);
277 assert_eq!(a, b);
278 assert_eq!(a.len(), 16);
279 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
280 assert_ne!(
282 a,
283 codeclimate_fingerprint_hash(&["FEATURE_X", "src/index.ts", "3"])
284 );
285 }
286
287 #[test]
291 fn codeclimate_fingerprint_hash_golden_values() {
292 assert_eq!(
293 codeclimate_fingerprint_hash(&["src/index.ts", "FEATURE_X", "3"]),
294 "2278c9d9bd9d2dd2"
295 );
296 assert_eq!(
297 codeclimate_fingerprint_hash(&["fallow/unused-file", "src/orphan.ts"]),
298 "c03b925ddb7d871a"
299 );
300 assert_eq!(codeclimate_fingerprint_hash(&[]), "cbf29ce484222325");
301 }
302
303 #[test]
304 fn codeclimate_fingerprint_parts_are_separated() {
305 assert_ne!(
306 codeclimate_fingerprint_hash(&["ab", "c"]),
307 codeclimate_fingerprint_hash(&["a", "bc"])
308 );
309 }
310
311 #[test]
312 fn annotate_codeclimate_issues_adds_owner_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::Owner, |path| {
324 format!("team:{path}")
325 });
326 let value = codeclimate_issues_to_value(&issues);
327
328 assert_eq!(value[0]["owner"], "team:src/app.ts");
329 }
330
331 #[test]
332 fn annotate_codeclimate_issues_adds_group_from_location_path() {
333 let mut issues = vec![build_codeclimate_issue(CodeClimateIssueInput {
334 check_name: "fallow/test",
335 description: "description",
336 category: "Bug Risk",
337 severity: CodeClimateSeverity::Minor,
338 fingerprint: "abc123",
339 path: "src/app.ts",
340 begin_line: Some(3),
341 })];
342
343 annotate_codeclimate_issues(&mut issues, CodeClimateAnnotationField::Group, |path| {
344 format!("group:{path}")
345 });
346 let value = codeclimate_issues_to_value(&issues);
347
348 assert_eq!(value[0]["group"], "group:src/app.ts");
349 assert!(value[0].get("owner").is_none());
350 }
351}