use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt::Write as _;
use std::io::{self, Write};
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::metric_catalog::lookup;
use crate::output::offenders::{OffenderRecord, Severity, TOOL_ID, warn_non_utf8_path};
const FINGERPRINT_BYTE_LEN: usize = 16;
pub fn write_code_climate<W: Write>(offenders: &[OffenderRecord], mut writer: W) -> io::Result<()> {
if offenders.is_empty() {
return writer.write_all(b"[]\n");
}
let mut issues: Vec<CodeClimateIssue> = Vec::with_capacity(offenders.len());
let mut seen: HashMap<(String, Option<String>, String), u32> = HashMap::new();
for record in offenders {
let Some(path_raw) = warn_non_utf8_path("code-climate", &record.path) else {
continue;
};
let Some(path) = normalize_path(path_raw) else {
eprintln!(
"Warning: skipping empty repo-relative path in code-climate output: {}",
record.path.display()
);
continue;
};
let start_line = record.start_line.max(1);
let lines_end = (record.end_line > start_line).then_some(record.end_line);
let positions = record.start_col.filter(|c| *c > 0).map(|column| Positions {
begin: Position {
line: start_line,
column,
},
});
let key = (path.clone(), record.function.clone(), record.metric.clone());
let ordinal = seen.entry(key).or_insert(0);
let fingerprint = fingerprint(&path, record.function.as_deref(), &record.metric, *ordinal);
*ordinal += 1;
issues.push(CodeClimateIssue {
description: build_description(record),
check_name: format!("{TOOL_ID}/{}", record.metric),
fingerprint,
severity: severity_band(&record.metric, record.value, record.limit, record.severity),
location: Location {
path,
lines: Lines {
begin: start_line,
end: lines_end,
},
positions,
},
});
}
serde_json::to_writer(&mut writer, &issues)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
writer.write_all(b"\n")
}
#[derive(Serialize)]
struct CodeClimateIssue {
description: String,
check_name: String,
fingerprint: String,
severity: &'static str,
location: Location,
}
#[derive(Serialize)]
struct Location {
path: String,
lines: Lines,
#[serde(skip_serializing_if = "Option::is_none")]
positions: Option<Positions>,
}
#[derive(Serialize)]
struct Lines {
begin: u32,
#[serde(skip_serializing_if = "Option::is_none")]
end: Option<u32>,
}
#[derive(Serialize)]
struct Positions {
begin: Position,
}
#[derive(Serialize)]
struct Position {
line: u32,
column: u32,
}
fn severity_band(metric: &str, value: f64, limit: f64, severity: Severity) -> &'static str {
let floor = match severity {
Severity::Warning => "minor",
Severity::Error => "major",
};
if !value.is_finite() || !limit.is_finite() || limit <= 0.0 || value <= 0.0 {
return floor;
}
let lower_is_worse = crate::metric_catalog::lower_is_worse(metric);
let ratio = if lower_is_worse {
limit / value
} else {
value / limit
};
let band = if ratio <= 1.5 {
"minor"
} else if ratio <= 2.0 {
"major"
} else if ratio <= 4.0 {
"critical"
} else {
"blocker"
};
worst_severity(band, floor)
}
fn severity_rank(level: &str) -> u8 {
match level {
"minor" => 1,
"major" => 2,
"critical" => 3,
"blocker" => 4,
_ => 0,
}
}
fn worst_severity(a: &'static str, b: &'static str) -> &'static str {
if severity_rank(a) >= severity_rank(b) {
a
} else {
b
}
}
fn fingerprint(path: &str, function: Option<&str>, metric: &str, ordinal: u32) -> String {
let mut h = Sha256::new();
h.update(path.as_bytes());
h.update(b"\0");
h.update(function.unwrap_or("").as_bytes());
h.update(b"\0");
h.update(metric.as_bytes());
if ordinal > 0 {
h.update(b"\0");
h.update(ordinal.to_le_bytes());
}
let digest = h.finalize();
hex_lower_bytes(&digest[..FINGERPRINT_BYTE_LEN])
}
fn hex_lower_bytes(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(&mut out, "{byte:02x}");
}
out
}
fn normalize_path(raw: &str) -> Option<String> {
let normalized: Cow<'_, str> = if raw.contains('\\') {
Cow::Owned(raw.replace('\\', "/"))
} else {
Cow::Borrowed(raw)
};
let stripped = normalized.strip_prefix("./").unwrap_or(&normalized);
if stripped.is_empty() {
None
} else {
Some(stripped.to_owned())
}
}
fn build_description(record: &OffenderRecord) -> String {
let Some(long_form) = lookup(&record.metric).map(|i| i.long_description) else {
return record.default_message();
};
let tail = record.default_message();
let mut out = String::with_capacity(long_form.len() + 1 + tail.len());
out.push_str(long_form);
out.push(' ');
out.push_str(&tail);
out
}
#[cfg(test)]
#[allow(
clippy::float_cmp,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::similar_names,
clippy::doc_markdown,
clippy::needless_raw_string_hashes,
clippy::too_many_lines
)]
mod tests {
use super::*;
use std::path::PathBuf;
fn rec(path: &str, metric: &str, value: f64, limit: f64) -> OffenderRecord {
OffenderRecord {
path: PathBuf::from(path),
function: Some("f".into()),
start_line: 42,
end_line: 50,
start_col: Some(5),
metric: metric.into(),
value,
limit,
severity: Severity::Warning,
}
}
fn render(offenders: &[OffenderRecord]) -> String {
let mut buf = Vec::new();
write_code_climate(offenders, &mut buf).expect("writing to Vec is infallible");
String::from_utf8(buf).expect("output is UTF-8")
}
fn render_value(offenders: &[OffenderRecord]) -> serde_json::Value {
serde_json::from_str(&render(offenders)).expect("valid JSON")
}
#[test]
fn empty_input_emits_bracket_newline() {
assert_eq!(render(&[]), "[]\n");
}
#[test]
fn single_offender_anchored_snapshot() {
let mut r = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
r.start_col = None;
let v = render_value(&[r]);
insta::assert_json_snapshot!(v, @r#"
[
{
"check_name": "big-code-analysis/cyclomatic",
"description": "Cyclomatic Complexity exceeds the configured threshold. cyclomatic 17 exceeds limit 15",
"fingerprint": "209c41c7caa70e296f0bb82946cce7cc",
"location": {
"lines": {
"begin": 42,
"end": 50
},
"path": "src/foo.rs"
},
"severity": "minor"
}
]
"#);
}
#[test]
fn multi_offender_with_column_and_file_level() {
let with_col = rec("src/a.rs", "cyclomatic", 30.0, 15.0);
let mut file_level = rec("src/b.rs", "loc.lloc", 250.0, 100.0);
file_level.function = None;
file_level.start_col = None;
let v = render_value(&[with_col, file_level]);
insta::assert_json_snapshot!(v, @r#"
[
{
"check_name": "big-code-analysis/cyclomatic",
"description": "Cyclomatic Complexity exceeds the configured threshold. cyclomatic 30 exceeds limit 15",
"fingerprint": "03dd26a883d163bd752853e1dd15557d",
"location": {
"lines": {
"begin": 42,
"end": 50
},
"path": "src/a.rs",
"positions": {
"begin": {
"column": 5,
"line": 42
}
}
},
"severity": "major"
},
{
"check_name": "big-code-analysis/loc.lloc",
"description": "Logical lines of code exceed the configured threshold. loc.lloc 250 exceeds limit 100",
"fingerprint": "cc3f570c9b909e186681cf36a6cffe5c",
"location": {
"lines": {
"begin": 42,
"end": 50
},
"path": "src/b.rs"
},
"severity": "critical"
}
]
"#);
}
#[test]
fn severity_band_table_upward_metric() {
assert_eq!(
severity_band("cyclomatic", 10.0, 10.0, Severity::Warning),
"minor"
);
assert_eq!(
severity_band("cyclomatic", 12.5, 10.0, Severity::Warning),
"minor"
);
assert_eq!(
severity_band("cyclomatic", 17.5, 10.0, Severity::Warning),
"major"
);
assert_eq!(
severity_band("cyclomatic", 30.0, 10.0, Severity::Warning),
"critical"
);
assert_eq!(
severity_band("cyclomatic", 100.0, 10.0, Severity::Warning),
"blocker"
);
}
#[test]
fn severity_band_table_mi_family_inverts() {
assert_eq!(
severity_band("mi.original", 100.0, 100.0, Severity::Warning),
"minor"
);
assert_eq!(
severity_band("mi.original", 50.0, 100.0, Severity::Warning),
"major"
);
assert_eq!(
severity_band("mi.original", 40.0, 100.0, Severity::Warning),
"critical"
);
assert_eq!(
severity_band("mi.original", 10.0, 100.0, Severity::Warning),
"blocker"
);
}
#[test]
fn declared_error_severity_is_a_floor_not_overridden_by_band() {
assert_eq!(
severity_band("cyclomatic", 11.0, 10.0, Severity::Error),
"major",
"Error must floor at major even at a sub-1.5x ratio"
);
assert_eq!(
severity_band("cyclomatic", 30.0, 10.0, Severity::Error),
"critical"
);
assert_eq!(
severity_band("cyclomatic", 11.0, 10.0, Severity::Warning),
"minor"
);
}
#[test]
fn description_lower_is_worse_uses_falls_below() {
let mut r = rec("a.rs", "mi.original", 30.0, 50.0);
r.start_col = None;
let v = render_value(&[r]);
let desc = v[0]["description"].as_str().expect("string");
assert!(
desc.contains("falls below limit 50"),
"mi.* description must say 'falls below limit', got: {desc}"
);
assert!(
desc.starts_with("Maintainability Index falls below the configured threshold."),
"mi.* long-form prefix expected, got: {desc}"
);
}
#[test]
fn severity_band_falls_back_when_limit_zero() {
assert_eq!(
severity_band("cyclomatic", 5.0, 0.0, Severity::Warning),
"minor"
);
assert_eq!(
severity_band("cyclomatic", 5.0, 0.0, Severity::Error),
"major"
);
}
#[test]
fn severity_band_falls_back_when_value_nan() {
assert_eq!(
severity_band("cyclomatic", f64::NAN, 10.0, Severity::Warning),
"minor"
);
assert_eq!(
severity_band("cyclomatic", f64::NAN, 10.0, Severity::Error),
"major"
);
}
#[test]
fn severity_band_falls_back_when_value_inf() {
assert_eq!(
severity_band("cyclomatic", f64::INFINITY, 10.0, Severity::Warning),
"minor"
);
assert_eq!(
severity_band("cyclomatic", f64::INFINITY, 10.0, Severity::Error),
"major"
);
}
#[test]
fn fingerprint_is_line_value_insensitive() {
let mut a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
let mut b = rec("src/foo.rs", "cyclomatic", 99.0, 15.0);
a.start_line = 10;
b.start_line = 20;
let va = render_value(&[a]);
let vb = render_value(&[b]);
assert_eq!(va[0]["fingerprint"], vb[0]["fingerprint"]);
}
#[test]
fn fingerprint_changes_with_metric() {
let a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
let b = rec("src/foo.rs", "cognitive", 17.0, 15.0);
let va = render_value(&[a]);
let vb = render_value(&[b]);
assert_ne!(va[0]["fingerprint"], vb[0]["fingerprint"]);
}
#[test]
fn fingerprint_changes_with_function() {
let mut a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
let mut b = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
a.function = Some("foo".into());
b.function = Some("bar".into());
let va = render_value(&[a]);
let vb = render_value(&[b]);
assert_ne!(va[0]["fingerprint"], vb[0]["fingerprint"]);
}
#[test]
fn fingerprint_changes_with_path() {
let a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
let b = rec("src/bar.rs", "cyclomatic", 17.0, 15.0);
let va = render_value(&[a]);
let vb = render_value(&[b]);
assert_ne!(va[0]["fingerprint"], vb[0]["fingerprint"]);
}
#[test]
fn fingerprint_handles_none_function() {
let none_fp = fingerprint("a.rs", None, "cyclomatic", 0);
let empty_fp = fingerprint("a.rs", Some(""), "cyclomatic", 0);
assert_eq!(none_fp, empty_fp);
}
#[test]
fn same_named_offenders_get_distinct_fingerprints() {
let mut a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
let mut b = rec("src/foo.rs", "cyclomatic", 22.0, 15.0);
a.function = Some("new".into());
b.function = Some("new".into());
a.start_line = 10;
b.start_line = 40;
let v = render_value(&[a, b]);
let arr = v.as_array().expect("array");
assert_eq!(arr.len(), 2, "both offenders must be emitted");
assert_ne!(
arr[0]["fingerprint"], arr[1]["fingerprint"],
"distinct same-named offenders must get distinct fingerprints"
);
}
#[test]
fn first_same_triple_offender_keeps_historical_fingerprint() {
let legacy = fingerprint("src/foo.rs", Some("f"), "cyclomatic", 0);
let r = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
let v = render_value(&[r]);
assert_eq!(v[0]["fingerprint"], legacy);
}
#[test]
fn fingerprint_ordinal_changes_hash() {
let zero = fingerprint("a.rs", Some("f"), "cyclomatic", 0);
let one = fingerprint("a.rs", Some("f"), "cyclomatic", 1);
assert_ne!(zero, one);
}
#[test]
fn hex_lower_bytes_pads_low_bytes_to_two_chars() {
assert_eq!(
hex_lower_bytes(&[0x00, 0x01, 0x0f, 0x10, 0xab, 0xff]),
"00010f10abff",
);
assert_eq!(hex_lower_bytes(&[]), "");
assert_eq!(hex_lower_bytes(&[0x00]), "00");
}
#[test]
fn fingerprint_uses_full_truncation_width() {
let fp = fingerprint("a.rs", Some("fn"), "cyclomatic", 0);
assert_eq!(fp.len(), FINGERPRINT_BYTE_LEN * 2);
assert!(
fp.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())
);
}
#[test]
fn fingerprint_is_deterministic() {
let a = fingerprint("src/x.rs", Some("f"), "cyclomatic", 0);
let b = fingerprint("src/x.rs", Some("f"), "cyclomatic", 0);
assert_eq!(a, b);
}
#[cfg(unix)]
#[test]
fn non_utf8_path_is_skipped() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let bad = OffenderRecord {
path: PathBuf::from(OsString::from_vec(b"weird-\xff\xfe.rs".to_vec())),
function: Some("f".into()),
start_line: 1,
end_line: 1,
start_col: None,
metric: "cyclomatic".into(),
value: 17.0,
limit: 15.0,
severity: Severity::Warning,
};
let good = rec("src/ok.rs", "cyclomatic", 17.0, 15.0);
let v = render_value(&[bad, good]);
let arr = v.as_array().expect("array");
assert_eq!(arr.len(), 1, "bad-path record skipped");
assert_eq!(arr[0]["location"]["path"], "src/ok.rs");
}
#[test]
fn windows_backslash_path_is_normalized() {
assert_eq!(
normalize_path(r"src\foo\bar.rs"),
Some("src/foo/bar.rs".to_owned())
);
}
#[test]
fn dot_slash_prefix_is_stripped() {
assert_eq!(
normalize_path("./src/foo.rs"),
Some("src/foo.rs".to_owned())
);
assert_eq!(
normalize_path("././src/foo.rs"),
Some("./src/foo.rs".to_owned())
);
}
#[test]
fn path_normalising_to_empty_is_skipped() {
assert_eq!(normalize_path("./"), None);
assert_eq!(normalize_path(""), None);
}
#[test]
fn start_line_zero_is_clamped_to_one() {
let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
r.start_line = 0;
r.end_line = 0;
let v = render_value(&[r]);
assert_eq!(v[0]["location"]["lines"]["begin"], 1);
assert!(v[0]["location"]["lines"].get("end").is_none());
}
#[test]
fn end_line_less_than_or_equal_start_omits_end() {
let mut equal = rec("a.rs", "cyclomatic", 17.0, 15.0);
equal.start_line = 10;
equal.end_line = 10;
let v_equal = render_value(&[equal]);
assert!(v_equal[0]["location"]["lines"].get("end").is_none());
let mut less = rec("a.rs", "cyclomatic", 17.0, 15.0);
less.start_line = 10;
less.end_line = 5;
let v_less = render_value(&[less]);
assert!(v_less[0]["location"]["lines"].get("end").is_none());
}
#[test]
fn start_col_zero_omits_positions() {
let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
r.start_col = Some(0);
let v = render_value(&[r]);
assert!(v[0]["location"].get("positions").is_none());
}
#[test]
fn description_includes_long_form_when_metric_known() {
let known = rec("a.rs", "cyclomatic", 17.0, 15.0);
let v = render_value(&[known]);
let desc = v[0]["description"].as_str().expect("string");
assert!(
desc.starts_with("Cyclomatic Complexity exceeds the configured threshold."),
"expected long-form prefix, got: {desc}"
);
assert!(
desc.ends_with("cyclomatic 17 exceeds limit 15"),
"expected default_message tail, got: {desc}"
);
let unknown = rec("a.rs", "made.up.metric", 1.0, 0.0);
let v = render_value(&[unknown]);
assert_eq!(v[0]["description"], "made.up.metric 1 exceeds limit 0");
}
#[test]
fn check_name_is_tool_namespaced() {
let r = rec("a.rs", "halstead.effort", 5000.0, 1000.0);
let v = render_value(&[r]);
assert_eq!(v[0]["check_name"], "big-code-analysis/halstead.effort");
}
#[test]
fn output_has_no_bom() {
let r = rec("a.rs", "cyclomatic", 17.0, 15.0);
let mut buf = Vec::new();
write_code_climate(&[r], &mut buf).expect("writing to Vec is infallible");
assert!(
!buf.starts_with(&[0xEF, 0xBB, 0xBF]),
"code-climate output must not start with a UTF-8 BOM"
);
assert_eq!(buf[0], b'[', "first byte must be the opening bracket");
}
}