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    /// The fingerprint an older Fallow version gave this issue, when it is
52    /// different from `fingerprint`. Never serialized: the CodeClimate wire
53    /// shape does not change. The review layer uses it for one release to
54    /// match review threads that carry the older marker.
55    #[serde(skip)]
56    #[cfg_attr(feature = "schema", schemars(skip))]
57    pub legacy_fingerprint: Option<String>,
58}
59
60/// Discriminator value for [`CodeClimateIssue::kind`].
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
62#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
63#[serde(rename_all = "lowercase")]
64pub enum CodeClimateIssueKind {
65    /// The only valid CodeClimate type today.
66    Issue,
67}
68
69/// CodeClimate severity scale.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
72#[serde(rename_all = "lowercase")]
73pub enum CodeClimateSeverity {
74    /// Informational. Reserved for future severity mappings; not produced
75    /// by the current runtime path (which only emits Minor / Major /
76    /// Critical via `severity_to_codeclimate` and the health / runtime-
77    /// coverage match arms).
78    #[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 finding.
84    Minor,
85    /// Major finding.
86    Major,
87    /// Critical finding.
88    Critical,
89    /// Blocker (highest severity). Reserved for future severity
90    /// mappings; not produced by the current runtime path.
91    #[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/// Location block inside [`CodeClimateIssue::location`].
99#[derive(Debug, Clone, Serialize)]
100#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
101pub struct CodeClimateLocation {
102    /// File path relative to the analysed root.
103    pub path: String,
104    /// Wrapper carrying the line range so the schema lines up with
105    /// CodeClimate's spec.
106    pub lines: CodeClimateLines,
107}
108
109/// Inclusive line range for [`CodeClimateLocation`].
110#[derive(Debug, Clone, Copy, Serialize)]
111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
112pub struct CodeClimateLines {
113    /// 1-based start line.
114    pub begin: u32,
115    /// Inclusive 1-based end line. Omitted for point findings.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub end: Option<u32>,
118}
119
120/// Fields needed to build one CodeClimate issue.
121///
122/// Callers decide what should be reported. This crate owns how that decision is
123/// shaped into the stable CodeClimate / GitLab Code Quality wire contract.
124#[derive(Debug, Clone, Copy)]
125pub struct CodeClimateIssueInput<'a> {
126    /// Fallow rule identifier for the issue's `check_name`.
127    pub check_name: &'a str,
128    /// Human-readable finding description.
129    pub description: &'a str,
130    /// CodeClimate severity to report.
131    pub severity: CodeClimateSeverity,
132    /// Single CodeClimate category label; wrapped into `categories`.
133    pub category: &'a str,
134    /// File path relative to the analysed root.
135    pub path: &'a str,
136    /// 1-based begin line; defaults to line 1 when absent.
137    pub begin_line: Option<u32>,
138    /// Stable finding fingerprint.
139    pub fingerprint: &'a str,
140}
141
142/// Optional grouped CodeClimate annotation field.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum CodeClimateAnnotationField {
145    /// Dead-code grouped output uses the top-level `owner` property.
146    Owner,
147    /// Health and duplication grouped output use the top-level `group`
148    /// property.
149    Group,
150}
151
152/// Compute a deterministic fingerprint hash from key fields.
153///
154/// Delegates to [`fallow_types::identity::fnv1a64_parts`], the one FNV-1a 64
155/// implementation that fingerprints and finding ids share.
156#[must_use]
157pub fn codeclimate_fingerprint_hash(parts: &[&str]) -> String {
158    fallow_types::identity::fnv1a64_parts(parts)
159}
160
161/// Build a single CodeClimate issue from a stable contract descriptor.
162#[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/// Serialize typed CodeClimate issues to the wire-shape JSON array.
186///
187/// Infallible: `CodeClimateIssue` contains only strings, integers, arrays, and
188/// enums serialized as fixed strings.
189///
190/// # Panics
191///
192/// Does not panic in practice: every `CodeClimateIssue` field serializes
193/// infallibly.
194#[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
203/// Add a top-level grouped property to each typed CodeClimate issue.
204///
205/// Grouped CLI outputs use this to attach `owner` or `group` while keeping the
206/// issue array shape and path lookup contract in `fallow-output`.
207pub 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        // Per-part separation means reordering parts changes the digest.
281        assert_ne!(
282            a,
283            codeclimate_fingerprint_hash(&["FEATURE_X", "src/index.ts", "3"])
284        );
285    }
286
287    /// Pins exact digests. CI review threads and CodeClimate consumers match on
288    /// these values, so a change to the hash breaks every saved fingerprint.
289    /// The expected values come from an independent FNV-1a 64 script.
290    #[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}