use serde_json::{json, Value};
use crate::{CheckResult, EvidenceData, MultiReport, Report, Severity, Verdict};
const SARIF_VERSION: &str = "2.1.0";
const SARIF_SCHEMA_URI: &str =
"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json";
const TOOL_INFO_URI: &str = "https://github.com/jamesgober/dev-report";
pub fn to_sarif(report: &Report) -> String {
let log = json!({
"version": SARIF_VERSION,
"$schema": SARIF_SCHEMA_URI,
"runs": [run_for(report)]
});
serde_json::to_string_pretty(&log).expect("SARIF JSON is always serializable")
}
pub fn multi_to_sarif(multi: &MultiReport) -> String {
let runs: Vec<Value> = multi.reports.iter().map(run_for).collect();
let log = json!({
"version": SARIF_VERSION,
"$schema": SARIF_SCHEMA_URI,
"runs": runs
});
serde_json::to_string_pretty(&log).expect("SARIF JSON is always serializable")
}
fn run_for(report: &Report) -> Value {
let driver_name = report.producer.as_deref().unwrap_or("dev-report");
let results: Vec<Value> = report
.checks
.iter()
.filter(|c| matches!(c.verdict, Verdict::Fail | Verdict::Warn))
.map(result_for)
.collect();
json!({
"tool": {
"driver": {
"name": driver_name,
"informationUri": TOOL_INFO_URI
}
},
"results": results
})
}
fn result_for(check: &CheckResult) -> Value {
let level = level_for(check.severity);
let message_text = check.detail.clone().unwrap_or_else(|| check.name.clone());
let mut result = json!({
"ruleId": check.name,
"level": level,
"message": { "text": message_text }
});
let locations: Vec<Value> = check
.evidence
.iter()
.filter_map(|e| match &e.data {
EvidenceData::FileRef(f) => Some(location_for(f)),
_ => None,
})
.collect();
if !locations.is_empty() {
result["locations"] = Value::Array(locations);
}
result
}
fn level_for(severity: Option<Severity>) -> &'static str {
match severity {
Some(Severity::Critical) | Some(Severity::Error) => "error",
Some(Severity::Warning) => "warning",
Some(Severity::Info) => "note",
None => "none",
}
}
fn location_for(file_ref: &crate::FileRef) -> Value {
let mut physical = serde_json::Map::new();
physical.insert(
"artifactLocation".into(),
json!({ "uri": artifact_uri(&file_ref.path) }),
);
if let Some(s) = file_ref.line_start.filter(|&n| n >= 1) {
let mut region = serde_json::Map::new();
region.insert("startLine".into(), Value::Number(s.into()));
if let Some(e) = file_ref.line_end.filter(|&e| e >= s) {
region.insert("endLine".into(), Value::Number(e.into()));
}
physical.insert("region".into(), Value::Object(region));
}
json!({ "physicalLocation": Value::Object(physical) })
}
fn artifact_uri(path: &str) -> String {
let normalized = path.replace('\\', "/");
let bytes = normalized.as_bytes();
let is_drive = bytes.len() >= 2
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes.len() == 2 || bytes[2] == b'/');
let prefix = if normalized.starts_with("//") {
"file:"
} else if is_drive {
"file:///"
} else if normalized.starts_with('/') {
"file://"
} else {
""
};
let mut out = String::with_capacity(prefix.len() + normalized.len());
out.push_str(prefix);
for (i, b) in normalized.bytes().enumerate() {
let drive_colon = is_drive && i == 1;
let keep = drive_colon
|| b.is_ascii_alphanumeric()
|| matches!(
b,
b'-' | b'.'
| b'_'
| b'~'
| b'/'
| b'!'
| b'$'
| b'&'
| b'\''
| b'('
| b')'
| b'*'
| b'+'
| b','
| b';'
| b'='
| b'@'
);
if keep {
out.push(char::from(b));
} else {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
out.push('%');
out.push(char::from(HEX[usize::from(b >> 4)]));
out.push(char::from(HEX[usize::from(b & 0x0f)]));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Evidence, FileRef};
#[test]
fn skips_pass_and_skip_checks() {
let mut r = Report::new("c", "0.1.0").with_producer("p");
r.push(CheckResult::pass("ok"));
r.push(CheckResult::skip("not_applicable"));
r.push(CheckResult::fail("oops", Severity::Error));
let sarif = to_sarif(&r);
let v: Value = serde_json::from_str(&sarif).unwrap();
let results = v["runs"][0]["results"].as_array().unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0]["ruleId"], "oops");
}
#[test]
fn severity_maps_to_sarif_level() {
let mut r = Report::new("c", "0.1.0").with_producer("p");
r.push(CheckResult::fail("a", Severity::Critical));
r.push(CheckResult::fail("b", Severity::Error));
r.push(CheckResult::warn("c", Severity::Warning));
r.push(CheckResult::warn("d", Severity::Info));
let sarif = to_sarif(&r);
let v: Value = serde_json::from_str(&sarif).unwrap();
let results = v["runs"][0]["results"].as_array().unwrap();
assert_eq!(results[0]["level"], "error");
assert_eq!(results[1]["level"], "error");
assert_eq!(results[2]["level"], "warning");
assert_eq!(results[3]["level"], "note");
}
#[test]
fn file_ref_evidence_becomes_location() {
let mut r = Report::new("c", "0.1.0").with_producer("p");
r.push(
CheckResult::fail("oops", Severity::Error)
.with_evidence(Evidence::file_ref_lines("site", "src/lib.rs", 10, 20))
.with_evidence(Evidence::numeric("ignored", 1.0)),
);
let sarif = to_sarif(&r);
let v: Value = serde_json::from_str(&sarif).unwrap();
let locs = v["runs"][0]["results"][0]["locations"].as_array().unwrap();
assert_eq!(locs.len(), 1);
let phys = &locs[0]["physicalLocation"];
assert_eq!(phys["artifactLocation"]["uri"], "src/lib.rs");
assert_eq!(phys["region"]["startLine"], 10);
assert_eq!(phys["region"]["endLine"], 20);
}
#[test]
fn file_ref_without_line_range_omits_region() {
let mut r = Report::new("c", "0.1.0").with_producer("p");
r.push(
CheckResult::fail("oops", Severity::Error).with_evidence(Evidence {
label: "src".into(),
data: EvidenceData::FileRef(FileRef::new("src/lib.rs")),
}),
);
let sarif = to_sarif(&r);
let v: Value = serde_json::from_str(&sarif).unwrap();
let phys = &v["runs"][0]["results"][0]["locations"][0]["physicalLocation"];
assert_eq!(phys["artifactLocation"]["uri"], "src/lib.rs");
assert!(phys.get("region").is_none());
}
fn uri_of(path: &str) -> String {
let mut r = Report::new("c", "0.1.0").with_producer("p");
r.push(
CheckResult::fail("x", Severity::Error).with_evidence(Evidence::file_ref("f", path)),
);
let v: Value = serde_json::from_str(&to_sarif(&r)).unwrap();
v["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
.as_str()
.unwrap()
.to_string()
}
#[test]
fn relative_paths_stay_relative() {
assert_eq!(uri_of("src/lib.rs"), "src/lib.rs");
assert_eq!(uri_of("./src/lib.rs"), "./src/lib.rs");
assert_eq!(uri_of(r"src\parse\mod.rs"), "src/parse/mod.rs");
}
#[test]
fn absolute_paths_become_file_uris() {
assert_eq!(uri_of("/home/me/x.rs"), "file:///home/me/x.rs");
assert_eq!(
uri_of(r"C:\Dev\crate\src\lib.rs"),
"file:///C:/Dev/crate/src/lib.rs"
);
assert_eq!(uri_of("d:/a/b.rs"), "file:///d:/a/b.rs");
assert_eq!(uri_of(r"\\server\share\x.rs"), "file://server/share/x.rs");
}
#[test]
fn uri_unsafe_characters_are_percent_encoded() {
assert_eq!(uri_of("my dir/a%b.rs"), "my%20dir/a%25b.rs");
assert_eq!(uri_of("src/caf\u{e9}.rs"), "src/caf%C3%A9.rs");
assert_eq!(
uri_of(r"C:\Program Files\x.rs"),
"file:///C:/Program%20Files/x.rs"
);
assert_eq!(uri_of("a:b/c.rs"), "a%3Ab/c.rs");
assert_eq!(uri_of(r"C:\a:b.rs"), "file:///C:/a%3Ab.rs");
}
#[test]
fn invalid_line_numbers_are_dropped_from_region() {
let mut r = Report::new("c", "0.1.0").with_producer("p");
r.push(
CheckResult::fail("a", Severity::Error)
.with_evidence(Evidence::file_ref_lines("f", "x.rs", 0, 0)),
);
r.push(
CheckResult::fail("b", Severity::Error)
.with_evidence(Evidence::file_ref_lines("f", "x.rs", 9, 3)),
);
let v: Value = serde_json::from_str(&to_sarif(&r)).unwrap();
let results = v["runs"][0]["results"].as_array().unwrap();
assert!(results[0]["locations"][0]["physicalLocation"]
.get("region")
.is_none());
let region = &results[1]["locations"][0]["physicalLocation"]["region"];
assert_eq!(region["startLine"], 9);
assert!(region.get("endLine").is_none());
}
#[test]
fn multi_emits_one_run_per_constituent_report() {
let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
bench.push(CheckResult::fail("a", Severity::Error));
let mut chaos = Report::new("c", "0.1.0").with_producer("dev-chaos");
chaos.push(CheckResult::warn("b", Severity::Warning));
let mut multi = MultiReport::new("c", "0.1.0");
multi.push(bench);
multi.push(chaos);
let sarif = multi_to_sarif(&multi);
let v: Value = serde_json::from_str(&sarif).unwrap();
let runs = v["runs"].as_array().unwrap();
assert_eq!(runs.len(), 2);
assert_eq!(runs[0]["tool"]["driver"]["name"], "dev-bench");
assert_eq!(runs[1]["tool"]["driver"]["name"], "dev-chaos");
}
#[test]
fn output_is_deterministic() {
let mut r = Report::new("c", "0.1.0").with_producer("p");
r.push(CheckResult::fail("a", Severity::Error).with_detail("bad"));
r.push(CheckResult::warn("b", Severity::Warning));
let s1 = to_sarif(&r);
let s2 = to_sarif(&r);
assert_eq!(s1, s2);
}
#[test]
fn empty_report_emits_empty_results() {
let r = Report::new("c", "0.1.0").with_producer("p");
let sarif = to_sarif(&r);
let v: Value = serde_json::from_str(&sarif).unwrap();
assert_eq!(v["runs"][0]["results"].as_array().unwrap().len(), 0);
}
#[test]
fn detail_becomes_message_text() {
let mut r = Report::new("c", "0.1.0").with_producer("p");
r.push(CheckResult::fail("a", Severity::Error).with_detail("the exact reason"));
let sarif = to_sarif(&r);
let v: Value = serde_json::from_str(&sarif).unwrap();
assert_eq!(
v["runs"][0]["results"][0]["message"]["text"],
"the exact reason"
);
}
#[test]
fn missing_detail_falls_back_to_name() {
let mut r = Report::new("c", "0.1.0").with_producer("p");
r.push(CheckResult::fail("the_check", Severity::Error));
let sarif = to_sarif(&r);
let v: Value = serde_json::from_str(&sarif).unwrap();
assert_eq!(v["runs"][0]["results"][0]["message"]["text"], "the_check");
}
}