use std::collections::BTreeSet;
use std::io::{self, Write};
use serde::Serialize;
use crate::metric_catalog::lookup;
#[cfg(test)]
use crate::output::offenders::Severity;
use crate::output::offenders::{OffenderRecord, TOOL_ID, warn_non_utf8_path};
const SARIF_SCHEMA: &str = "https://json.schemastore.org/sarif-2.1.0.json";
const SARIF_VERSION: &str = "2.1.0";
fn is_windows_drive_abs(bytes: &[u8]) -> bool {
bytes.len() >= 2
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes.len() == 2 || bytes[2] == b'/' || bytes[2] == b'\\')
}
fn relative_first_segment_has_colon(bytes: &[u8]) -> bool {
!is_windows_drive_abs(bytes)
&& bytes.first() != Some(&b'/')
&& bytes.first() != Some(&b'\\')
&& bytes
.iter()
.take_while(|&&b| b != b'/' && b != b'\\')
.any(|&b| b == b':')
}
fn path_to_uri_reference(path: &str) -> String {
let bytes = path.as_bytes();
let is_windows_drive_abs = is_windows_drive_abs(bytes);
let first_segment_has_colon = relative_first_segment_has_colon(bytes);
let mut out = String::with_capacity(
path.len()
+ if is_windows_drive_abs { 8 } else { 0 }
+ usize::from(first_segment_has_colon) * 2,
);
if is_windows_drive_abs {
out.push_str("file:///");
} else if first_segment_has_colon {
out.push_str("./");
}
for &b in bytes {
match b {
b'\\' => out.push('/'),
b'A'..=b'Z'
| b'a'..=b'z'
| b'0'..=b'9'
| b'-'
| b'.'
| b'_'
| b'~'
| b'/'
| b':'
| b'@' => out.push(b as char),
_ => {
let hi = b >> 4;
let lo = b & 0xF;
out.push('%');
out.push(hex_digit(hi));
out.push(hex_digit(lo));
}
}
}
out
}
fn hex_digit(nibble: u8) -> char {
match nibble {
0..=9 => (b'0' + nibble) as char,
10..=15 => (b'A' + nibble - 10) as char,
_ => '0',
}
}
pub fn write_sarif<W: Write>(offenders: &[OffenderRecord], writer: W) -> io::Result<()> {
write_sarif_with_suppressed(offenders, &[], &[], writer)
}
pub fn write_sarif_with_suppressed<W: Write>(
active: &[OffenderRecord],
in_source: &[OffenderRecord],
baseline: &[OffenderRecord],
mut writer: W,
) -> io::Result<()> {
let mut results: Vec<SarifResult<'_>> =
Vec::with_capacity(active.len() + in_source.len() + baseline.len());
let mut rule_ids: BTreeSet<&str> = BTreeSet::new();
for (offenders, origin) in [
(active, None),
(in_source, Some(SuppressionOrigin::InSource)),
(baseline, Some(SuppressionOrigin::Baseline)),
] {
collect_results(offenders, origin, &mut results, &mut rule_ids);
}
let rules: Vec<Rule<'_>> = rule_ids
.iter()
.map(|id| Rule {
id,
short_description: Description {
text: lookup(id).map_or(*id, |info| info.long_description),
},
})
.collect();
let log = SarifLog {
schema: SARIF_SCHEMA,
version: SARIF_VERSION,
runs: vec![Run {
tool: Tool {
driver: Driver {
name: TOOL_ID,
version: env!("CARGO_PKG_VERSION"),
rules,
},
},
results,
}],
};
serde_json::to_writer_pretty(&mut writer, &log)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
writer.write_all(b"\n")
}
fn collect_results<'a>(
offenders: &'a [OffenderRecord],
origin: Option<SuppressionOrigin>,
results: &mut Vec<SarifResult<'a>>,
rule_ids: &mut BTreeSet<&'a str>,
) {
for record in offenders {
let Some(path_str) = warn_non_utf8_path("SARIF", &record.path) else {
continue;
};
rule_ids.insert(record.metric.as_str());
let logical_locations = record.function.as_deref().map(|name| {
vec![LogicalLocation {
fully_qualified_name: name,
}]
});
results.push(SarifResult {
rule_id: &record.metric,
level: record.severity.as_str(),
message: Message {
text: record.default_message(),
},
locations: vec![Location {
physical_location: PhysicalLocation {
artifact_location: ArtifactLocation {
uri: path_to_uri_reference(path_str),
},
region: Region {
start_line: record.start_line.max(1),
end_line: Some(record.end_line.max(record.start_line.max(1))),
start_column: record.start_col.map(|c| c.max(1)),
},
},
logical_locations,
}],
suppressions: origin.map(|o| {
vec![Suppression {
kind: o.sarif_kind(),
justification: o.justification(&record.metric),
}]
}),
});
}
}
#[derive(Debug, Clone, Copy)]
enum SuppressionOrigin {
InSource,
Baseline,
}
impl SuppressionOrigin {
fn sarif_kind(self) -> &'static str {
match self {
Self::InSource => "inSource",
Self::Baseline => "external",
}
}
fn justification(self, metric: &str) -> String {
match self {
Self::InSource => {
format!("metric '{metric}' silenced by an in-source bca suppression marker")
}
Self::Baseline => format!("metric '{metric}' within the recorded bca baseline"),
}
}
}
#[derive(Serialize)]
struct SarifLog<'a> {
#[serde(rename = "$schema")]
schema: &'a str,
version: &'a str,
runs: Vec<Run<'a>>,
}
#[derive(Serialize)]
struct Run<'a> {
tool: Tool<'a>,
results: Vec<SarifResult<'a>>,
}
#[derive(Serialize)]
struct Tool<'a> {
driver: Driver<'a>,
}
#[derive(Serialize)]
struct Driver<'a> {
name: &'a str,
version: &'a str,
rules: Vec<Rule<'a>>,
}
#[derive(Serialize)]
struct Rule<'a> {
id: &'a str,
#[serde(rename = "shortDescription")]
short_description: Description<'a>,
}
#[derive(Serialize)]
struct Description<'a> {
text: &'a str,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifResult<'a> {
rule_id: &'a str,
level: &'static str,
message: Message,
locations: Vec<Location<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
suppressions: Option<Vec<Suppression>>,
}
#[derive(Serialize)]
struct Suppression {
kind: &'static str,
justification: String,
}
#[derive(Serialize)]
struct Message {
text: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Location<'a> {
physical_location: PhysicalLocation,
#[serde(skip_serializing_if = "Option::is_none")]
logical_locations: Option<Vec<LogicalLocation<'a>>>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct PhysicalLocation {
artifact_location: ArtifactLocation,
region: Region,
}
#[derive(Serialize)]
struct ArtifactLocation {
uri: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Region {
start_line: u32,
#[serde(skip_serializing_if = "Option::is_none")]
end_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
start_column: Option<u32>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct LogicalLocation<'a> {
fully_qualified_name: &'a str,
}
#[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_sarif(offenders, &mut buf).expect("writing to Vec is infallible");
String::from_utf8(buf).expect("output is UTF-8")
}
#[test]
fn empty_emits_minimal_valid_run() {
let out = render(&[]);
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(v["version"], "2.1.0");
assert_eq!(v["runs"][0]["tool"]["driver"]["name"], "big-code-analysis");
assert!(
v["runs"][0]["results"]
.as_array()
.expect("array")
.is_empty()
);
assert!(
v["runs"][0]["tool"]["driver"]["rules"]
.as_array()
.expect("array")
.is_empty()
);
}
#[test]
fn single_offender_includes_rule_and_result() {
let offenders = vec![rec("src/foo.rs", "cyclomatic", 17.0, 15.0)];
let out = render(&offenders);
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
let result = &v["runs"][0]["results"][0];
assert_eq!(result["ruleId"], "cyclomatic");
assert_eq!(result["level"], "warning");
assert_eq!(result["message"]["text"], "cyclomatic 17 exceeds limit 15");
let loc = &result["locations"][0];
assert_eq!(
loc["physicalLocation"]["artifactLocation"]["uri"],
"src/foo.rs"
);
assert_eq!(loc["physicalLocation"]["region"]["startLine"], 42);
assert_eq!(loc["physicalLocation"]["region"]["endLine"], 50);
assert_eq!(loc["physicalLocation"]["region"]["startColumn"], 5);
assert_eq!(loc["logicalLocations"][0]["fullyQualifiedName"], "f");
let rule = &v["runs"][0]["tool"]["driver"]["rules"][0];
assert_eq!(rule["id"], "cyclomatic");
assert!(rule["shortDescription"]["text"].is_string());
}
#[test]
fn write_sarif_omits_suppressions_for_active_results() {
let out = render(&[rec("src/foo.rs", "cyclomatic", 17.0, 15.0)]);
assert!(
!out.contains("suppressions"),
"active result must not carry a suppressions array:\n{out}"
);
}
#[test]
fn suppressed_offenders_carry_suppressions_with_kind() {
let active = vec![rec("src/active.rs", "cyclomatic", 17.0, 15.0)];
let in_source = vec![rec("src/marked.rs", "halstead.effort", 9e4, 5e4)];
let baseline = vec![rec("src/legacy.rs", "cognitive", 26.0, 25.0)];
let mut buf = Vec::new();
write_sarif_with_suppressed(&active, &in_source, &baseline, &mut buf)
.expect("writing to Vec is infallible");
let v: serde_json::Value =
serde_json::from_str(&String::from_utf8(buf).expect("utf8")).expect("valid JSON");
let results = v["runs"][0]["results"].as_array().expect("array");
assert_eq!(results.len(), 3);
let active_r = results
.iter()
.find(|r| {
r["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] == "src/active.rs"
})
.expect("active result present");
assert!(active_r.get("suppressions").is_none());
let marked = results
.iter()
.find(|r| {
r["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] == "src/marked.rs"
})
.expect("in-source result present");
assert_eq!(marked["suppressions"][0]["kind"], "inSource");
assert!(
marked["suppressions"][0]["justification"]
.as_str()
.expect("justification string")
.contains("in-source")
);
let legacy = results
.iter()
.find(|r| {
r["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] == "src/legacy.rs"
})
.expect("baseline result present");
assert_eq!(legacy["suppressions"][0]["kind"], "external");
}
#[test]
fn error_severity_maps_to_error_level() {
let mut r = rec("a.rs", "cyclomatic", 99.0, 15.0);
r.severity = Severity::Error;
let out = render(&[r]);
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(v["runs"][0]["results"][0]["level"], "error");
}
#[test]
fn missing_column_omits_field() {
let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
r.start_col = None;
let out = render(&[r]);
assert!(!out.contains("startColumn"), "{out}");
}
#[test]
fn missing_function_omits_logical_locations() {
let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
r.function = None;
let out = render(&[r]);
assert!(!out.contains("logicalLocations"), "{out}");
}
#[test]
fn start_column_zero_is_clamped_to_one() {
let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
r.start_col = Some(0);
let out = render(&[r]);
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
let region = &v["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"];
assert_eq!(
region["startColumn"], 1,
"startColumn must clamp 0 -> 1, got: {region}"
);
}
#[test]
fn rules_deduplicate_per_metric() {
let offenders = vec![
rec("a.rs", "cyclomatic", 17.0, 15.0),
rec("b.rs", "cyclomatic", 20.0, 15.0),
rec("a.rs", "loc.lloc", 250.0, 100.0),
];
let out = render(&offenders);
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
let rules = v["runs"][0]["tool"]["driver"]["rules"]
.as_array()
.expect("array");
assert_eq!(rules.len(), 2);
assert_eq!(rules[0]["id"], "cyclomatic");
assert_eq!(rules[1]["id"], "loc.lloc");
}
#[test]
fn unknown_metric_falls_back_to_metric_name_as_description() {
let r = rec("a.rs", "made.up.metric", 1.0, 0.0);
let out = render(&[r]);
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(
v["runs"][0]["tool"]["driver"]["rules"][0]["shortDescription"]["text"],
"made.up.metric"
);
}
#[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 out = render(&[r]);
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(
v["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"]["startLine"],
1
);
}
#[test]
fn driver_version_matches_pkg_version() {
let out = render(&[]);
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(
v["runs"][0]["tool"]["driver"]["version"],
env!("CARGO_PKG_VERSION")
);
}
#[test]
fn windows_drive_path_becomes_file_uri() {
assert_eq!(
path_to_uri_reference(r"C:\Users\RUNNER~1\AppData\Local\Temp\fixture.rs"),
"file:///C:/Users/RUNNER~1/AppData/Local/Temp/fixture.rs"
);
}
#[test]
fn posix_relative_path_is_unchanged() {
assert_eq!(path_to_uri_reference("src/foo.rs"), "src/foo.rs");
}
#[test]
fn posix_absolute_path_keeps_leading_slash() {
assert_eq!(path_to_uri_reference("/tmp/foo.rs"), "/tmp/foo.rs");
}
#[test]
fn space_is_percent_encoded() {
assert_eq!(path_to_uri_reference("src/my file.rs"), "src/my%20file.rs");
}
#[test]
fn relative_path_with_colon_in_first_segment_is_not_scheme_ambiguous() {
assert_eq!(path_to_uri_reference("a:b/c.rs"), "./a:b/c.rs");
assert_eq!(path_to_uri_reference("foo:bar/baz.rs"), "./foo:bar/baz.rs");
}
#[test]
fn relative_path_with_colon_after_first_slash_is_unchanged() {
assert_eq!(path_to_uri_reference("a/b:c.rs"), "a/b:c.rs");
}
#[test]
fn normal_relative_path_keeps_no_dot_slash_prefix() {
assert_eq!(path_to_uri_reference("a/b/c.rs"), "a/b/c.rs");
}
#[test]
fn empty_snapshot_is_stable() {
insta::assert_snapshot!("sarif_empty", render(&[]));
}
#[test]
fn multi_offender_snapshot_is_stable() {
let mut err = rec("src/zeta.rs", "cognitive", 30.0, 15.0);
err.severity = Severity::Error;
err.start_col = None;
err.function = None;
let offenders = vec![
rec("src/alpha.rs", "cyclomatic", 17.0, 15.0),
rec("src/alpha.rs", "loc.lloc", 250.0, 100.0),
err,
];
insta::assert_snapshot!("sarif_multi", render(&offenders));
}
}