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 begin-line location of the finding.
38    pub location: CodeClimateLocation,
39    /// Optional owner attribution used by grouped dead-code output.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub owner: Option<String>,
42    /// Optional grouping attribution used by grouped health and duplication
43    /// output.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub group: Option<String>,
46}
47
48/// Discriminator value for [`CodeClimateIssue::kind`].
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
50#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
51#[serde(rename_all = "lowercase")]
52pub enum CodeClimateIssueKind {
53    /// The only valid CodeClimate type today.
54    Issue,
55}
56
57/// CodeClimate severity scale.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60#[serde(rename_all = "lowercase")]
61pub enum CodeClimateSeverity {
62    /// Informational. Reserved for future severity mappings; not produced
63    /// by the current runtime path (which only emits Minor / Major /
64    /// Critical via `severity_to_codeclimate` and the health / runtime-
65    /// coverage match arms).
66    #[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 finding.
72    Minor,
73    /// Major finding.
74    Major,
75    /// Critical finding.
76    Critical,
77    /// Blocker (highest severity). Reserved for future severity
78    /// mappings; not produced by the current runtime path.
79    #[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/// Location block inside [`CodeClimateIssue::location`].
87#[derive(Debug, Clone, Serialize)]
88#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
89pub struct CodeClimateLocation {
90    /// File path relative to the analysed root.
91    pub path: String,
92    /// Wrapper carrying the begin line so the schema lines up with
93    /// CodeClimate's spec.
94    pub lines: CodeClimateLines,
95}
96
97/// `lines.begin` for [`CodeClimateLocation`].
98#[derive(Debug, Clone, Copy, Serialize)]
99#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
100pub struct CodeClimateLines {
101    /// 1-based start line.
102    pub begin: u32,
103}
104
105/// Fields needed to build one CodeClimate issue.
106///
107/// Callers decide what should be reported. This crate owns how that decision is
108/// shaped into the stable CodeClimate / GitLab Code Quality wire contract.
109#[derive(Debug, Clone, Copy)]
110pub struct CodeClimateIssueInput<'a> {
111    /// Fallow rule identifier for the issue's `check_name`.
112    pub check_name: &'a str,
113    /// Human-readable finding description.
114    pub description: &'a str,
115    /// CodeClimate severity to report.
116    pub severity: CodeClimateSeverity,
117    /// Single CodeClimate category label; wrapped into `categories`.
118    pub category: &'a str,
119    /// File path relative to the analysed root.
120    pub path: &'a str,
121    /// 1-based begin line; defaults to line 1 when absent.
122    pub begin_line: Option<u32>,
123    /// Stable finding fingerprint.
124    pub fingerprint: &'a str,
125}
126
127/// Optional grouped CodeClimate annotation field.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum CodeClimateAnnotationField {
130    /// Dead-code grouped output uses the top-level `owner` property.
131    Owner,
132    /// Health and duplication grouped output use the top-level `group`
133    /// property.
134    Group,
135}
136
137/// Compute a deterministic fingerprint hash from key fields.
138///
139/// Uses FNV-1a (64-bit) for guaranteed cross-version stability. `DefaultHasher`
140/// is intentionally not used because it is not specified across Rust versions.
141#[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/// Build a single CodeClimate issue from a stable contract descriptor.
156#[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/// Serialize typed CodeClimate issues to the wire-shape JSON array.
177///
178/// Infallible: `CodeClimateIssue` contains only strings, integers, arrays, and
179/// enums serialized as fixed strings.
180#[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
189/// Add a top-level grouped property to each typed CodeClimate issue.
190///
191/// Grouped CLI outputs use this to attach `owner` or `group` while keeping the
192/// issue array shape and path lookup contract in `fallow-output`.
193pub 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}