use crate::model::{TestResultRow, TestStatus};
use std::path::Path;
pub const DEFAULT_SARIF_MAX_RESULTS: usize = 25_000;
#[derive(Debug, Clone, Default)]
pub struct SarifWriteOutcome {
pub omitted_count: u64,
}
pub const SARIF_SCHEMA: &str =
"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json";
const SYNTHETIC_LOCATION_URI: &str = ".assay/eval.yaml";
#[inline]
pub fn is_sarif_eligible(status: TestStatus) -> bool {
!matches!(
status,
TestStatus::Pass | TestStatus::Skipped | TestStatus::AllowedOnError
)
}
#[inline]
pub fn blocking_rank(status: TestStatus) -> u8 {
if status.is_blocking() {
0
} else {
1
}
}
#[inline]
pub fn severity_rank(status: TestStatus) -> u8 {
match status {
TestStatus::Fail | TestStatus::Error => 0,
TestStatus::Warn | TestStatus::Flaky | TestStatus::Unstable => 1,
_ => 2,
}
}
fn sarif_sort_key(r: &TestResultRow) -> (u8, u8, &str) {
(
blocking_rank(r.status),
severity_rank(r.status),
r.test_id.as_str(),
)
}
pub fn write_sarif_with_limit(
tool_name: &str,
results: &[TestResultRow],
out: &Path,
max_results: usize,
) -> anyhow::Result<SarifWriteOutcome> {
let eligible: Vec<&TestResultRow> = results
.iter()
.filter(|r| is_sarif_eligible(r.status))
.collect();
let eligible_total = eligible.len();
let mut sorted: Vec<&TestResultRow> = eligible;
sorted.sort_by_cached_key(|r| sarif_sort_key(r));
let kept: Vec<&TestResultRow> = sorted.into_iter().take(max_results).collect();
let kept_count = kept.len();
let omitted_count = eligible_total.saturating_sub(kept_count) as u64;
let sarif_results: Vec<serde_json::Value> = kept
.iter()
.map(|r| {
let level = match r.status {
TestStatus::Warn | TestStatus::Flaky | TestStatus::Unstable => "warning",
TestStatus::Fail | TestStatus::Error => "error",
_ => "note",
};
serde_json::json!({
"ruleId": "assay",
"level": level,
"message": { "text": format!("{}: {}", r.test_id, r.message) },
"locations": [{
"physicalLocation": {
"artifactLocation": { "uri": SYNTHETIC_LOCATION_URI },
"region": { "startLine": 1, "startColumn": 1 }
}
}]
})
})
.collect();
let run_obj: serde_json::Value = if omitted_count > 0 {
serde_json::json!({
"tool": { "driver": { "name": tool_name } },
"results": sarif_results,
"properties": {
"assay": {
"truncated": true,
"omitted_count": omitted_count
}
}
})
} else {
serde_json::json!({
"tool": { "driver": { "name": tool_name } },
"results": sarif_results
})
};
let doc = serde_json::json!({
"version": "2.1.0",
"$schema": SARIF_SCHEMA,
"runs": [run_obj]
});
std::fs::write(out, serde_json::to_string_pretty(&doc)?)?;
Ok(SarifWriteOutcome { omitted_count })
}
pub fn write_sarif(
tool_name: &str,
results: &[TestResultRow],
out: &Path,
) -> anyhow::Result<SarifWriteOutcome> {
write_sarif_with_limit(tool_name, results, out, DEFAULT_SARIF_MAX_RESULTS)
}
pub fn build_sarif_diagnostics(
tool_name: &str,
diagnostics: &[crate::errors::diagnostic::Diagnostic],
exit_code: i32,
) -> serde_json::Value {
fn normalize_severity(s: &str) -> &str {
match s {
"error" | "ERROR" => "error",
"warn" | "warning" | "WARN" | "WARNING" => "warning",
_ => "note",
}
}
let sarif_results: Vec<serde_json::Value> = diagnostics
.iter()
.map(|d| {
let level = normalize_severity(d.severity.as_str());
let rule_id = &d.code;
let file_uri = d
.context
.get("file")
.and_then(|v| v.as_str())
.unwrap_or(SYNTHETIC_LOCATION_URI);
let line = d.context.get("line").and_then(|v| v.as_u64()).unwrap_or(1);
let locations = vec![serde_json::json!({
"physicalLocation": {
"artifactLocation": { "uri": file_uri },
"region": { "startLine": line, "startColumn": 1 }
}
})];
serde_json::json!({
"ruleId": rule_id,
"level": level,
"message": { "text": d.message },
"locations": locations
})
})
.collect();
let execution_successful = !diagnostics.iter().any(|d| {
let s = normalize_severity(d.severity.as_str());
s == "error"
});
serde_json::json!({
"version": "2.1.0",
"$schema": SARIF_SCHEMA,
"runs": [{
"tool": {
"driver": {
"name": tool_name,
"version": env!("CARGO_PKG_VERSION")
}
},
"results": sarif_results,
"invocations": [{
"executionSuccessful": execution_successful,
"exitCode": exit_code
}]
}]
})
}
pub fn write_sarif_diagnostics(
tool_name: &str,
diagnostics: &[crate::errors::diagnostic::Diagnostic],
out: &std::path::Path,
) -> anyhow::Result<()> {
let doc = build_sarif_diagnostics(tool_name, diagnostics, 0);
std::fs::write(out, serde_json::to_string_pretty(&doc)?)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::diagnostic::Diagnostic;
use serde_json::json;
#[test]
fn test_sarif_generation() {
let diag = Diagnostic::new("TEST001", "Test error".to_string())
.with_severity("error")
.with_context(json!({"file": "test.rs"}));
let sarif = build_sarif_diagnostics("assay-test", &[diag], 1);
let runs = sarif["runs"].as_array().unwrap();
assert_eq!(runs.len(), 1);
let results = runs[0]["results"].as_array().unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0]["level"], "error");
assert_eq!(results[0]["ruleId"], "TEST001");
let invocations = runs[0]["invocations"].as_array().unwrap();
assert!(!invocations[0]["executionSuccessful"].as_bool().unwrap());
assert_eq!(invocations[0]["exitCode"], 1);
}
#[test]
fn test_sarif_location_invariant_with_context() {
let diag = Diagnostic::new("TEST002", "Error with file context".to_string())
.with_severity("error")
.with_context(json!({"file": "src/main.rs", "line": 42}));
let sarif = build_sarif_diagnostics("assay", &[diag], 1);
let results = sarif["runs"][0]["results"].as_array().unwrap();
let locations = results[0]["locations"].as_array().unwrap();
assert!(
!locations.is_empty(),
"SARIF result must have at least one location"
);
let uri = &locations[0]["physicalLocation"]["artifactLocation"]["uri"];
assert_eq!(uri, "src/main.rs");
let line = &locations[0]["physicalLocation"]["region"]["startLine"];
assert_eq!(line, 42);
}
#[test]
fn test_sarif_location_invariant_synthetic_fallback() {
let diag = Diagnostic::new("TEST003", "Error without file context".to_string())
.with_severity("error");
let sarif = build_sarif_diagnostics("assay", &[diag], 1);
let results = sarif["runs"][0]["results"].as_array().unwrap();
let locations = results[0]["locations"].as_array().unwrap();
assert!(
!locations.is_empty(),
"SARIF result must have synthetic location fallback"
);
let uri = &locations[0]["physicalLocation"]["artifactLocation"]["uri"];
assert_eq!(uri, SYNTHETIC_LOCATION_URI);
}
}