1use 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
41pub 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
64pub 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}