Skip to main content

fallow_output/
codeclimate.rs

1use serde::Serialize;
2use serde_json::Value;
3
4/// Envelope emitted by `fallow --format codeclimate` and
5/// `fallow --format gitlab-codequality`. GitLab Code Quality consumes the
6/// same shape. The wire form is a bare JSON array, not an object.
7#[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/// Single CodeClimate-compatible issue inside [`CodeClimateOutput`].
21#[derive(Debug, Clone, Serialize)]
22#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
23pub struct CodeClimateIssue {
24    /// CodeClimate `type` discriminator; always `issue`.
25    #[serde(rename = "type")]
26    pub kind: CodeClimateIssueKind,
27    /// Fallow rule identifier, e.g. `fallow/unused-file`.
28    pub check_name: String,
29    /// Human-readable finding description.
30    pub description: String,
31    /// CodeClimate category labels, e.g. `Clarity` or `Duplication`.
32    pub categories: Vec<String>,
33    /// CodeClimate severity mapped from the configured rule severity.
34    pub severity: CodeClimateSeverity,
35    /// Stable finding fingerprint GitLab uses to track issues across pushes.
36    pub fingerprint: String,
37    /// File and inclusive line range of the finding.
38    pub location: CodeClimateLocation,
39    /// Other source locations that provide evidence for the finding. GitLab's
40    /// Code Quality widget ignores this standard CodeClimate field, but Fallow
41    /// preserves it for review-comment rendering.
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub other_locations: Vec<CodeClimateLocation>,
44    /// Optional owner attribution used by grouped dead-code output.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub owner: Option<String>,
47    /// Optional grouping attribution used by grouped health and duplication
48    /// output.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub group: Option<String>,
51}
52
53/// Discriminator value for [`CodeClimateIssue::kind`].
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
55#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56#[serde(rename_all = "lowercase")]
57pub enum CodeClimateIssueKind {
58    /// The only valid CodeClimate type today.
59    Issue,
60}
61
62/// CodeClimate severity scale.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
64#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
65#[serde(rename_all = "lowercase")]
66pub enum CodeClimateSeverity {
67    /// Informational. Reserved for future severity mappings; not produced
68    /// by the current runtime path (which only emits Minor / Major /
69    /// Critical via `severity_to_codeclimate` and the health / runtime-
70    /// coverage match arms).
71    #[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 finding.
77    Minor,
78    /// Major finding.
79    Major,
80    /// Critical finding.
81    Critical,
82    /// Blocker (highest severity). Reserved for future severity
83    /// mappings; not produced by the current runtime path.
84    #[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/// Location block inside [`CodeClimateIssue::location`].
92#[derive(Debug, Clone, Serialize)]
93#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
94pub struct CodeClimateLocation {
95    /// File path relative to the analysed root.
96    pub path: String,
97    /// Wrapper carrying the line range so the schema lines up with
98    /// CodeClimate's spec.
99    pub lines: CodeClimateLines,
100}
101
102/// Inclusive line range for [`CodeClimateLocation`].
103#[derive(Debug, Clone, Copy, Serialize)]
104#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
105pub struct CodeClimateLines {
106    /// 1-based start line.
107    pub begin: u32,
108    /// Inclusive 1-based end line. Omitted for point findings.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub end: Option<u32>,
111}
112
113/// Fields needed to build one CodeClimate issue.
114///
115/// Callers decide what should be reported. This crate owns how that decision is
116/// shaped into the stable CodeClimate / GitLab Code Quality wire contract.
117#[derive(Debug, Clone, Copy)]
118pub struct CodeClimateIssueInput<'a> {
119    /// Fallow rule identifier for the issue's `check_name`.
120    pub check_name: &'a str,
121    /// Human-readable finding description.
122    pub description: &'a str,
123    /// CodeClimate severity to report.
124    pub severity: CodeClimateSeverity,
125    /// Single CodeClimate category label; wrapped into `categories`.
126    pub category: &'a str,
127    /// File path relative to the analysed root.
128    pub path: &'a str,
129    /// 1-based begin line; defaults to line 1 when absent.
130    pub begin_line: Option<u32>,
131    /// Stable finding fingerprint.
132    pub fingerprint: &'a str,
133}
134
135/// Optional grouped CodeClimate annotation field.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum CodeClimateAnnotationField {
138    /// Dead-code grouped output uses the top-level `owner` property.
139    Owner,
140    /// Health and duplication grouped output use the top-level `group`
141    /// property.
142    Group,
143}
144
145/// Compute a deterministic fingerprint hash from key fields.
146///
147/// Uses FNV-1a (64-bit) for guaranteed cross-version stability. `DefaultHasher`
148/// is intentionally not used because it is not specified across Rust versions.
149#[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/// Build a single CodeClimate issue from a stable contract descriptor.
164#[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/// Serialize typed CodeClimate issues to the wire-shape JSON array.
187///
188/// Infallible: `CodeClimateIssue` contains only strings, integers, arrays, and
189/// enums serialized as fixed strings.
190#[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
199/// Add a top-level grouped property to each typed CodeClimate issue.
200///
201/// Grouped CLI outputs use this to attach `owner` or `group` while keeping the
202/// issue array shape and path lookup contract in `fallow-output`.
203pub 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        // Per-part separation means reordering parts changes the digest.
277        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}