Skip to main content

big_code_analysis/output/
code_climate.rs

1//! GitLab Code Climate JSON writer for [`OffenderRecord`] batches.
2//!
3//! GitLab's merge-request *Code Quality* widget consumes a strict
4//! subset of the upstream [Code Climate engine
5//! spec](https://github.com/codeclimate/platform/blob/master/spec/analyzers/SPEC.md);
6//! this writer emits exactly that subset so a `bca check` artifact
7//! can drop straight into `.gitlab-ci.yml`'s
8//! `artifacts.reports.codequality:` slot. See the authoritative
9//! GitLab docs at
10//! <https://docs.gitlab.com/ci/testing/code_quality/> for the
11//! consumer side.
12//!
13//! # Fields emitted
14//!
15//! | JSON field | Source |
16//! |------------|--------|
17//! | `description` | [`metric_catalog`](crate::metric_catalog) long-form + [`OffenderRecord::default_message`]; bare `default_message` for unknown metrics |
18//! | `check_name` | `"big-code-analysis/<metric>"` (namespaced so multi-tool pipelines do not collide) |
19//! | `fingerprint` | SHA-256 of `path \0 function.unwrap_or("") \0 metric`, truncated to 32 hex chars. Deliberately excludes line / value so re-runs after upstream-line edits still dedup in the MR widget. |
20//! | `severity` | Ratio-band mapping over `value / limit` (inverted for the `mi.*` family — lower is worse there). Falls back to a per-record `Severity` lookup when the ratio is ill-defined. |
21//! | `location.path` | UTF-8 relative path, forward slashes, leading `./` stripped. Non-UTF-8 paths emit a stderr warning and the offender is skipped. |
22//! | `location.lines.begin`, `lines.end` | `start_line` (clamped ≥ 1) and `end_line` (only when `> start_line`). |
23//! | `location.positions.begin` | `{line, column}` emitted only when `start_col` is `Some(c)` with `c > 0`. |
24//!
25//! # Not emitted
26//!
27//! The upstream Code Climate spec defines `type`, `categories`,
28//! `remediation_points`, and `content`; GitLab ignores all of
29//! them, so we omit them to keep the artifact small. Adding them
30//! later is a purely additive change.
31//!
32//! # Framing
33//!
34//! Single JSON array of objects, no byte-order-mark, one trailing
35//! newline. The empty case emits the literal `[]\n` so consumers
36//! that pipe through `jq` see a well-formed document even when no
37//! offenders triggered.
38
39use std::borrow::Cow;
40use std::collections::HashMap;
41use std::fmt::Write as _;
42use std::io::{self, Write};
43
44use serde::Serialize;
45use sha2::{Digest, Sha256};
46
47use crate::metric_catalog::lookup;
48use crate::output::offenders::{OffenderRecord, Severity, TOOL_ID, warn_non_utf8_path};
49
50/// Number of leading SHA-256 bytes retained in each fingerprint
51/// (matches the issue spec — 128 bits is enough to keep collision
52/// probability negligible for any realistic offender corpus while
53/// keeping the JSON artifact compact). The hex-encoded width is
54/// `FINGERPRINT_BYTE_LEN * 2` chars (32) by construction.
55const FINGERPRINT_BYTE_LEN: usize = 16;
56
57/// Write a GitLab Code Climate JSON report for `offenders` to
58/// `writer`.
59///
60/// Offenders whose path is not valid UTF-8 — or whose
61/// repo-relative path collapses to the empty string after
62/// normalization — are skipped with a warning to stderr. The
63/// empty case emits the literal `[]\n` so the artifact is always
64/// well-formed, even before the threshold engine produces any
65/// violations.
66///
67/// # Errors
68///
69/// Returns any [`io::Error`] produced by `writer` while emitting
70/// the JSON document, or a `serde_json::Error` (mapped to
71/// `io::Error` via [`io::Error::new`]) if a record cannot be
72/// serialised.
73pub fn write_code_climate<W: Write>(offenders: &[OffenderRecord], mut writer: W) -> io::Result<()> {
74    if offenders.is_empty() {
75        return writer.write_all(b"[]\n");
76    }
77    let mut issues: Vec<CodeClimateIssue> = Vec::with_capacity(offenders.len());
78    // Disambiguate findings that share the same `path \0 function \0
79    // metric` triple — two same-named functions (overloads, a trait-impl
80    // `fn new`, sibling closures) each breaching the same metric. Without
81    // a discriminator they hash identically, GitLab's MR widget dedups
82    // them, and the second offender silently vanishes (#703). The ordinal
83    // is the count of prior same-triple findings in the (deterministic,
84    // source-order) offender stream; the first occurrence keeps ordinal 0
85    // so unique findings — the overwhelming majority — retain their
86    // historical fingerprint and the frozen dedup contract is unbroken.
87    let mut seen: HashMap<(String, Option<String>, String), u32> = HashMap::new();
88    for record in offenders {
89        let Some(path_raw) = warn_non_utf8_path("code-climate", &record.path) else {
90            continue;
91        };
92        let Some(path) = normalize_path(path_raw) else {
93            eprintln!(
94                "Warning: skipping empty repo-relative path in code-climate output: {}",
95                record.path.display()
96            );
97            continue;
98        };
99        let start_line = record.start_line.max(1);
100        let lines_end = (record.end_line > start_line).then_some(record.end_line);
101        let positions = record.start_col.filter(|c| *c > 0).map(|column| Positions {
102            begin: Position {
103                line: start_line,
104                column,
105            },
106        });
107        let key = (path.clone(), record.function.clone(), record.metric.clone());
108        let ordinal = seen.entry(key).or_insert(0);
109        let fingerprint = fingerprint(&path, record.function.as_deref(), &record.metric, *ordinal);
110        *ordinal += 1;
111        issues.push(CodeClimateIssue {
112            description: build_description(record),
113            check_name: format!("{TOOL_ID}/{}", record.metric),
114            fingerprint,
115            severity: severity_band(&record.metric, record.value, record.limit, record.severity),
116            location: Location {
117                path,
118                lines: Lines {
119                    begin: start_line,
120                    end: lines_end,
121                },
122                positions,
123            },
124        });
125    }
126    serde_json::to_writer(&mut writer, &issues)
127        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
128    writer.write_all(b"\n")
129}
130
131#[derive(Serialize)]
132struct CodeClimateIssue {
133    description: String,
134    check_name: String,
135    fingerprint: String,
136    severity: &'static str,
137    location: Location,
138}
139
140#[derive(Serialize)]
141struct Location {
142    path: String,
143    lines: Lines,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    positions: Option<Positions>,
146}
147
148#[derive(Serialize)]
149struct Lines {
150    begin: u32,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    end: Option<u32>,
153}
154
155#[derive(Serialize)]
156struct Positions {
157    begin: Position,
158}
159
160#[derive(Serialize)]
161struct Position {
162    line: u32,
163    column: u32,
164}
165
166/// Map a metric value/limit ratio onto GitLab's five-level severity
167/// enum.
168///
169/// GitLab accepts `info`, `minor`, `major`, `critical`, `blocker`.
170/// We never emit `info`; the lowest band starts at `minor` so a
171/// threshold violation always shows in the MR widget (per the
172/// issue spec). The `mi.*` family inverts the ratio direction —
173/// for Maintainability Index, lower values mean *worse*, so the
174/// "how many times the threshold did we breach by" is `limit /
175/// value`, not `value / limit`.
176fn severity_band(metric: &str, value: f64, limit: f64, severity: Severity) -> &'static str {
177    // The declared `Severity` is a floor, not just a fallback: an
178    // offender the threshold engine marked `Error` must never render as
179    // the cosmetic `minor` band, even when its breach ratio is small
180    // (#698). `Warning` floors at `minor` (the lowest band we emit),
181    // `Error` at `major`, so a 1.1x `Error` reads as `major`, not
182    // `minor`. The computed ratio band can only raise the level above
183    // this floor, never lower it.
184    let floor = match severity {
185        Severity::Warning => "minor",
186        Severity::Error => "major",
187    };
188    // Filter ill-defined inputs BEFORE choosing the ratio direction:
189    // a future refactor that moves the metric-family check above this
190    // guard would let `mi.*` reach the inverted ratio with `value <=
191    // 0.0` and divide-by-zero. Keep the guards here.
192    if !value.is_finite() || !limit.is_finite() || limit <= 0.0 || value <= 0.0 {
193        return floor;
194    }
195    let lower_is_worse = crate::metric_catalog::lower_is_worse(metric);
196    let ratio = if lower_is_worse {
197        limit / value
198    } else {
199        value / limit
200    };
201    let band = if ratio <= 1.5 {
202        "minor"
203    } else if ratio <= 2.0 {
204        "major"
205    } else if ratio <= 4.0 {
206        "critical"
207    } else {
208        "blocker"
209    };
210    worst_severity(band, floor)
211}
212
213/// GitLab's severity enum as an ordered scale, lowest-first. Used to
214/// take the worst of the ratio-derived band and the declared-severity
215/// floor so a higher declared severity is never downgraded by a small
216/// breach ratio (#698). An unrecognized token ranks below `minor` so it
217/// never wins a comparison.
218fn severity_rank(level: &str) -> u8 {
219    match level {
220        "minor" => 1,
221        "major" => 2,
222        "critical" => 3,
223        "blocker" => 4,
224        _ => 0,
225    }
226}
227
228/// The more-severe of two GitLab severity tokens.
229fn worst_severity(a: &'static str, b: &'static str) -> &'static str {
230    if severity_rank(a) >= severity_rank(b) {
231        a
232    } else {
233        b
234    }
235}
236
237/// Compute a stable per-violation fingerprint. GitLab's MR widget
238/// uses this for deduplication across pipeline runs and for the
239/// base-vs-head diff, so we deliberately omit the line number and
240/// metric value — both shift on cosmetic edits that should not
241/// re-surface a known violation.
242///
243/// `ordinal` disambiguates findings that share the same `path \0
244/// function \0 metric` triple (two same-named functions each breaching
245/// the same metric). The first occurrence (`ordinal == 0`) is hashed
246/// exactly as before so unique findings keep their historical
247/// fingerprint and the frozen GitLab-dedup contract is preserved; only a
248/// genuine collision folds the ordinal into the digest, giving the
249/// second and later same-triple findings distinct fingerprints so
250/// GitLab no longer drops them (#703). An ordinal survives cosmetic
251/// edits as long as the relative source order of the same-named
252/// functions is unchanged — preferred over `end_line`, which shifts on
253/// any edit above the function.
254///
255/// Truncated to [`FINGERPRINT_BYTE_LEN`] bytes per the issue spec
256/// (32 hex chars by construction). Leading-zero bytes are preserved
257/// by [`hex_lower_bytes`] (which a `format!("{:x}", u128)` rendering
258/// would silently drop).
259fn fingerprint(path: &str, function: Option<&str>, metric: &str, ordinal: u32) -> String {
260    let mut h = Sha256::new();
261    h.update(path.as_bytes());
262    h.update(b"\0");
263    h.update(function.unwrap_or("").as_bytes());
264    h.update(b"\0");
265    h.update(metric.as_bytes());
266    if ordinal > 0 {
267        h.update(b"\0");
268        h.update(ordinal.to_le_bytes());
269    }
270    let digest = h.finalize();
271    hex_lower_bytes(&digest[..FINGERPRINT_BYTE_LEN])
272}
273
274/// Lowercase, zero-padded hex encoding of `bytes`. Extracted from
275/// [`fingerprint`] so the zero-padding invariant can be tested with
276/// synthetic byte sequences (including bytes < `0x10`) that the
277/// SHA-256 driver of `fingerprint` does not surface deterministically.
278fn hex_lower_bytes(bytes: &[u8]) -> String {
279    let mut out = String::with_capacity(bytes.len() * 2);
280    for byte in bytes {
281        // write! to a String is infallible; the Result is discarded
282        // intentionally rather than unwrapped to avoid an `expect`
283        // in non-test code.
284        let _ = write!(&mut out, "{byte:02x}");
285    }
286    out
287}
288
289fn normalize_path(raw: &str) -> Option<String> {
290    // Avoid a transient String allocation on the no-backslash path
291    // (the typical Linux-CI case). The `replace` allocation only
292    // pays for itself when there's actually a backslash to swap.
293    let normalized: Cow<'_, str> = if raw.contains('\\') {
294        Cow::Owned(raw.replace('\\', "/"))
295    } else {
296        Cow::Borrowed(raw)
297    };
298    let stripped = normalized.strip_prefix("./").unwrap_or(&normalized);
299    if stripped.is_empty() {
300        None
301    } else {
302        Some(stripped.to_owned())
303    }
304}
305
306fn build_description(record: &OffenderRecord) -> String {
307    let Some(long_form) = lookup(&record.metric).map(|i| i.long_description) else {
308        return record.default_message();
309    };
310    // Prefix the catalog's long-form sentence, then reuse the offender's
311    // direction-aware `default_message` tail so the breach phrasing
312    // ("exceeds limit" vs the `mi.*` "falls below limit", #698) stays in
313    // lockstep with the SARIF / Checkstyle / warning-line surfaces
314    // instead of hardcoding "exceeds limit" here.
315    let tail = record.default_message();
316    let mut out = String::with_capacity(long_form.len() + 1 + tail.len());
317    out.push_str(long_form);
318    out.push(' ');
319    out.push_str(&tail);
320    out
321}
322
323#[cfg(test)]
324#[allow(
325    clippy::float_cmp,
326    clippy::cast_precision_loss,
327    clippy::cast_possible_truncation,
328    clippy::cast_sign_loss,
329    clippy::similar_names,
330    clippy::doc_markdown,
331    clippy::needless_raw_string_hashes,
332    clippy::too_many_lines
333)]
334mod tests {
335    use super::*;
336    use std::path::PathBuf;
337
338    fn rec(path: &str, metric: &str, value: f64, limit: f64) -> OffenderRecord {
339        OffenderRecord {
340            path: PathBuf::from(path),
341            function: Some("f".into()),
342            start_line: 42,
343            end_line: 50,
344            start_col: Some(5),
345            metric: metric.into(),
346            value,
347            limit,
348            severity: Severity::Warning,
349        }
350    }
351
352    fn render(offenders: &[OffenderRecord]) -> String {
353        let mut buf = Vec::new();
354        write_code_climate(offenders, &mut buf).expect("writing to Vec is infallible");
355        String::from_utf8(buf).expect("output is UTF-8")
356    }
357
358    fn render_value(offenders: &[OffenderRecord]) -> serde_json::Value {
359        serde_json::from_str(&render(offenders)).expect("valid JSON")
360    }
361
362    #[test]
363    fn empty_input_emits_bracket_newline() {
364        assert_eq!(render(&[]), "[]\n");
365    }
366
367    #[test]
368    fn single_offender_anchored_snapshot() {
369        let mut r = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
370        r.start_col = None;
371        let v = render_value(&[r]);
372        insta::assert_json_snapshot!(v, @r#"
373        [
374          {
375            "check_name": "big-code-analysis/cyclomatic",
376            "description": "Cyclomatic Complexity exceeds the configured threshold. cyclomatic 17 exceeds limit 15",
377            "fingerprint": "209c41c7caa70e296f0bb82946cce7cc",
378            "location": {
379              "lines": {
380                "begin": 42,
381                "end": 50
382              },
383              "path": "src/foo.rs"
384            },
385            "severity": "minor"
386          }
387        ]
388        "#);
389    }
390
391    #[test]
392    fn multi_offender_with_column_and_file_level() {
393        let with_col = rec("src/a.rs", "cyclomatic", 30.0, 15.0);
394        let mut file_level = rec("src/b.rs", "loc.lloc", 250.0, 100.0);
395        file_level.function = None;
396        file_level.start_col = None;
397        let v = render_value(&[with_col, file_level]);
398        insta::assert_json_snapshot!(v, @r#"
399        [
400          {
401            "check_name": "big-code-analysis/cyclomatic",
402            "description": "Cyclomatic Complexity exceeds the configured threshold. cyclomatic 30 exceeds limit 15",
403            "fingerprint": "03dd26a883d163bd752853e1dd15557d",
404            "location": {
405              "lines": {
406                "begin": 42,
407                "end": 50
408              },
409              "path": "src/a.rs",
410              "positions": {
411                "begin": {
412                  "column": 5,
413                  "line": 42
414                }
415              }
416            },
417            "severity": "major"
418          },
419          {
420            "check_name": "big-code-analysis/loc.lloc",
421            "description": "Logical lines of code exceed the configured threshold. loc.lloc 250 exceeds limit 100",
422            "fingerprint": "cc3f570c9b909e186681cf36a6cffe5c",
423            "location": {
424              "lines": {
425                "begin": 42,
426                "end": 50
427              },
428              "path": "src/b.rs"
429            },
430            "severity": "critical"
431          }
432        ]
433        "#);
434    }
435
436    #[test]
437    fn severity_band_table_upward_metric() {
438        // limit=10, value at 1.0x/1.25x/1.75x/3.0x/10.0x of limit.
439        assert_eq!(
440            severity_band("cyclomatic", 10.0, 10.0, Severity::Warning),
441            "minor"
442        );
443        assert_eq!(
444            severity_band("cyclomatic", 12.5, 10.0, Severity::Warning),
445            "minor"
446        );
447        assert_eq!(
448            severity_band("cyclomatic", 17.5, 10.0, Severity::Warning),
449            "major"
450        );
451        assert_eq!(
452            severity_band("cyclomatic", 30.0, 10.0, Severity::Warning),
453            "critical"
454        );
455        assert_eq!(
456            severity_band("cyclomatic", 100.0, 10.0, Severity::Warning),
457            "blocker"
458        );
459    }
460
461    #[test]
462    fn severity_band_table_mi_family_inverts() {
463        // `mi.original` is the real offender id (the threshold-engine
464        // EXTRACTOR key). `severity_band` only ever sees offender ids,
465        // and inversion is now driven by `metric_catalog`'s per-row
466        // `Direction` rather than a `starts_with("mi.")` prefix — so the
467        // id must match the catalog exactly, not just the `mi.` prefix.
468        // limit=100 (MI threshold), lower value = worse violation.
469        assert_eq!(
470            severity_band("mi.original", 100.0, 100.0, Severity::Warning),
471            "minor"
472        );
473        // 100/50 = 2.0 → major.
474        assert_eq!(
475            severity_band("mi.original", 50.0, 100.0, Severity::Warning),
476            "major"
477        );
478        // 100/40 = 2.5 → critical.
479        assert_eq!(
480            severity_band("mi.original", 40.0, 100.0, Severity::Warning),
481            "critical"
482        );
483        // 100/10 = 10.0 → blocker.
484        assert_eq!(
485            severity_band("mi.original", 10.0, 100.0, Severity::Warning),
486            "blocker"
487        );
488    }
489
490    #[test]
491    fn declared_error_severity_is_a_floor_not_overridden_by_band() {
492        // An offender the engine marked `Error` must never render as the
493        // cosmetic `minor` band, even at a 1.1x breach ratio (#698).
494        // Before the fix the ratio band always won, so a 1.1x `Error`
495        // emitted `minor`.
496        assert_eq!(
497            severity_band("cyclomatic", 11.0, 10.0, Severity::Error),
498            "major",
499            "Error must floor at major even at a sub-1.5x ratio"
500        );
501        // A higher ratio still raises the level above the Error floor.
502        assert_eq!(
503            severity_band("cyclomatic", 30.0, 10.0, Severity::Error),
504            "critical"
505        );
506        // A Warning at the same small ratio stays minor (floor unchanged).
507        assert_eq!(
508            severity_band("cyclomatic", 11.0, 10.0, Severity::Warning),
509            "minor"
510        );
511    }
512
513    #[test]
514    fn description_lower_is_worse_uses_falls_below() {
515        // The Code Climate description tail must match the offender's
516        // direction-aware wording: an `mi.*` offender reads "falls below
517        // limit", not "exceeds limit" (#698).
518        let mut r = rec("a.rs", "mi.original", 30.0, 50.0);
519        r.start_col = None;
520        let v = render_value(&[r]);
521        let desc = v[0]["description"].as_str().expect("string");
522        assert!(
523            desc.contains("falls below limit 50"),
524            "mi.* description must say 'falls below limit', got: {desc}"
525        );
526        assert!(
527            desc.starts_with("Maintainability Index falls below the configured threshold."),
528            "mi.* long-form prefix expected, got: {desc}"
529        );
530    }
531
532    #[test]
533    fn severity_band_falls_back_when_limit_zero() {
534        assert_eq!(
535            severity_band("cyclomatic", 5.0, 0.0, Severity::Warning),
536            "minor"
537        );
538        assert_eq!(
539            severity_band("cyclomatic", 5.0, 0.0, Severity::Error),
540            "major"
541        );
542    }
543
544    #[test]
545    fn severity_band_falls_back_when_value_nan() {
546        assert_eq!(
547            severity_band("cyclomatic", f64::NAN, 10.0, Severity::Warning),
548            "minor"
549        );
550        assert_eq!(
551            severity_band("cyclomatic", f64::NAN, 10.0, Severity::Error),
552            "major"
553        );
554    }
555
556    #[test]
557    fn severity_band_falls_back_when_value_inf() {
558        assert_eq!(
559            severity_band("cyclomatic", f64::INFINITY, 10.0, Severity::Warning),
560            "minor"
561        );
562        assert_eq!(
563            severity_band("cyclomatic", f64::INFINITY, 10.0, Severity::Error),
564            "major"
565        );
566    }
567
568    #[test]
569    fn fingerprint_is_line_value_insensitive() {
570        let mut a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
571        let mut b = rec("src/foo.rs", "cyclomatic", 99.0, 15.0);
572        a.start_line = 10;
573        b.start_line = 20;
574        let va = render_value(&[a]);
575        let vb = render_value(&[b]);
576        assert_eq!(va[0]["fingerprint"], vb[0]["fingerprint"]);
577    }
578
579    #[test]
580    fn fingerprint_changes_with_metric() {
581        let a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
582        let b = rec("src/foo.rs", "cognitive", 17.0, 15.0);
583        let va = render_value(&[a]);
584        let vb = render_value(&[b]);
585        assert_ne!(va[0]["fingerprint"], vb[0]["fingerprint"]);
586    }
587
588    #[test]
589    fn fingerprint_changes_with_function() {
590        let mut a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
591        let mut b = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
592        a.function = Some("foo".into());
593        b.function = Some("bar".into());
594        let va = render_value(&[a]);
595        let vb = render_value(&[b]);
596        assert_ne!(va[0]["fingerprint"], vb[0]["fingerprint"]);
597    }
598
599    #[test]
600    fn fingerprint_changes_with_path() {
601        let a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
602        let b = rec("src/bar.rs", "cyclomatic", 17.0, 15.0);
603        let va = render_value(&[a]);
604        let vb = render_value(&[b]);
605        assert_ne!(va[0]["fingerprint"], vb[0]["fingerprint"]);
606    }
607
608    #[test]
609    fn fingerprint_handles_none_function() {
610        let none_fp = fingerprint("a.rs", None, "cyclomatic", 0);
611        let empty_fp = fingerprint("a.rs", Some(""), "cyclomatic", 0);
612        assert_eq!(none_fp, empty_fp);
613    }
614
615    #[test]
616    fn same_named_offenders_get_distinct_fingerprints() {
617        // Two same-named functions (overloads, sibling closures, a
618        // trait-impl `fn new`) each breaching the same metric in the same
619        // file share the `path \0 function \0 metric` triple. Before #703
620        // they hashed identically and GitLab's MR widget dropped the
621        // second; now the ordinal disambiguates them.
622        let mut a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
623        let mut b = rec("src/foo.rs", "cyclomatic", 22.0, 15.0);
624        a.function = Some("new".into());
625        b.function = Some("new".into());
626        a.start_line = 10;
627        b.start_line = 40;
628        let v = render_value(&[a, b]);
629        let arr = v.as_array().expect("array");
630        assert_eq!(arr.len(), 2, "both offenders must be emitted");
631        assert_ne!(
632            arr[0]["fingerprint"], arr[1]["fingerprint"],
633            "distinct same-named offenders must get distinct fingerprints"
634        );
635    }
636
637    #[test]
638    fn first_same_triple_offender_keeps_historical_fingerprint() {
639        // Ordinal 0 must hash exactly as the pre-#703 three-field form so
640        // unique findings keep their frozen GitLab-dedup fingerprint.
641        let legacy = fingerprint("src/foo.rs", Some("f"), "cyclomatic", 0);
642        let r = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
643        let v = render_value(&[r]);
644        assert_eq!(v[0]["fingerprint"], legacy);
645    }
646
647    #[test]
648    fn fingerprint_ordinal_changes_hash() {
649        let zero = fingerprint("a.rs", Some("f"), "cyclomatic", 0);
650        let one = fingerprint("a.rs", Some("f"), "cyclomatic", 1);
651        assert_ne!(zero, one);
652    }
653
654    #[test]
655    fn hex_lower_bytes_pads_low_bytes_to_two_chars() {
656        // Deterministic, input-controlled check of the zero-padding
657        // invariant. A `{:02x}` → `{:x}` regression would render
658        // `0x00` as `"0"` (not `"00"`), shortening the output and
659        // failing the explicit string comparison below. The
660        // fingerprint pipeline cannot directly surface a digest with
661        // leading zero bytes, so this is the load-bearing test for
662        // the format spec used by `fingerprint`.
663        assert_eq!(
664            hex_lower_bytes(&[0x00, 0x01, 0x0f, 0x10, 0xab, 0xff]),
665            "00010f10abff",
666        );
667        // Empty input → empty output (preserves the `len * 2` invariant).
668        assert_eq!(hex_lower_bytes(&[]), "");
669        // Single zero byte.
670        assert_eq!(hex_lower_bytes(&[0x00]), "00");
671    }
672
673    #[test]
674    fn fingerprint_uses_full_truncation_width() {
675        // Lock the constant against drift. If `FINGERPRINT_BYTE_LEN`
676        // changes, the truncation width in fingerprints changes too;
677        // we want a loud failure rather than a silent shift.
678        let fp = fingerprint("a.rs", Some("fn"), "cyclomatic", 0);
679        assert_eq!(fp.len(), FINGERPRINT_BYTE_LEN * 2);
680        assert!(
681            fp.chars()
682                .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())
683        );
684    }
685
686    #[test]
687    fn fingerprint_is_deterministic() {
688        let a = fingerprint("src/x.rs", Some("f"), "cyclomatic", 0);
689        let b = fingerprint("src/x.rs", Some("f"), "cyclomatic", 0);
690        assert_eq!(a, b);
691    }
692
693    #[cfg(unix)]
694    #[test]
695    fn non_utf8_path_is_skipped() {
696        use std::ffi::OsString;
697        use std::os::unix::ffi::OsStringExt;
698        let bad = OffenderRecord {
699            path: PathBuf::from(OsString::from_vec(b"weird-\xff\xfe.rs".to_vec())),
700            function: Some("f".into()),
701            start_line: 1,
702            end_line: 1,
703            start_col: None,
704            metric: "cyclomatic".into(),
705            value: 17.0,
706            limit: 15.0,
707            severity: Severity::Warning,
708        };
709        let good = rec("src/ok.rs", "cyclomatic", 17.0, 15.0);
710        let v = render_value(&[bad, good]);
711        let arr = v.as_array().expect("array");
712        assert_eq!(arr.len(), 1, "bad-path record skipped");
713        assert_eq!(arr[0]["location"]["path"], "src/ok.rs");
714    }
715
716    #[test]
717    fn windows_backslash_path_is_normalized() {
718        assert_eq!(
719            normalize_path(r"src\foo\bar.rs"),
720            Some("src/foo/bar.rs".to_owned())
721        );
722    }
723
724    #[test]
725    fn dot_slash_prefix_is_stripped() {
726        assert_eq!(
727            normalize_path("./src/foo.rs"),
728            Some("src/foo.rs".to_owned())
729        );
730        // Only one strip — leftover `./` is preserved.
731        assert_eq!(
732            normalize_path("././src/foo.rs"),
733            Some("./src/foo.rs".to_owned())
734        );
735    }
736
737    #[test]
738    fn path_normalising_to_empty_is_skipped() {
739        assert_eq!(normalize_path("./"), None);
740        assert_eq!(normalize_path(""), None);
741    }
742
743    #[test]
744    fn start_line_zero_is_clamped_to_one() {
745        let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
746        r.start_line = 0;
747        r.end_line = 0;
748        let v = render_value(&[r]);
749        assert_eq!(v[0]["location"]["lines"]["begin"], 1);
750        assert!(v[0]["location"]["lines"].get("end").is_none());
751    }
752
753    #[test]
754    fn end_line_less_than_or_equal_start_omits_end() {
755        let mut equal = rec("a.rs", "cyclomatic", 17.0, 15.0);
756        equal.start_line = 10;
757        equal.end_line = 10;
758        let v_equal = render_value(&[equal]);
759        assert!(v_equal[0]["location"]["lines"].get("end").is_none());
760
761        let mut less = rec("a.rs", "cyclomatic", 17.0, 15.0);
762        less.start_line = 10;
763        less.end_line = 5;
764        let v_less = render_value(&[less]);
765        assert!(v_less[0]["location"]["lines"].get("end").is_none());
766    }
767
768    #[test]
769    fn start_col_zero_omits_positions() {
770        let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
771        r.start_col = Some(0);
772        let v = render_value(&[r]);
773        assert!(v[0]["location"].get("positions").is_none());
774    }
775
776    #[test]
777    fn description_includes_long_form_when_metric_known() {
778        let known = rec("a.rs", "cyclomatic", 17.0, 15.0);
779        let v = render_value(&[known]);
780        let desc = v[0]["description"].as_str().expect("string");
781        assert!(
782            desc.starts_with("Cyclomatic Complexity exceeds the configured threshold."),
783            "expected long-form prefix, got: {desc}"
784        );
785        assert!(
786            desc.ends_with("cyclomatic 17 exceeds limit 15"),
787            "expected default_message tail, got: {desc}"
788        );
789
790        let unknown = rec("a.rs", "made.up.metric", 1.0, 0.0);
791        let v = render_value(&[unknown]);
792        assert_eq!(v[0]["description"], "made.up.metric 1 exceeds limit 0");
793    }
794
795    #[test]
796    fn check_name_is_tool_namespaced() {
797        let r = rec("a.rs", "halstead.effort", 5000.0, 1000.0);
798        let v = render_value(&[r]);
799        assert_eq!(v[0]["check_name"], "big-code-analysis/halstead.effort");
800    }
801
802    #[test]
803    fn output_has_no_bom() {
804        let r = rec("a.rs", "cyclomatic", 17.0, 15.0);
805        let mut buf = Vec::new();
806        write_code_climate(&[r], &mut buf).expect("writing to Vec is infallible");
807        // GitLab's parser rejects a UTF-8 BOM (EF BB BF) at the start
808        // of the artifact, so check the full three-byte prefix rather
809        // than just the first byte — the latter would still admit a
810        // future regression that emits only the leading `EF`.
811        assert!(
812            !buf.starts_with(&[0xEF, 0xBB, 0xBF]),
813            "code-climate output must not start with a UTF-8 BOM"
814        );
815        assert_eq!(buf[0], b'[', "first byte must be the opening bracket");
816    }
817}