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