Skip to main content

dev_report/
sarif.rs

1//! SARIF 2.1.0 export for [`Report`] and [`MultiReport`].
2//!
3//! Maps fail-verdict and warn-verdict [`CheckResult`]s to SARIF results.
4//! Pass and skip checks are intentionally NOT emitted — SARIF is a defect
5//! report format, not a test-result format. Use [`crate::junit`] (or the
6//! native JSON schema) when you need every check.
7//!
8//! Severity → SARIF level:
9//!
10//! | [`Severity`]         | SARIF `level` |
11//! |----------------------|---------------|
12//! | `Critical`, `Error`  | `error`       |
13//! | `Warning`            | `warning`     |
14//! | `Info`               | `note`        |
15//! | `None` (unreachable for fail/warn) | `none` |
16//!
17//! [`Evidence`] payloads of kind [`EvidenceData::FileRef`] become SARIF
18//! `physicalLocation` entries with `region.startLine` / `region.endLine`
19//! when the [`FileRef`] carries a line range.
20//!
21//! For a [`MultiReport`], each constituent [`Report`] becomes a separate
22//! SARIF `run`, so consumers can tell which producer emitted which finding.
23//!
24//! Available with the `sarif` feature.
25//!
26//! [`Evidence`]: crate::Evidence
27//! [`EvidenceData::FileRef`]: crate::EvidenceData::FileRef
28//! [`FileRef`]: crate::FileRef
29//! [`Severity`]: crate::Severity
30//! [`CheckResult`]: crate::CheckResult
31
32use serde_json::{json, Value};
33
34use crate::{CheckResult, EvidenceData, MultiReport, Report, Severity, Verdict};
35
36const SARIF_VERSION: &str = "2.1.0";
37const SARIF_SCHEMA_URI: &str =
38    "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json";
39const TOOL_INFO_URI: &str = "https://github.com/jamesgober/dev-report";
40
41/// Render `report` as a SARIF 2.1.0 document.
42///
43/// # Example
44///
45/// ```
46/// use dev_report::{CheckResult, Report, Severity};
47///
48/// let mut r = Report::new("crate", "0.1.0").with_producer("dev-bench");
49/// r.push(CheckResult::fail("oops", Severity::Error).with_detail("explodes"));
50///
51/// let sarif = dev_report::sarif::to_sarif(&r);
52/// assert!(sarif.contains("\"version\": \"2.1.0\""));
53/// assert!(sarif.contains("\"ruleId\": \"oops\""));
54/// ```
55pub fn to_sarif(report: &Report) -> String {
56    let log = json!({
57        "version": SARIF_VERSION,
58        "$schema": SARIF_SCHEMA_URI,
59        "runs": [run_for(report)]
60    });
61    serde_json::to_string_pretty(&log).expect("SARIF JSON is always serializable")
62}
63
64/// Render `multi` as a SARIF 2.1.0 document with one `run` per constituent
65/// [`Report`].
66///
67/// # Example
68///
69/// ```
70/// use dev_report::{CheckResult, MultiReport, Report, Severity};
71///
72/// let mut bench = Report::new("crate", "0.1.0").with_producer("dev-bench");
73/// bench.push(CheckResult::fail("a", Severity::Error));
74/// let mut chaos = Report::new("crate", "0.1.0").with_producer("dev-chaos");
75/// chaos.push(CheckResult::warn("b", Severity::Warning));
76///
77/// let mut multi = MultiReport::new("crate", "0.1.0");
78/// multi.push(bench);
79/// multi.push(chaos);
80///
81/// let sarif = dev_report::sarif::multi_to_sarif(&multi);
82/// assert!(sarif.contains("\"version\": \"2.1.0\""));
83/// ```
84pub fn multi_to_sarif(multi: &MultiReport) -> String {
85    let runs: Vec<Value> = multi.reports.iter().map(run_for).collect();
86    let log = json!({
87        "version": SARIF_VERSION,
88        "$schema": SARIF_SCHEMA_URI,
89        "runs": runs
90    });
91    serde_json::to_string_pretty(&log).expect("SARIF JSON is always serializable")
92}
93
94fn run_for(report: &Report) -> Value {
95    let driver_name = report.producer.as_deref().unwrap_or("dev-report");
96    let results: Vec<Value> = report
97        .checks
98        .iter()
99        .filter(|c| matches!(c.verdict, Verdict::Fail | Verdict::Warn))
100        .map(result_for)
101        .collect();
102    json!({
103        "tool": {
104            "driver": {
105                "name": driver_name,
106                "informationUri": TOOL_INFO_URI
107            }
108        },
109        "results": results
110    })
111}
112
113fn result_for(check: &CheckResult) -> Value {
114    let level = level_for(check.severity);
115    let message_text = check.detail.clone().unwrap_or_else(|| check.name.clone());
116    let mut result = json!({
117        "ruleId": check.name,
118        "level": level,
119        "message": { "text": message_text }
120    });
121    let locations: Vec<Value> = check
122        .evidence
123        .iter()
124        .filter_map(|e| match &e.data {
125            EvidenceData::FileRef(f) => Some(location_for(f)),
126            _ => None,
127        })
128        .collect();
129    if !locations.is_empty() {
130        result["locations"] = Value::Array(locations);
131    }
132    result
133}
134
135fn level_for(severity: Option<Severity>) -> &'static str {
136    match severity {
137        Some(Severity::Critical) | Some(Severity::Error) => "error",
138        Some(Severity::Warning) => "warning",
139        Some(Severity::Info) => "note",
140        None => "none",
141    }
142}
143
144fn location_for(file_ref: &crate::FileRef) -> Value {
145    let mut physical = serde_json::Map::new();
146    physical.insert("artifactLocation".into(), json!({ "uri": file_ref.path }));
147    if file_ref.line_start.is_some() || file_ref.line_end.is_some() {
148        let mut region = serde_json::Map::new();
149        if let Some(s) = file_ref.line_start {
150            region.insert("startLine".into(), Value::Number(s.into()));
151        }
152        if let Some(e) = file_ref.line_end {
153            region.insert("endLine".into(), Value::Number(e.into()));
154        }
155        physical.insert("region".into(), Value::Object(region));
156    }
157    json!({ "physicalLocation": Value::Object(physical) })
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::{Evidence, FileRef};
164
165    #[test]
166    fn skips_pass_and_skip_checks() {
167        let mut r = Report::new("c", "0.1.0").with_producer("p");
168        r.push(CheckResult::pass("ok"));
169        r.push(CheckResult::skip("not_applicable"));
170        r.push(CheckResult::fail("oops", Severity::Error));
171        let sarif = to_sarif(&r);
172        let v: Value = serde_json::from_str(&sarif).unwrap();
173        let results = v["runs"][0]["results"].as_array().unwrap();
174        assert_eq!(results.len(), 1);
175        assert_eq!(results[0]["ruleId"], "oops");
176    }
177
178    #[test]
179    fn severity_maps_to_sarif_level() {
180        let mut r = Report::new("c", "0.1.0").with_producer("p");
181        r.push(CheckResult::fail("a", Severity::Critical));
182        r.push(CheckResult::fail("b", Severity::Error));
183        r.push(CheckResult::warn("c", Severity::Warning));
184        r.push(CheckResult::warn("d", Severity::Info));
185        let sarif = to_sarif(&r);
186        let v: Value = serde_json::from_str(&sarif).unwrap();
187        let results = v["runs"][0]["results"].as_array().unwrap();
188        assert_eq!(results[0]["level"], "error");
189        assert_eq!(results[1]["level"], "error");
190        assert_eq!(results[2]["level"], "warning");
191        assert_eq!(results[3]["level"], "note");
192    }
193
194    #[test]
195    fn file_ref_evidence_becomes_location() {
196        let mut r = Report::new("c", "0.1.0").with_producer("p");
197        r.push(
198            CheckResult::fail("oops", Severity::Error)
199                .with_evidence(Evidence::file_ref_lines("site", "src/lib.rs", 10, 20))
200                .with_evidence(Evidence::numeric("ignored", 1.0)),
201        );
202        let sarif = to_sarif(&r);
203        let v: Value = serde_json::from_str(&sarif).unwrap();
204        let locs = v["runs"][0]["results"][0]["locations"].as_array().unwrap();
205        assert_eq!(locs.len(), 1);
206        let phys = &locs[0]["physicalLocation"];
207        assert_eq!(phys["artifactLocation"]["uri"], "src/lib.rs");
208        assert_eq!(phys["region"]["startLine"], 10);
209        assert_eq!(phys["region"]["endLine"], 20);
210    }
211
212    #[test]
213    fn file_ref_without_line_range_omits_region() {
214        let mut r = Report::new("c", "0.1.0").with_producer("p");
215        r.push(
216            CheckResult::fail("oops", Severity::Error).with_evidence(Evidence {
217                label: "src".into(),
218                data: EvidenceData::FileRef(FileRef::new("src/lib.rs")),
219            }),
220        );
221        let sarif = to_sarif(&r);
222        let v: Value = serde_json::from_str(&sarif).unwrap();
223        let phys = &v["runs"][0]["results"][0]["locations"][0]["physicalLocation"];
224        assert_eq!(phys["artifactLocation"]["uri"], "src/lib.rs");
225        assert!(phys.get("region").is_none());
226    }
227
228    #[test]
229    fn multi_emits_one_run_per_constituent_report() {
230        let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
231        bench.push(CheckResult::fail("a", Severity::Error));
232        let mut chaos = Report::new("c", "0.1.0").with_producer("dev-chaos");
233        chaos.push(CheckResult::warn("b", Severity::Warning));
234        let mut multi = MultiReport::new("c", "0.1.0");
235        multi.push(bench);
236        multi.push(chaos);
237        let sarif = multi_to_sarif(&multi);
238        let v: Value = serde_json::from_str(&sarif).unwrap();
239        let runs = v["runs"].as_array().unwrap();
240        assert_eq!(runs.len(), 2);
241        assert_eq!(runs[0]["tool"]["driver"]["name"], "dev-bench");
242        assert_eq!(runs[1]["tool"]["driver"]["name"], "dev-chaos");
243    }
244
245    #[test]
246    fn output_is_deterministic() {
247        let mut r = Report::new("c", "0.1.0").with_producer("p");
248        r.push(CheckResult::fail("a", Severity::Error).with_detail("bad"));
249        r.push(CheckResult::warn("b", Severity::Warning));
250        let s1 = to_sarif(&r);
251        let s2 = to_sarif(&r);
252        assert_eq!(s1, s2);
253    }
254
255    #[test]
256    fn empty_report_emits_empty_results() {
257        let r = Report::new("c", "0.1.0").with_producer("p");
258        let sarif = to_sarif(&r);
259        let v: Value = serde_json::from_str(&sarif).unwrap();
260        assert_eq!(v["runs"][0]["results"].as_array().unwrap().len(), 0);
261    }
262
263    #[test]
264    fn detail_becomes_message_text() {
265        let mut r = Report::new("c", "0.1.0").with_producer("p");
266        r.push(CheckResult::fail("a", Severity::Error).with_detail("the exact reason"));
267        let sarif = to_sarif(&r);
268        let v: Value = serde_json::from_str(&sarif).unwrap();
269        assert_eq!(
270            v["runs"][0]["results"][0]["message"]["text"],
271            "the exact reason"
272        );
273    }
274
275    #[test]
276    fn missing_detail_falls_back_to_name() {
277        let mut r = Report::new("c", "0.1.0").with_producer("p");
278        r.push(CheckResult::fail("the_check", Severity::Error));
279        let sarif = to_sarif(&r);
280        let v: Value = serde_json::from_str(&sarif).unwrap();
281        assert_eq!(v["runs"][0]["results"][0]["message"]["text"], "the_check");
282    }
283}