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    #[serde(rename = "type")]
25    pub kind: CodeClimateIssueKind,
26    pub check_name: String,
27    pub description: String,
28    pub categories: Vec<String>,
29    pub severity: CodeClimateSeverity,
30    pub fingerprint: String,
31    pub location: CodeClimateLocation,
32    /// Optional owner attribution used by grouped dead-code output.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub owner: Option<String>,
35    /// Optional grouping attribution used by grouped health and duplication
36    /// output.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub group: Option<String>,
39}
40
41/// Discriminator value for [`CodeClimateIssue::kind`].
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
44#[serde(rename_all = "lowercase")]
45pub enum CodeClimateIssueKind {
46    /// The only valid CodeClimate type today.
47    Issue,
48}
49
50/// CodeClimate severity scale.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
52#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
53#[serde(rename_all = "lowercase")]
54pub enum CodeClimateSeverity {
55    /// Informational. Reserved for future severity mappings; not produced
56    /// by the current runtime path (which only emits Minor / Major /
57    /// Critical via `severity_to_codeclimate` and the health / runtime-
58    /// coverage match arms).
59    #[allow(
60        dead_code,
61        reason = "schema-source-of-truth: documents the full CodeClimate severity spec; runtime never produces this variant today."
62    )]
63    Info,
64    /// Minor finding.
65    Minor,
66    /// Major finding.
67    Major,
68    /// Critical finding.
69    Critical,
70    /// Blocker (highest severity). Reserved for future severity
71    /// mappings; not produced by the current runtime path.
72    #[allow(
73        dead_code,
74        reason = "schema-source-of-truth: documents the full CodeClimate severity spec; runtime never produces this variant today."
75    )]
76    Blocker,
77}
78
79/// Location block inside [`CodeClimateIssue::location`].
80#[derive(Debug, Clone, Serialize)]
81#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
82pub struct CodeClimateLocation {
83    /// File path relative to the analysed root.
84    pub path: String,
85    /// Wrapper carrying the begin line so the schema lines up with
86    /// CodeClimate's spec.
87    pub lines: CodeClimateLines,
88}
89
90/// `lines.begin` for [`CodeClimateLocation`].
91#[derive(Debug, Clone, Copy, Serialize)]
92#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
93pub struct CodeClimateLines {
94    /// 1-based start line.
95    pub begin: u32,
96}
97
98/// Fields needed to build one CodeClimate issue.
99///
100/// Callers decide what should be reported. This crate owns how that decision is
101/// shaped into the stable CodeClimate / GitLab Code Quality wire contract.
102#[derive(Debug, Clone, Copy)]
103pub struct CodeClimateIssueInput<'a> {
104    pub check_name: &'a str,
105    pub description: &'a str,
106    pub severity: CodeClimateSeverity,
107    pub category: &'a str,
108    pub path: &'a str,
109    pub begin_line: Option<u32>,
110    pub fingerprint: &'a str,
111}
112
113/// Optional grouped CodeClimate annotation field.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum CodeClimateAnnotationField {
116    /// Dead-code grouped output uses the top-level `owner` property.
117    Owner,
118    /// Health and duplication grouped output use the top-level `group`
119    /// property.
120    Group,
121}
122
123/// Compute a deterministic fingerprint hash from key fields.
124///
125/// Uses FNV-1a (64-bit) for guaranteed cross-version stability. `DefaultHasher`
126/// is intentionally not used because it is not specified across Rust versions.
127#[must_use]
128pub fn codeclimate_fingerprint_hash(parts: &[&str]) -> String {
129    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
130    for part in parts {
131        for byte in part.bytes() {
132            hash ^= u64::from(byte);
133            hash = hash.wrapping_mul(0x0100_0000_01b3);
134        }
135        hash ^= 0xff;
136        hash = hash.wrapping_mul(0x0100_0000_01b3);
137    }
138    format!("{hash:016x}")
139}
140
141/// Build a single CodeClimate issue from a stable contract descriptor.
142#[must_use]
143pub fn build_codeclimate_issue(input: CodeClimateIssueInput<'_>) -> CodeClimateIssue {
144    CodeClimateIssue {
145        kind: CodeClimateIssueKind::Issue,
146        check_name: input.check_name.to_string(),
147        description: input.description.to_string(),
148        categories: vec![input.category.to_string()],
149        severity: input.severity,
150        fingerprint: input.fingerprint.to_string(),
151        location: CodeClimateLocation {
152            path: input.path.to_string(),
153            lines: CodeClimateLines {
154                begin: input.begin_line.unwrap_or(1),
155            },
156        },
157        owner: None,
158        group: None,
159    }
160}
161
162/// Serialize typed CodeClimate issues to the wire-shape JSON array.
163///
164/// Infallible: `CodeClimateIssue` contains only strings, integers, arrays, and
165/// enums serialized as fixed strings.
166#[must_use]
167#[expect(
168    clippy::expect_used,
169    reason = "CodeClimateIssue contains only infallibly serializable fields"
170)]
171pub fn codeclimate_issues_to_value(issues: &[CodeClimateIssue]) -> Value {
172    serde_json::to_value(issues).expect("CodeClimateIssue serializes infallibly")
173}
174
175/// Add a top-level grouped property to each typed CodeClimate issue.
176///
177/// Grouped CLI outputs use this to attach `owner` or `group` while keeping the
178/// issue array shape and path lookup contract in `fallow-output`.
179pub fn annotate_codeclimate_issues(
180    issues: &mut [CodeClimateIssue],
181    field: CodeClimateAnnotationField,
182    mut value_for_path: impl FnMut(&str) -> String,
183) {
184    for issue in issues {
185        let value = value_for_path(&issue.location.path);
186        match field {
187            CodeClimateAnnotationField::Owner => issue.owner = Some(value),
188            CodeClimateAnnotationField::Group => issue.group = Some(value),
189        }
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn codeclimate_issue_serializes_spec_shape() {
199        let issue = build_codeclimate_issue(CodeClimateIssueInput {
200            check_name: "fallow/test",
201            description: "description",
202            category: "Bug Risk",
203            severity: CodeClimateSeverity::Major,
204            fingerprint: "abc123",
205            path: "src/app.ts",
206            begin_line: Some(7),
207        });
208
209        let value = serde_json::to_value(issue).expect("CodeClimate issue serializes");
210        assert_eq!(value["type"], "issue");
211        assert_eq!(value["severity"], "major");
212        assert_eq!(value["location"]["lines"]["begin"], 7);
213    }
214
215    #[test]
216    fn output_serializes_as_bare_array() {
217        let output = CodeClimateOutput(Vec::new());
218        let value = serde_json::to_value(output).expect("CodeClimate output serializes");
219        assert!(value.is_array());
220    }
221
222    #[test]
223    fn codeclimate_issues_to_value_serializes_bare_array() {
224        let value = codeclimate_issues_to_value(&[]);
225        assert!(value.is_array());
226    }
227
228    #[test]
229    fn build_codeclimate_issue_defaults_missing_line_to_one() {
230        let issue = build_codeclimate_issue(CodeClimateIssueInput {
231            check_name: "fallow/test",
232            description: "description",
233            category: "Bug Risk",
234            severity: CodeClimateSeverity::Minor,
235            fingerprint: "abc123",
236            path: "src/app.ts",
237            begin_line: None,
238        });
239
240        assert_eq!(issue.location.lines.begin, 1);
241    }
242
243    #[test]
244    fn codeclimate_fingerprint_parts_are_separated() {
245        assert_ne!(
246            codeclimate_fingerprint_hash(&["ab", "c"]),
247            codeclimate_fingerprint_hash(&["a", "bc"])
248        );
249    }
250
251    #[test]
252    fn annotate_codeclimate_issues_adds_owner_from_location_path() {
253        let mut issues = vec![build_codeclimate_issue(CodeClimateIssueInput {
254            check_name: "fallow/test",
255            description: "description",
256            category: "Bug Risk",
257            severity: CodeClimateSeverity::Minor,
258            fingerprint: "abc123",
259            path: "src/app.ts",
260            begin_line: Some(3),
261        })];
262
263        annotate_codeclimate_issues(&mut issues, CodeClimateAnnotationField::Owner, |path| {
264            format!("team:{path}")
265        });
266        let value = codeclimate_issues_to_value(&issues);
267
268        assert_eq!(value[0]["owner"], "team:src/app.ts");
269    }
270
271    #[test]
272    fn annotate_codeclimate_issues_adds_group_from_location_path() {
273        let mut issues = vec![build_codeclimate_issue(CodeClimateIssueInput {
274            check_name: "fallow/test",
275            description: "description",
276            category: "Bug Risk",
277            severity: CodeClimateSeverity::Minor,
278            fingerprint: "abc123",
279            path: "src/app.ts",
280            begin_line: Some(3),
281        })];
282
283        annotate_codeclimate_issues(&mut issues, CodeClimateAnnotationField::Group, |path| {
284            format!("group:{path}")
285        });
286        let value = codeclimate_issues_to_value(&issues);
287
288        assert_eq!(value[0]["group"], "group:src/app.ts");
289        assert!(value[0].get("owner").is_none());
290    }
291}