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]
190#[expect(
191 clippy::expect_used,
192 reason = "CodeClimateIssue contains only infallibly serializable fields"
193)]
194pub fn codeclimate_issues_to_value(issues: &[CodeClimateIssue]) -> Value {
195 serde_json::to_value(issues).expect("CodeClimateIssue serializes infallibly")
196}
197
198pub fn annotate_codeclimate_issues(
203 issues: &mut [CodeClimateIssue],
204 field: CodeClimateAnnotationField,
205 mut value_for_path: impl FnMut(&str) -> String,
206) {
207 for issue in issues {
208 let value = value_for_path(&issue.location.path);
209 match field {
210 CodeClimateAnnotationField::Owner => issue.owner = Some(value),
211 CodeClimateAnnotationField::Group => issue.group = Some(value),
212 }
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn codeclimate_issue_serializes_spec_shape() {
222 let issue = build_codeclimate_issue(CodeClimateIssueInput {
223 check_name: "fallow/test",
224 description: "description",
225 category: "Bug Risk",
226 severity: CodeClimateSeverity::Major,
227 fingerprint: "abc123",
228 path: "src/app.ts",
229 begin_line: Some(7),
230 });
231
232 let value = serde_json::to_value(issue).expect("CodeClimate issue serializes");
233 assert_eq!(value["type"], "issue");
234 assert_eq!(value["severity"], "major");
235 assert_eq!(value["location"]["lines"]["begin"], 7);
236 assert!(value["location"]["lines"].get("end").is_none());
237 assert!(value.get("other_locations").is_none());
238 }
239
240 #[test]
241 fn output_serializes_as_bare_array() {
242 let output = CodeClimateOutput(Vec::new());
243 let value = serde_json::to_value(output).expect("CodeClimate output serializes");
244 assert!(value.is_array());
245 }
246
247 #[test]
248 fn codeclimate_issues_to_value_serializes_bare_array() {
249 let value = codeclimate_issues_to_value(&[]);
250 assert!(value.is_array());
251 }
252
253 #[test]
254 fn build_codeclimate_issue_defaults_missing_line_to_one() {
255 let issue = build_codeclimate_issue(CodeClimateIssueInput {
256 check_name: "fallow/test",
257 description: "description",
258 category: "Bug Risk",
259 severity: CodeClimateSeverity::Minor,
260 fingerprint: "abc123",
261 path: "src/app.ts",
262 begin_line: None,
263 });
264
265 assert_eq!(issue.location.lines.begin, 1);
266 }
267
268 #[test]
269 fn codeclimate_fingerprint_hash_is_deterministic_16_hex() {
270 let a = codeclimate_fingerprint_hash(&["src/index.ts", "FEATURE_X", "3"]);
271 let b = codeclimate_fingerprint_hash(&["src/index.ts", "FEATURE_X", "3"]);
272 assert_eq!(a, b);
273 assert_eq!(a.len(), 16);
274 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
275 assert_ne!(
277 a,
278 codeclimate_fingerprint_hash(&["FEATURE_X", "src/index.ts", "3"])
279 );
280 }
281
282 #[test]
286 fn codeclimate_fingerprint_hash_golden_values() {
287 assert_eq!(
288 codeclimate_fingerprint_hash(&["src/index.ts", "FEATURE_X", "3"]),
289 "2278c9d9bd9d2dd2"
290 );
291 assert_eq!(
292 codeclimate_fingerprint_hash(&["fallow/unused-file", "src/orphan.ts"]),
293 "c03b925ddb7d871a"
294 );
295 assert_eq!(codeclimate_fingerprint_hash(&[]), "cbf29ce484222325");
296 }
297
298 #[test]
299 fn codeclimate_fingerprint_parts_are_separated() {
300 assert_ne!(
301 codeclimate_fingerprint_hash(&["ab", "c"]),
302 codeclimate_fingerprint_hash(&["a", "bc"])
303 );
304 }
305
306 #[test]
307 fn annotate_codeclimate_issues_adds_owner_from_location_path() {
308 let mut issues = vec![build_codeclimate_issue(CodeClimateIssueInput {
309 check_name: "fallow/test",
310 description: "description",
311 category: "Bug Risk",
312 severity: CodeClimateSeverity::Minor,
313 fingerprint: "abc123",
314 path: "src/app.ts",
315 begin_line: Some(3),
316 })];
317
318 annotate_codeclimate_issues(&mut issues, CodeClimateAnnotationField::Owner, |path| {
319 format!("team:{path}")
320 });
321 let value = codeclimate_issues_to_value(&issues);
322
323 assert_eq!(value[0]["owner"], "team:src/app.ts");
324 }
325
326 #[test]
327 fn annotate_codeclimate_issues_adds_group_from_location_path() {
328 let mut issues = vec![build_codeclimate_issue(CodeClimateIssueInput {
329 check_name: "fallow/test",
330 description: "description",
331 category: "Bug Risk",
332 severity: CodeClimateSeverity::Minor,
333 fingerprint: "abc123",
334 path: "src/app.ts",
335 begin_line: Some(3),
336 })];
337
338 annotate_codeclimate_issues(&mut issues, CodeClimateAnnotationField::Group, |path| {
339 format!("group:{path}")
340 });
341 let value = codeclimate_issues_to_value(&issues);
342
343 assert_eq!(value[0]["group"], "group:src/app.ts");
344 assert!(value[0].get("owner").is_none());
345 }
346}