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#[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
198/// Add a top-level grouped property to each typed CodeClimate issue.
199///
200/// Grouped CLI outputs use this to attach `owner` or `group` while keeping the
201/// issue array shape and path lookup contract in `fallow-output`.
202pub 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        // Per-part separation means reordering parts changes the digest.
276        assert_ne!(
277            a,
278            codeclimate_fingerprint_hash(&["FEATURE_X", "src/index.ts", "3"])
279        );
280    }
281
282    /// Pins exact digests. CI review threads and CodeClimate consumers match on
283    /// these values, so a change to the hash breaks every saved fingerprint.
284    /// The expected values come from an independent FNV-1a 64 script.
285    #[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}