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::diag::warn;
48use crate::metric_catalog::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 metric value/limit ratio onto GitLab's five-level severity
168/// enum.
169///
170/// GitLab accepts `info`, `minor`, `major`, `critical`, `blocker`.
171/// We never emit `info`; the lowest band starts at `minor` so a
172/// threshold violation always shows in the MR widget (per the
173/// issue spec). The `mi.*` family inverts the ratio direction —
174/// for Maintainability Index, lower values mean *worse*, so the
175/// "how many times the threshold did we breach by" is `limit /
176/// value`, not `value / limit`.
177fn severity_band(metric: &str, value: f64, limit: f64, severity: Severity) -> &'static str {
178    // The declared `Severity` is a floor, not just a fallback: an
179    // offender the threshold engine marked `Error` must never render as
180    // the cosmetic `minor` band, even when its breach ratio is small
181    // (#698). `Warning` floors at `minor` (the lowest band we emit),
182    // `Error` at `major`, so a 1.1x `Error` reads as `major`, not
183    // `minor`. The computed ratio band can only raise the level above
184    // this floor, never lower it.
185    let floor = match severity {
186        Severity::Warning => "minor",
187        Severity::Error => "major",
188    };
189    // Filter ill-defined inputs BEFORE choosing the ratio direction:
190    // a future refactor that moves the metric-family check above this
191    // guard would let `mi.*` reach the inverted ratio with `value <=
192    // 0.0` and divide-by-zero. Keep the guards here.
193    if !value.is_finite() || !limit.is_finite() || limit <= 0.0 || value <= 0.0 {
194        return floor;
195    }
196    let lower_is_worse = crate::metric_catalog::lower_is_worse(metric);
197    let ratio = if lower_is_worse {
198        limit / value
199    } else {
200        value / limit
201    };
202    let band = if ratio <= 1.5 {
203        "minor"
204    } else if ratio <= 2.0 {
205        "major"
206    } else if ratio <= 4.0 {
207        "critical"
208    } else {
209        "blocker"
210    };
211    worst_severity(band, floor)
212}
213
214/// GitLab's severity enum as an ordered scale, lowest-first. Used to
215/// take the worst of the ratio-derived band and the declared-severity
216/// floor so a higher declared severity is never downgraded by a small
217/// breach ratio (#698). An unrecognized token ranks below `minor` so it
218/// never wins a comparison.
219fn severity_rank(level: &str) -> u8 {
220    match level {
221        "minor" => 1,
222        "major" => 2,
223        "critical" => 3,
224        "blocker" => 4,
225        _ => 0,
226    }
227}
228
229/// The more-severe of two GitLab severity tokens.
230fn worst_severity(a: &'static str, b: &'static str) -> &'static str {
231    if severity_rank(a) >= severity_rank(b) {
232        a
233    } else {
234        b
235    }
236}
237
238/// Compute a stable per-violation fingerprint. GitLab's MR widget
239/// uses this for deduplication across pipeline runs and for the
240/// base-vs-head diff, so we deliberately omit the line number and
241/// metric value — both shift on cosmetic edits that should not
242/// re-surface a known violation.
243///
244/// `ordinal` disambiguates findings that share the same `path \0
245/// function \0 metric` triple (two same-named functions each breaching
246/// the same metric). The first occurrence (`ordinal == 0`) is hashed
247/// exactly as before so unique findings keep their historical
248/// fingerprint and the frozen GitLab-dedup contract is preserved; only a
249/// genuine collision folds the ordinal into the digest, giving the
250/// second and later same-triple findings distinct fingerprints so
251/// GitLab no longer drops them (#703). An ordinal survives cosmetic
252/// edits as long as the relative source order of the same-named
253/// functions is unchanged — preferred over `end_line`, which shifts on
254/// any edit above the function.
255///
256/// Truncated to [`FINGERPRINT_BYTE_LEN`] bytes per the issue spec
257/// (32 hex chars by construction). Leading-zero bytes are preserved
258/// by [`hex_lower_bytes`] (which a `format!("{:x}", u128)` rendering
259/// would silently drop).
260fn fingerprint(path: &str, function: Option<&str>, metric: &str, ordinal: u32) -> String {
261    let mut h = Sha256::new();
262    h.update(path.as_bytes());
263    h.update(b"\0");
264    h.update(function.unwrap_or("").as_bytes());
265    h.update(b"\0");
266    h.update(metric.as_bytes());
267    if ordinal > 0 {
268        h.update(b"\0");
269        h.update(ordinal.to_le_bytes());
270    }
271    let digest = h.finalize();
272    hex_lower_bytes(&digest[..FINGERPRINT_BYTE_LEN])
273}
274
275/// Lowercase, zero-padded hex encoding of `bytes`. Extracted from
276/// [`fingerprint`] so the zero-padding invariant can be tested with
277/// synthetic byte sequences (including bytes < `0x10`) that the
278/// SHA-256 driver of `fingerprint` does not surface deterministically.
279fn hex_lower_bytes(bytes: &[u8]) -> String {
280    let mut out = String::with_capacity(bytes.len() * 2);
281    for byte in bytes {
282        // write! to a String is infallible; the Result is discarded
283        // intentionally rather than unwrapped to avoid an `expect`
284        // in non-test code.
285        let _ = write!(&mut out, "{byte:02x}");
286    }
287    out
288}
289
290fn normalize_path(raw: &str) -> Option<String> {
291    // Avoid a transient String allocation on the no-backslash path
292    // (the typical Linux-CI case). The `replace` allocation only
293    // pays for itself when there's actually a backslash to swap.
294    let normalized: Cow<'_, str> = if raw.contains('\\') {
295        Cow::Owned(raw.replace('\\', "/"))
296    } else {
297        Cow::Borrowed(raw)
298    };
299    let stripped = normalized.strip_prefix("./").unwrap_or(&normalized);
300    if stripped.is_empty() {
301        None
302    } else {
303        Some(stripped.to_owned())
304    }
305}
306
307fn build_description(record: &OffenderRecord) -> String {
308    let Some(long_form) = lookup(&record.metric).map(|i| i.long_description) else {
309        return record.default_message();
310    };
311    // Prefix the catalog's long-form sentence, then reuse the offender's
312    // direction-aware `default_message` tail so the breach phrasing
313    // ("exceeds limit" vs the `mi.*` "falls below limit", #698) stays in
314    // lockstep with the SARIF / Checkstyle / warning-line surfaces
315    // instead of hardcoding "exceeds limit" here.
316    let tail = record.default_message();
317    let mut out = String::with_capacity(long_form.len() + 1 + tail.len());
318    out.push_str(long_form);
319    out.push(' ');
320    out.push_str(&tail);
321    out
322}
323
324#[cfg(test)]
325#[allow(
326    clippy::float_cmp,
327    clippy::cast_precision_loss,
328    clippy::cast_possible_truncation,
329    clippy::cast_sign_loss,
330    clippy::similar_names,
331    clippy::doc_markdown,
332    clippy::needless_raw_string_hashes,
333    clippy::too_many_lines
334)]
335mod tests {
336    use super::*;
337    use std::path::PathBuf;
338
339    fn rec(path: &str, metric: &str, value: f64, limit: f64) -> OffenderRecord {
340        OffenderRecord {
341            path: PathBuf::from(path),
342            function: Some("f".into()),
343            start_line: 42,
344            end_line: 50,
345            start_col: Some(5),
346            metric: metric.into(),
347            value,
348            limit,
349            severity: Severity::Warning,
350        }
351    }
352
353    fn render(offenders: &[OffenderRecord]) -> String {
354        let mut buf = Vec::new();
355        write_code_climate(offenders, &mut buf).expect("writing to Vec is infallible");
356        String::from_utf8(buf).expect("output is UTF-8")
357    }
358
359    fn render_value(offenders: &[OffenderRecord]) -> serde_json::Value {
360        serde_json::from_str(&render(offenders)).expect("valid JSON")
361    }
362
363    #[test]
364    fn empty_input_emits_bracket_newline() {
365        assert_eq!(render(&[]), "[]\n");
366    }
367
368    #[test]
369    fn single_offender_anchored_snapshot() {
370        let mut r = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
371        r.start_col = None;
372        let v = render_value(&[r]);
373        insta::assert_json_snapshot!(v, @r#"
374        [
375          {
376            "check_name": "big-code-analysis/cyclomatic",
377            "description": "Cyclomatic Complexity exceeds the configured threshold. cyclomatic 17 exceeds limit 15",
378            "fingerprint": "209c41c7caa70e296f0bb82946cce7cc",
379            "location": {
380              "lines": {
381                "begin": 42,
382                "end": 50
383              },
384              "path": "src/foo.rs"
385            },
386            "severity": "minor"
387          }
388        ]
389        "#);
390    }
391
392    #[test]
393    fn multi_offender_with_column_and_file_level() {
394        let with_col = rec("src/a.rs", "cyclomatic", 30.0, 15.0);
395        let mut file_level = rec("src/b.rs", "loc.lloc", 250.0, 100.0);
396        file_level.function = None;
397        file_level.start_col = None;
398        let v = render_value(&[with_col, file_level]);
399        insta::assert_json_snapshot!(v, @r#"
400        [
401          {
402            "check_name": "big-code-analysis/cyclomatic",
403            "description": "Cyclomatic Complexity exceeds the configured threshold. cyclomatic 30 exceeds limit 15",
404            "fingerprint": "03dd26a883d163bd752853e1dd15557d",
405            "location": {
406              "lines": {
407                "begin": 42,
408                "end": 50
409              },
410              "path": "src/a.rs",
411              "positions": {
412                "begin": {
413                  "column": 5,
414                  "line": 42
415                }
416              }
417            },
418            "severity": "major"
419          },
420          {
421            "check_name": "big-code-analysis/loc.lloc",
422            "description": "Logical lines of code exceed the configured threshold. loc.lloc 250 exceeds limit 100",
423            "fingerprint": "cc3f570c9b909e186681cf36a6cffe5c",
424            "location": {
425              "lines": {
426                "begin": 42,
427                "end": 50
428              },
429              "path": "src/b.rs"
430            },
431            "severity": "critical"
432          }
433        ]
434        "#);
435    }
436
437    #[test]
438    fn severity_band_table_upward_metric() {
439        // limit=10, value at 1.0x/1.25x/1.75x/3.0x/10.0x of limit.
440        assert_eq!(
441            severity_band("cyclomatic", 10.0, 10.0, Severity::Warning),
442            "minor"
443        );
444        assert_eq!(
445            severity_band("cyclomatic", 12.5, 10.0, Severity::Warning),
446            "minor"
447        );
448        assert_eq!(
449            severity_band("cyclomatic", 17.5, 10.0, Severity::Warning),
450            "major"
451        );
452        assert_eq!(
453            severity_band("cyclomatic", 30.0, 10.0, Severity::Warning),
454            "critical"
455        );
456        assert_eq!(
457            severity_band("cyclomatic", 100.0, 10.0, Severity::Warning),
458            "blocker"
459        );
460    }
461
462    #[test]
463    fn severity_band_table_mi_family_inverts() {
464        // `mi.original` is the real offender id (the threshold-engine
465        // EXTRACTOR key). `severity_band` only ever sees offender ids,
466        // and inversion is now driven by `metric_catalog`'s per-row
467        // `Direction` rather than a `starts_with("mi.")` prefix — so the
468        // id must match the catalog exactly, not just the `mi.` prefix.
469        // limit=100 (MI threshold), lower value = worse violation.
470        assert_eq!(
471            severity_band("mi.original", 100.0, 100.0, Severity::Warning),
472            "minor"
473        );
474        // 100/50 = 2.0 → major.
475        assert_eq!(
476            severity_band("mi.original", 50.0, 100.0, Severity::Warning),
477            "major"
478        );
479        // 100/40 = 2.5 → critical.
480        assert_eq!(
481            severity_band("mi.original", 40.0, 100.0, Severity::Warning),
482            "critical"
483        );
484        // 100/10 = 10.0 → blocker.
485        assert_eq!(
486            severity_band("mi.original", 10.0, 100.0, Severity::Warning),
487            "blocker"
488        );
489    }
490
491    #[test]
492    fn declared_error_severity_is_a_floor_not_overridden_by_band() {
493        // An offender the engine marked `Error` must never render as the
494        // cosmetic `minor` band, even at a 1.1x breach ratio (#698).
495        // Before the fix the ratio band always won, so a 1.1x `Error`
496        // emitted `minor`.
497        assert_eq!(
498            severity_band("cyclomatic", 11.0, 10.0, Severity::Error),
499            "major",
500            "Error must floor at major even at a sub-1.5x ratio"
501        );
502        // A higher ratio still raises the level above the Error floor.
503        assert_eq!(
504            severity_band("cyclomatic", 30.0, 10.0, Severity::Error),
505            "critical"
506        );
507        // A Warning at the same small ratio stays minor (floor unchanged).
508        assert_eq!(
509            severity_band("cyclomatic", 11.0, 10.0, Severity::Warning),
510            "minor"
511        );
512    }
513
514    #[test]
515    fn description_lower_is_worse_uses_falls_below() {
516        // The Code Climate description tail must match the offender's
517        // direction-aware wording: an `mi.*` offender reads "falls below
518        // limit", not "exceeds limit" (#698).
519        let mut r = rec("a.rs", "mi.original", 30.0, 50.0);
520        r.start_col = None;
521        let v = render_value(&[r]);
522        let desc = v[0]["description"].as_str().expect("string");
523        assert!(
524            desc.contains("falls below limit 50"),
525            "mi.* description must say 'falls below limit', got: {desc}"
526        );
527        assert!(
528            desc.starts_with("Maintainability Index falls below the configured threshold."),
529            "mi.* long-form prefix expected, got: {desc}"
530        );
531    }
532
533    #[test]
534    fn severity_band_falls_back_when_limit_zero() {
535        assert_eq!(
536            severity_band("cyclomatic", 5.0, 0.0, Severity::Warning),
537            "minor"
538        );
539        assert_eq!(
540            severity_band("cyclomatic", 5.0, 0.0, Severity::Error),
541            "major"
542        );
543    }
544
545    #[test]
546    fn severity_band_falls_back_when_value_nan() {
547        assert_eq!(
548            severity_band("cyclomatic", f64::NAN, 10.0, Severity::Warning),
549            "minor"
550        );
551        assert_eq!(
552            severity_band("cyclomatic", f64::NAN, 10.0, Severity::Error),
553            "major"
554        );
555    }
556
557    #[test]
558    fn severity_band_falls_back_when_value_inf() {
559        assert_eq!(
560            severity_band("cyclomatic", f64::INFINITY, 10.0, Severity::Warning),
561            "minor"
562        );
563        assert_eq!(
564            severity_band("cyclomatic", f64::INFINITY, 10.0, Severity::Error),
565            "major"
566        );
567    }
568
569    #[test]
570    fn fingerprint_is_line_value_insensitive() {
571        let mut a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
572        let mut b = rec("src/foo.rs", "cyclomatic", 99.0, 15.0);
573        a.start_line = 10;
574        b.start_line = 20;
575        let va = render_value(&[a]);
576        let vb = render_value(&[b]);
577        assert_eq!(va[0]["fingerprint"], vb[0]["fingerprint"]);
578    }
579
580    #[test]
581    fn fingerprint_changes_with_metric() {
582        let a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
583        let b = rec("src/foo.rs", "cognitive", 17.0, 15.0);
584        let va = render_value(&[a]);
585        let vb = render_value(&[b]);
586        assert_ne!(va[0]["fingerprint"], vb[0]["fingerprint"]);
587    }
588
589    #[test]
590    fn fingerprint_changes_with_function() {
591        let mut a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
592        let mut b = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
593        a.function = Some("foo".into());
594        b.function = Some("bar".into());
595        let va = render_value(&[a]);
596        let vb = render_value(&[b]);
597        assert_ne!(va[0]["fingerprint"], vb[0]["fingerprint"]);
598    }
599
600    #[test]
601    fn fingerprint_changes_with_path() {
602        let a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
603        let b = rec("src/bar.rs", "cyclomatic", 17.0, 15.0);
604        let va = render_value(&[a]);
605        let vb = render_value(&[b]);
606        assert_ne!(va[0]["fingerprint"], vb[0]["fingerprint"]);
607    }
608
609    #[test]
610    fn fingerprint_handles_none_function() {
611        let none_fp = fingerprint("a.rs", None, "cyclomatic", 0);
612        let empty_fp = fingerprint("a.rs", Some(""), "cyclomatic", 0);
613        assert_eq!(none_fp, empty_fp);
614    }
615
616    #[test]
617    fn same_named_offenders_get_distinct_fingerprints() {
618        // Two same-named functions (overloads, sibling closures, a
619        // trait-impl `fn new`) each breaching the same metric in the same
620        // file share the `path \0 function \0 metric` triple. Before #703
621        // they hashed identically and GitLab's MR widget dropped the
622        // second; now the ordinal disambiguates them.
623        let mut a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
624        let mut b = rec("src/foo.rs", "cyclomatic", 22.0, 15.0);
625        a.function = Some("new".into());
626        b.function = Some("new".into());
627        a.start_line = 10;
628        b.start_line = 40;
629        let v = render_value(&[a, b]);
630        let arr = v.as_array().expect("array");
631        assert_eq!(arr.len(), 2, "both offenders must be emitted");
632        assert_ne!(
633            arr[0]["fingerprint"], arr[1]["fingerprint"],
634            "distinct same-named offenders must get distinct fingerprints"
635        );
636    }
637
638    #[test]
639    fn first_same_triple_offender_keeps_historical_fingerprint() {
640        // Ordinal 0 must hash exactly as the pre-#703 three-field form so
641        // unique findings keep their frozen GitLab-dedup fingerprint.
642        let legacy = fingerprint("src/foo.rs", Some("f"), "cyclomatic", 0);
643        let r = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
644        let v = render_value(&[r]);
645        assert_eq!(v[0]["fingerprint"], legacy);
646    }
647
648    #[test]
649    fn fingerprint_ordinal_changes_hash() {
650        let zero = fingerprint("a.rs", Some("f"), "cyclomatic", 0);
651        let one = fingerprint("a.rs", Some("f"), "cyclomatic", 1);
652        assert_ne!(zero, one);
653    }
654
655    #[test]
656    fn hex_lower_bytes_pads_low_bytes_to_two_chars() {
657        // Deterministic, input-controlled check of the zero-padding
658        // invariant. A `{:02x}` → `{:x}` regression would render
659        // `0x00` as `"0"` (not `"00"`), shortening the output and
660        // failing the explicit string comparison below. The
661        // fingerprint pipeline cannot directly surface a digest with
662        // leading zero bytes, so this is the load-bearing test for
663        // the format spec used by `fingerprint`.
664        assert_eq!(
665            hex_lower_bytes(&[0x00, 0x01, 0x0f, 0x10, 0xab, 0xff]),
666            "00010f10abff",
667        );
668        // Empty input → empty output (preserves the `len * 2` invariant).
669        assert_eq!(hex_lower_bytes(&[]), "");
670        // Single zero byte.
671        assert_eq!(hex_lower_bytes(&[0x00]), "00");
672    }
673
674    #[test]
675    fn fingerprint_uses_full_truncation_width() {
676        // Lock the constant against drift. If `FINGERPRINT_BYTE_LEN`
677        // changes, the truncation width in fingerprints changes too;
678        // we want a loud failure rather than a silent shift.
679        let fp = fingerprint("a.rs", Some("fn"), "cyclomatic", 0);
680        assert_eq!(fp.len(), FINGERPRINT_BYTE_LEN * 2);
681        assert!(
682            fp.chars()
683                .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())
684        );
685    }
686
687    #[test]
688    fn fingerprint_is_deterministic() {
689        let a = fingerprint("src/x.rs", Some("f"), "cyclomatic", 0);
690        let b = fingerprint("src/x.rs", Some("f"), "cyclomatic", 0);
691        assert_eq!(a, b);
692    }
693
694    #[cfg(unix)]
695    #[test]
696    fn non_utf8_path_is_skipped() {
697        use std::ffi::OsString;
698        use std::os::unix::ffi::OsStringExt;
699        let bad = OffenderRecord {
700            path: PathBuf::from(OsString::from_vec(b"weird-\xff\xfe.rs".to_vec())),
701            function: Some("f".into()),
702            start_line: 1,
703            end_line: 1,
704            start_col: None,
705            metric: "cyclomatic".into(),
706            value: 17.0,
707            limit: 15.0,
708            severity: Severity::Warning,
709        };
710        let good = rec("src/ok.rs", "cyclomatic", 17.0, 15.0);
711        let v = render_value(&[bad, good]);
712        let arr = v.as_array().expect("array");
713        assert_eq!(arr.len(), 1, "bad-path record skipped");
714        assert_eq!(arr[0]["location"]["path"], "src/ok.rs");
715    }
716
717    #[test]
718    fn windows_backslash_path_is_normalized() {
719        assert_eq!(
720            normalize_path(r"src\foo\bar.rs"),
721            Some("src/foo/bar.rs".to_owned())
722        );
723    }
724
725    #[test]
726    fn dot_slash_prefix_is_stripped() {
727        assert_eq!(
728            normalize_path("./src/foo.rs"),
729            Some("src/foo.rs".to_owned())
730        );
731        // Only one strip — leftover `./` is preserved.
732        assert_eq!(
733            normalize_path("././src/foo.rs"),
734            Some("./src/foo.rs".to_owned())
735        );
736    }
737
738    #[test]
739    fn path_normalising_to_empty_is_skipped() {
740        assert_eq!(normalize_path("./"), None);
741        assert_eq!(normalize_path(""), None);
742    }
743
744    #[test]
745    fn offender_whose_path_normalises_to_empty_is_dropped_from_the_report() {
746        // The unit test above pins `normalize_path`; this pins what the
747        // *writer* does with its `None` — skip that finding and carry
748        // on, rather than abandoning the document or emitting a finding
749        // with an empty `location.path` (which Code Climate consumers
750        // key on). Only a second, well-formed offender can tell those
751        // apart, so the good record is what makes the assertion sharp.
752        let v = render_value(&[
753            rec("./", "cyclomatic", 17.0, 15.0),
754            rec("src/good.rs", "cognitive", 20.0, 15.0),
755        ]);
756        let findings = v.as_array().expect("an array of findings");
757        assert_eq!(findings.len(), 1, "the empty-path offender is skipped: {v}");
758        assert_eq!(findings[0]["location"]["path"], "src/good.rs");
759    }
760
761    #[test]
762    fn start_line_zero_is_clamped_to_one() {
763        let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
764        r.start_line = 0;
765        r.end_line = 0;
766        let v = render_value(&[r]);
767        assert_eq!(v[0]["location"]["lines"]["begin"], 1);
768        assert!(v[0]["location"]["lines"].get("end").is_none());
769    }
770
771    #[test]
772    fn end_line_less_than_or_equal_start_omits_end() {
773        let mut equal = rec("a.rs", "cyclomatic", 17.0, 15.0);
774        equal.start_line = 10;
775        equal.end_line = 10;
776        let v_equal = render_value(&[equal]);
777        assert!(v_equal[0]["location"]["lines"].get("end").is_none());
778
779        let mut less = rec("a.rs", "cyclomatic", 17.0, 15.0);
780        less.start_line = 10;
781        less.end_line = 5;
782        let v_less = render_value(&[less]);
783        assert!(v_less[0]["location"]["lines"].get("end").is_none());
784    }
785
786    #[test]
787    fn start_col_zero_omits_positions() {
788        let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
789        r.start_col = Some(0);
790        let v = render_value(&[r]);
791        assert!(v[0]["location"].get("positions").is_none());
792    }
793
794    #[test]
795    fn description_includes_long_form_when_metric_known() {
796        let known = rec("a.rs", "cyclomatic", 17.0, 15.0);
797        let v = render_value(&[known]);
798        let desc = v[0]["description"].as_str().expect("string");
799        assert!(
800            desc.starts_with("Cyclomatic Complexity exceeds the configured threshold."),
801            "expected long-form prefix, got: {desc}"
802        );
803        assert!(
804            desc.ends_with("cyclomatic 17 exceeds limit 15"),
805            "expected default_message tail, got: {desc}"
806        );
807
808        let unknown = rec("a.rs", "made.up.metric", 1.0, 0.0);
809        let v = render_value(&[unknown]);
810        assert_eq!(v[0]["description"], "made.up.metric 1 exceeds limit 0");
811    }
812
813    #[test]
814    fn check_name_is_tool_namespaced() {
815        let r = rec("a.rs", "halstead.effort", 5000.0, 1000.0);
816        let v = render_value(&[r]);
817        assert_eq!(v[0]["check_name"], "big-code-analysis/halstead.effort");
818    }
819
820    #[test]
821    fn output_has_no_bom() {
822        let r = rec("a.rs", "cyclomatic", 17.0, 15.0);
823        let mut buf = Vec::new();
824        write_code_climate(&[r], &mut buf).expect("writing to Vec is infallible");
825        // GitLab's parser rejects a UTF-8 BOM (EF BB BF) at the start
826        // of the artifact, so check the full three-byte prefix rather
827        // than just the first byte — the latter would still admit a
828        // future regression that emits only the leading `EF`.
829        assert!(
830            !buf.starts_with(&[0xEF, 0xBB, 0xBF]),
831            "code-climate output must not start with a UTF-8 BOM"
832        );
833        assert_eq!(buf[0], b'[', "first byte must be the opening bracket");
834    }
835}