use std::borrow::Cow;
use std::collections::{BTreeMap, hash_map::DefaultHasher};
use std::hash::{Hash, Hasher};
use serde::Serialize;
use crate::checker::CheckFileResult;
use crate::diagnostic::Severity;
const SARIF_SCHEMA: &str = "https://json.schemastore.org/sarif-2.1.0.json";
const SARIF_VERSION: &str = "2.1.0";
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifLog<'a> {
#[serde(rename = "$schema")]
pub schema: &'static str,
pub version: &'static str,
pub runs: Vec<SarifRun<'a>>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifRun<'a> {
pub tool: SarifTool<'a>,
pub results: Vec<SarifResult<'a>>,
pub column_kind: &'static str,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifTool<'a> {
pub driver: SarifToolComponent<'a>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifToolComponent<'a> {
pub name: &'static str,
pub semantic_version: &'static str,
pub rules: Vec<SarifReportingDescriptor<'a>>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifReportingDescriptor<'a> {
pub id: &'static str,
pub short_description: SarifMessage<'a>,
pub full_description: SarifMessage<'a>,
pub help: SarifHelp,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<SarifRuleProperties>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifHelp {
pub text: &'static str,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifRuleProperties {
pub precision: &'static str,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifMessage<'a> {
pub text: Cow<'a, str>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifResult<'a> {
pub rule_id: &'static str,
pub rule_index: usize,
pub level: &'static str,
pub message: SarifMessage<'a>,
pub locations: Vec<SarifLocation<'a>>,
pub partial_fingerprints: SarifFingerprints,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifFingerprints {
pub primary_location_line_hash: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifLocation<'a> {
pub physical_location: SarifPhysicalLocation<'a>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifPhysicalLocation<'a> {
pub artifact_location: SarifArtifactLocation<'a>,
pub region: SarifRegion,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifArtifactLocation<'a> {
pub uri: Cow<'a, str>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifRegion {
pub start_line: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_column: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_column: Option<usize>,
}
fn sarif_level(severity: Severity) -> &'static str {
match severity {
Severity::Info => "note",
Severity::Warning => "warning",
Severity::Error => "error",
}
}
fn compute_fingerprint(rule: &str, path: &str, line: usize, message: &str) -> String {
let mut hasher = DefaultHasher::new();
rule.hash(&mut hasher);
path.hash(&mut hasher);
line.hash(&mut hasher);
message.hash(&mut hasher);
format!("{:016x}:1", hasher.finish())
}
pub fn build_sarif(result: &[CheckFileResult]) -> SarifLog<'_> {
let mut rules_map: BTreeMap<&str, &str> = BTreeMap::new();
for file in result {
for rule in &file.rules.enabled {
rules_map.entry(rule.name()).or_insert(rule.description());
}
}
let mut rule_index_map: BTreeMap<&str, usize> = BTreeMap::new();
let mut sarif_rules: Vec<SarifReportingDescriptor> = Vec::new();
for (idx, (name, description)) in rules_map.iter().enumerate() {
rule_index_map.insert(name, idx);
sarif_rules.push(SarifReportingDescriptor {
id: name,
short_description: SarifMessage {
text: Cow::Borrowed(description),
},
full_description: SarifMessage {
text: Cow::Borrowed(description),
},
help: SarifHelp { text: description },
properties: Some(SarifRuleProperties {
precision: "very-high",
}),
});
}
let mut sarif_results: Vec<SarifResult> = Vec::new();
for file in result {
for diag in &file.diagnostics {
let path_str = diag.path.to_string_lossy();
let first_line = diag
.lines
.iter()
.find(|l| l.line_number > 0)
.or(diag.lines.first());
let start_line =
first_line.map_or(1, |l| if l.line_number > 0 { l.line_number } else { 1 });
let (start_column, end_column) = first_line
.and_then(|l| {
l.highlights.first().map(|(s, e)| {
let sc = l.message[..*s].chars().count() + 1;
let ec = l.message[..*e].chars().count() + 1;
(Some(sc), Some(ec))
})
})
.unwrap_or((None, None));
let message_text = diag.build_message();
let fingerprint = compute_fingerprint(diag.rule, &path_str, start_line, &message_text);
let rule_index = rule_index_map.get(diag.rule).copied().unwrap_or(0);
sarif_results.push(SarifResult {
rule_id: diag.rule,
rule_index,
level: sarif_level(diag.severity),
message: SarifMessage { text: message_text },
locations: vec![SarifLocation {
physical_location: SarifPhysicalLocation {
artifact_location: SarifArtifactLocation { uri: path_str },
region: SarifRegion {
start_line,
start_column,
end_column,
},
},
}],
partial_fingerprints: SarifFingerprints {
primary_location_line_hash: fingerprint,
},
});
}
}
SarifLog {
schema: SARIF_SCHEMA,
version: SARIF_VERSION,
runs: vec![SarifRun {
tool: SarifTool {
driver: SarifToolComponent {
name: env!("CARGO_PKG_NAME"),
semantic_version: env!("CARGO_PKG_VERSION"),
rules: sarif_rules,
},
},
results: sarif_results,
column_kind: "unicodeCodePoints",
}],
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use std::path::PathBuf;
use super::*;
use crate::checker::CheckFileResult;
use crate::config::Config;
use crate::diagnostic::{Diagnostic, DiagnosticLine, Severity};
use crate::rules::rule::{RuleChecker, Rules};
struct MockRule {
name: &'static str,
description: &'static str,
}
impl RuleChecker for MockRule {
fn name(&self) -> &'static str {
self.name
}
fn description(&self) -> &'static str {
self.description
}
fn is_default(&self) -> bool {
true
}
fn is_check(&self) -> bool {
true
}
}
fn mock_rule(
name: &'static str,
description: &'static str,
) -> Box<dyn RuleChecker + Send + Sync> {
Box::new(MockRule { name, description })
}
fn mock_diagnostic(
path: &str,
rule: &'static str,
severity: Severity,
message: &str,
line_number: usize,
line_message: &str,
highlights: Vec<(usize, usize)>,
) -> Diagnostic {
Diagnostic {
path: PathBuf::from(path),
rule,
severity,
message: message.to_string().into(),
lines: vec![DiagnosticLine {
line_number,
message: line_message.to_string(),
highlights,
}],
misspelled_words: HashSet::new(),
}
}
#[test]
fn test_sarif_level() {
assert_eq!(sarif_level(Severity::Info), "note");
assert_eq!(sarif_level(Severity::Warning), "warning");
assert_eq!(sarif_level(Severity::Error), "error");
}
#[test]
fn test_compute_fingerprint_deterministic() {
let fp1 = compute_fingerprint("blank", "test.po", 10, "blank translation");
let fp2 = compute_fingerprint("blank", "test.po", 10, "blank translation");
assert_eq!(fp1, fp2);
assert!(fp1.ends_with(":1"));
assert_eq!(fp1.len(), 18);
}
#[test]
fn test_compute_fingerprint_differs() {
let fp1 = compute_fingerprint("blank", "test.po", 10, "blank translation");
let fp2 = compute_fingerprint("blank", "test.po", 11, "blank translation");
let fp3 = compute_fingerprint("tabs", "test.po", 10, "blank translation");
let fp4 = compute_fingerprint("blank", "other.po", 10, "blank translation");
assert_ne!(fp1, fp2);
assert_ne!(fp1, fp3);
assert_ne!(fp1, fp4);
}
#[test]
fn test_build_sarif_empty() {
let result: Vec<CheckFileResult> = vec![];
let sarif = build_sarif(&result);
assert_eq!(sarif.schema, SARIF_SCHEMA);
assert_eq!(sarif.version, SARIF_VERSION);
assert_eq!(sarif.runs.len(), 1);
assert_eq!(sarif.runs[0].tool.driver.name, "poexam");
assert_eq!(sarif.runs[0].column_kind, "unicodeCodePoints");
assert!(sarif.runs[0].tool.driver.rules.is_empty());
assert!(sarif.runs[0].results.is_empty());
}
#[test]
fn test_build_sarif_no_diagnostics() {
let result = vec![CheckFileResult {
path: PathBuf::from("test.po"),
config: Config::default(),
rules: Rules::new(vec![mock_rule("blank", "Checks blank translations.")]),
diagnostics: vec![],
}];
let sarif = build_sarif(&result);
assert_eq!(sarif.runs[0].tool.driver.rules.len(), 1);
assert_eq!(sarif.runs[0].tool.driver.rules[0].id, "blank");
assert!(sarif.runs[0].results.is_empty());
}
#[test]
fn test_build_sarif_with_diagnostics() {
let result = vec![CheckFileResult {
path: PathBuf::from("fr.po"),
config: Config::default(),
rules: Rules::new(vec![
mock_rule("blank", "Checks blank translations."),
mock_rule("escapes", "Checks escape characters."),
]),
diagnostics: vec![
mock_diagnostic(
"fr.po",
"blank",
Severity::Warning,
"blank translation",
25,
"msgstr \" \"",
vec![(8, 9)],
),
mock_diagnostic(
"fr.po",
"escapes",
Severity::Error,
"missing escape \\n",
42,
"msgstr \"test\"",
vec![],
),
],
}];
let sarif = build_sarif(&result);
let run = &sarif.runs[0];
assert_eq!(run.tool.driver.rules.len(), 2);
assert_eq!(run.tool.driver.rules[0].id, "blank");
assert_eq!(run.tool.driver.rules[1].id, "escapes");
assert_eq!(run.results.len(), 2);
let r0 = &run.results[0];
assert_eq!(r0.rule_id, "blank");
assert_eq!(r0.rule_index, 0);
assert_eq!(r0.level, "warning");
assert_eq!(r0.message.text, "blank translation");
assert_eq!(r0.locations.len(), 1);
assert_eq!(
r0.locations[0].physical_location.artifact_location.uri,
"fr.po"
);
assert_eq!(r0.locations[0].physical_location.region.start_line, 25);
assert_eq!(
r0.locations[0].physical_location.region.start_column,
Some(9)
);
assert_eq!(
r0.locations[0].physical_location.region.end_column,
Some(10)
);
let r1 = &run.results[1];
assert_eq!(r1.rule_id, "escapes");
assert_eq!(r1.rule_index, 1);
assert_eq!(r1.level, "error");
assert_eq!(r1.locations[0].physical_location.region.start_line, 42);
assert!(
r1.locations[0]
.physical_location
.region
.start_column
.is_none()
);
assert!(
r1.locations[0]
.physical_location
.region
.end_column
.is_none()
);
}
#[test]
fn test_build_sarif_line_fallback() {
let result = vec![CheckFileResult {
path: PathBuf::from("test.po"),
config: Config::default(),
rules: Rules::new(vec![mock_rule("encoding", "Checks encoding.")]),
diagnostics: vec![Diagnostic {
path: PathBuf::from("test.po"),
rule: "encoding",
severity: Severity::Info,
message: Cow::Borrowed("invalid encoding"),
lines: vec![],
misspelled_words: HashSet::new(),
}],
}];
let sarif = build_sarif(&result);
assert_eq!(
sarif.runs[0].results[0].locations[0]
.physical_location
.region
.start_line,
1
);
}
#[test]
fn test_build_sarif_zero_line_fallback() {
let result = vec![CheckFileResult {
path: PathBuf::from("test.po"),
config: Config::default(),
rules: Rules::new(vec![mock_rule("compilation", "Checks compilation.")]),
diagnostics: vec![mock_diagnostic(
"test.po",
"compilation",
Severity::Error,
"msgfmt error",
0,
"some output",
vec![],
)],
}];
let sarif = build_sarif(&result);
assert_eq!(
sarif.runs[0].results[0].locations[0]
.physical_location
.region
.start_line,
1
);
}
#[test]
fn test_build_sarif_rules_deduplicated() {
let result = vec![
CheckFileResult {
path: PathBuf::from("a.po"),
config: Config::default(),
rules: Rules::new(vec![mock_rule("blank", "Checks blank.")]),
diagnostics: vec![],
},
CheckFileResult {
path: PathBuf::from("b.po"),
config: Config::default(),
rules: Rules::new(vec![mock_rule("blank", "Checks blank.")]),
diagnostics: vec![],
},
];
let sarif = build_sarif(&result);
assert_eq!(sarif.runs[0].tool.driver.rules.len(), 1);
}
#[test]
fn test_build_sarif_multiple_files() {
let result = vec![
CheckFileResult {
path: PathBuf::from("fr.po"),
config: Config::default(),
rules: Rules::new(vec![mock_rule("blank", "Checks blank.")]),
diagnostics: vec![mock_diagnostic(
"fr.po",
"blank",
Severity::Warning,
"blank translation",
10,
"msgstr \"\"",
vec![],
)],
},
CheckFileResult {
path: PathBuf::from("de.po"),
config: Config::default(),
rules: Rules::new(vec![mock_rule("blank", "Checks blank.")]),
diagnostics: vec![mock_diagnostic(
"de.po",
"blank",
Severity::Warning,
"blank translation",
20,
"msgstr \"\"",
vec![],
)],
},
];
let sarif = build_sarif(&result);
assert_eq!(sarif.runs[0].results.len(), 2);
assert_eq!(
sarif.runs[0].results[0].locations[0]
.physical_location
.artifact_location
.uri,
"fr.po"
);
assert_eq!(
sarif.runs[0].results[1].locations[0]
.physical_location
.artifact_location
.uri,
"de.po"
);
}
#[test]
fn test_build_sarif_serializes_to_valid_json() {
let result = vec![CheckFileResult {
path: PathBuf::from("test.po"),
config: Config::default(),
rules: Rules::new(vec![mock_rule("blank", "Checks blank.")]),
diagnostics: vec![mock_diagnostic(
"test.po",
"blank",
Severity::Warning,
"blank translation",
5,
"msgstr \" \"",
vec![(8, 9)],
)],
}];
let sarif = build_sarif(&result);
let json_str = serde_json::to_string(&sarif).expect("SARIF should serialize to JSON");
let parsed: serde_json::Value =
serde_json::from_str(&json_str).expect("SARIF JSON should be valid");
assert_eq!(parsed["$schema"], SARIF_SCHEMA);
assert_eq!(parsed["version"], SARIF_VERSION);
assert!(parsed["runs"][0]["results"][0]["ruleId"].is_string());
assert!(parsed["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"]["startLine"].is_number());
}
}