1use serde_json::{json, Value};
42
43use crate::{CheckResult, EvidenceData, MultiReport, Report, Severity, Verdict};
44
45const SARIF_VERSION: &str = "2.1.0";
46const SARIF_SCHEMA_URI: &str =
47 "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json";
48const TOOL_INFO_URI: &str = "https://github.com/jamesgober/dev-report";
49
50pub fn to_sarif(report: &Report) -> String {
65 let log = json!({
66 "version": SARIF_VERSION,
67 "$schema": SARIF_SCHEMA_URI,
68 "runs": [run_for(report)]
69 });
70 serde_json::to_string_pretty(&log).expect("SARIF JSON is always serializable")
71}
72
73pub fn multi_to_sarif(multi: &MultiReport) -> String {
94 let runs: Vec<Value> = multi.reports.iter().map(run_for).collect();
95 let log = json!({
96 "version": SARIF_VERSION,
97 "$schema": SARIF_SCHEMA_URI,
98 "runs": runs
99 });
100 serde_json::to_string_pretty(&log).expect("SARIF JSON is always serializable")
101}
102
103fn run_for(report: &Report) -> Value {
104 let driver_name = report.producer.as_deref().unwrap_or("dev-report");
105 let results: Vec<Value> = report
106 .checks
107 .iter()
108 .filter(|c| matches!(c.verdict, Verdict::Fail | Verdict::Warn))
109 .map(result_for)
110 .collect();
111 json!({
112 "tool": {
113 "driver": {
114 "name": driver_name,
115 "informationUri": TOOL_INFO_URI
116 }
117 },
118 "results": results
119 })
120}
121
122fn result_for(check: &CheckResult) -> Value {
123 let level = level_for(check.severity);
124 let message_text = check.detail.clone().unwrap_or_else(|| check.name.clone());
125 let mut result = json!({
126 "ruleId": check.name,
127 "level": level,
128 "message": { "text": message_text }
129 });
130 let locations: Vec<Value> = check
131 .evidence
132 .iter()
133 .filter_map(|e| match &e.data {
134 EvidenceData::FileRef(f) => Some(location_for(f)),
135 _ => None,
136 })
137 .collect();
138 if !locations.is_empty() {
139 result["locations"] = Value::Array(locations);
140 }
141 result
142}
143
144fn level_for(severity: Option<Severity>) -> &'static str {
145 match severity {
146 Some(Severity::Critical) | Some(Severity::Error) => "error",
147 Some(Severity::Warning) => "warning",
148 Some(Severity::Info) => "note",
149 None => "none",
150 }
151}
152
153fn location_for(file_ref: &crate::FileRef) -> Value {
154 let mut physical = serde_json::Map::new();
155 physical.insert(
156 "artifactLocation".into(),
157 json!({ "uri": artifact_uri(&file_ref.path) }),
158 );
159 if let Some(s) = file_ref.line_start.filter(|&n| n >= 1) {
162 let mut region = serde_json::Map::new();
163 region.insert("startLine".into(), Value::Number(s.into()));
164 if let Some(e) = file_ref.line_end.filter(|&e| e >= s) {
165 region.insert("endLine".into(), Value::Number(e.into()));
166 }
167 physical.insert("region".into(), Value::Object(region));
168 }
169 json!({ "physicalLocation": Value::Object(physical) })
170}
171
172fn artifact_uri(path: &str) -> String {
175 let normalized = path.replace('\\', "/");
176 let bytes = normalized.as_bytes();
177 let is_drive = bytes.len() >= 2
178 && bytes[0].is_ascii_alphabetic()
179 && bytes[1] == b':'
180 && (bytes.len() == 2 || bytes[2] == b'/');
181
182 let prefix = if normalized.starts_with("//") {
183 "file:"
185 } else if is_drive {
186 "file:///"
187 } else if normalized.starts_with('/') {
188 "file://"
189 } else {
190 ""
191 };
192
193 let mut out = String::with_capacity(prefix.len() + normalized.len());
194 out.push_str(prefix);
195 for (i, b) in normalized.bytes().enumerate() {
196 let drive_colon = is_drive && i == 1;
200 let keep = drive_colon
201 || b.is_ascii_alphanumeric()
202 || matches!(
203 b,
204 b'-' | b'.'
205 | b'_'
206 | b'~'
207 | b'/'
208 | b'!'
209 | b'$'
210 | b'&'
211 | b'\''
212 | b'('
213 | b')'
214 | b'*'
215 | b'+'
216 | b','
217 | b';'
218 | b'='
219 | b'@'
220 );
221 if keep {
222 out.push(char::from(b));
223 } else {
224 const HEX: &[u8; 16] = b"0123456789ABCDEF";
225 out.push('%');
226 out.push(char::from(HEX[usize::from(b >> 4)]));
227 out.push(char::from(HEX[usize::from(b & 0x0f)]));
228 }
229 }
230 out
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use crate::{Evidence, FileRef};
237
238 #[test]
239 fn skips_pass_and_skip_checks() {
240 let mut r = Report::new("c", "0.1.0").with_producer("p");
241 r.push(CheckResult::pass("ok"));
242 r.push(CheckResult::skip("not_applicable"));
243 r.push(CheckResult::fail("oops", Severity::Error));
244 let sarif = to_sarif(&r);
245 let v: Value = serde_json::from_str(&sarif).unwrap();
246 let results = v["runs"][0]["results"].as_array().unwrap();
247 assert_eq!(results.len(), 1);
248 assert_eq!(results[0]["ruleId"], "oops");
249 }
250
251 #[test]
252 fn severity_maps_to_sarif_level() {
253 let mut r = Report::new("c", "0.1.0").with_producer("p");
254 r.push(CheckResult::fail("a", Severity::Critical));
255 r.push(CheckResult::fail("b", Severity::Error));
256 r.push(CheckResult::warn("c", Severity::Warning));
257 r.push(CheckResult::warn("d", Severity::Info));
258 let sarif = to_sarif(&r);
259 let v: Value = serde_json::from_str(&sarif).unwrap();
260 let results = v["runs"][0]["results"].as_array().unwrap();
261 assert_eq!(results[0]["level"], "error");
262 assert_eq!(results[1]["level"], "error");
263 assert_eq!(results[2]["level"], "warning");
264 assert_eq!(results[3]["level"], "note");
265 }
266
267 #[test]
268 fn file_ref_evidence_becomes_location() {
269 let mut r = Report::new("c", "0.1.0").with_producer("p");
270 r.push(
271 CheckResult::fail("oops", Severity::Error)
272 .with_evidence(Evidence::file_ref_lines("site", "src/lib.rs", 10, 20))
273 .with_evidence(Evidence::numeric("ignored", 1.0)),
274 );
275 let sarif = to_sarif(&r);
276 let v: Value = serde_json::from_str(&sarif).unwrap();
277 let locs = v["runs"][0]["results"][0]["locations"].as_array().unwrap();
278 assert_eq!(locs.len(), 1);
279 let phys = &locs[0]["physicalLocation"];
280 assert_eq!(phys["artifactLocation"]["uri"], "src/lib.rs");
281 assert_eq!(phys["region"]["startLine"], 10);
282 assert_eq!(phys["region"]["endLine"], 20);
283 }
284
285 #[test]
286 fn file_ref_without_line_range_omits_region() {
287 let mut r = Report::new("c", "0.1.0").with_producer("p");
288 r.push(
289 CheckResult::fail("oops", Severity::Error).with_evidence(Evidence {
290 label: "src".into(),
291 data: EvidenceData::FileRef(FileRef::new("src/lib.rs")),
292 }),
293 );
294 let sarif = to_sarif(&r);
295 let v: Value = serde_json::from_str(&sarif).unwrap();
296 let phys = &v["runs"][0]["results"][0]["locations"][0]["physicalLocation"];
297 assert_eq!(phys["artifactLocation"]["uri"], "src/lib.rs");
298 assert!(phys.get("region").is_none());
299 }
300
301 fn uri_of(path: &str) -> String {
302 let mut r = Report::new("c", "0.1.0").with_producer("p");
303 r.push(
304 CheckResult::fail("x", Severity::Error).with_evidence(Evidence::file_ref("f", path)),
305 );
306 let v: Value = serde_json::from_str(&to_sarif(&r)).unwrap();
307 v["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
308 .as_str()
309 .unwrap()
310 .to_string()
311 }
312
313 #[test]
314 fn relative_paths_stay_relative() {
315 assert_eq!(uri_of("src/lib.rs"), "src/lib.rs");
316 assert_eq!(uri_of("./src/lib.rs"), "./src/lib.rs");
317 assert_eq!(uri_of(r"src\parse\mod.rs"), "src/parse/mod.rs");
318 }
319
320 #[test]
321 fn absolute_paths_become_file_uris() {
322 assert_eq!(uri_of("/home/me/x.rs"), "file:///home/me/x.rs");
323 assert_eq!(
324 uri_of(r"C:\Dev\crate\src\lib.rs"),
325 "file:///C:/Dev/crate/src/lib.rs"
326 );
327 assert_eq!(uri_of("d:/a/b.rs"), "file:///d:/a/b.rs");
328 assert_eq!(uri_of(r"\\server\share\x.rs"), "file://server/share/x.rs");
329 }
330
331 #[test]
332 fn uri_unsafe_characters_are_percent_encoded() {
333 assert_eq!(uri_of("my dir/a%b.rs"), "my%20dir/a%25b.rs");
334 assert_eq!(uri_of("src/caf\u{e9}.rs"), "src/caf%C3%A9.rs");
335 assert_eq!(
336 uri_of(r"C:\Program Files\x.rs"),
337 "file:///C:/Program%20Files/x.rs"
338 );
339 assert_eq!(uri_of("a:b/c.rs"), "a%3Ab/c.rs");
341 assert_eq!(uri_of(r"C:\a:b.rs"), "file:///C:/a%3Ab.rs");
342 }
343
344 #[test]
345 fn invalid_line_numbers_are_dropped_from_region() {
346 let mut r = Report::new("c", "0.1.0").with_producer("p");
347 r.push(
348 CheckResult::fail("a", Severity::Error)
349 .with_evidence(Evidence::file_ref_lines("f", "x.rs", 0, 0)),
350 );
351 r.push(
352 CheckResult::fail("b", Severity::Error)
353 .with_evidence(Evidence::file_ref_lines("f", "x.rs", 9, 3)),
354 );
355 let v: Value = serde_json::from_str(&to_sarif(&r)).unwrap();
356 let results = v["runs"][0]["results"].as_array().unwrap();
357 assert!(results[0]["locations"][0]["physicalLocation"]
358 .get("region")
359 .is_none());
360 let region = &results[1]["locations"][0]["physicalLocation"]["region"];
361 assert_eq!(region["startLine"], 9);
362 assert!(region.get("endLine").is_none());
363 }
364
365 #[test]
366 fn multi_emits_one_run_per_constituent_report() {
367 let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
368 bench.push(CheckResult::fail("a", Severity::Error));
369 let mut chaos = Report::new("c", "0.1.0").with_producer("dev-chaos");
370 chaos.push(CheckResult::warn("b", Severity::Warning));
371 let mut multi = MultiReport::new("c", "0.1.0");
372 multi.push(bench);
373 multi.push(chaos);
374 let sarif = multi_to_sarif(&multi);
375 let v: Value = serde_json::from_str(&sarif).unwrap();
376 let runs = v["runs"].as_array().unwrap();
377 assert_eq!(runs.len(), 2);
378 assert_eq!(runs[0]["tool"]["driver"]["name"], "dev-bench");
379 assert_eq!(runs[1]["tool"]["driver"]["name"], "dev-chaos");
380 }
381
382 #[test]
383 fn output_is_deterministic() {
384 let mut r = Report::new("c", "0.1.0").with_producer("p");
385 r.push(CheckResult::fail("a", Severity::Error).with_detail("bad"));
386 r.push(CheckResult::warn("b", Severity::Warning));
387 let s1 = to_sarif(&r);
388 let s2 = to_sarif(&r);
389 assert_eq!(s1, s2);
390 }
391
392 #[test]
393 fn empty_report_emits_empty_results() {
394 let r = Report::new("c", "0.1.0").with_producer("p");
395 let sarif = to_sarif(&r);
396 let v: Value = serde_json::from_str(&sarif).unwrap();
397 assert_eq!(v["runs"][0]["results"].as_array().unwrap().len(), 0);
398 }
399
400 #[test]
401 fn detail_becomes_message_text() {
402 let mut r = Report::new("c", "0.1.0").with_producer("p");
403 r.push(CheckResult::fail("a", Severity::Error).with_detail("the exact reason"));
404 let sarif = to_sarif(&r);
405 let v: Value = serde_json::from_str(&sarif).unwrap();
406 assert_eq!(
407 v["runs"][0]["results"][0]["message"]["text"],
408 "the exact reason"
409 );
410 }
411
412 #[test]
413 fn missing_detail_falls_back_to_name() {
414 let mut r = Report::new("c", "0.1.0").with_producer("p");
415 r.push(CheckResult::fail("the_check", Severity::Error));
416 let sarif = to_sarif(&r);
417 let v: Value = serde_json::from_str(&sarif).unwrap();
418 assert_eq!(v["runs"][0]["results"][0]["message"]["text"], "the_check");
419 }
420}