1use serde::Serialize;
7use std::collections::BTreeMap;
8
9use diffguard_types::{CheckReceipt, Finding, Severity};
10
11const SARIF_SCHEMA: &str = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json";
13
14const SARIF_VERSION: &str = "2.1.0";
16
17const DIFFGUARD_INFO_URI: &str = "https://github.com/effortlessmetrics/diffguard";
19
20#[derive(Debug, Clone, Serialize)]
22pub struct SarifReport {
23 #[serde(rename = "$schema")]
24 pub schema: String,
25 pub version: String,
26 pub runs: Vec<SarifRun>,
27}
28
29#[derive(Debug, Clone, Serialize)]
31#[serde(rename_all = "camelCase")]
32pub struct SarifRun {
33 pub tool: SarifTool,
34 pub results: Vec<SarifResult>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub invocations: Option<Vec<SarifInvocation>>,
37}
38
39#[derive(Debug, Clone, Serialize)]
41pub struct SarifTool {
42 pub driver: SarifDriver,
43}
44
45#[derive(Debug, Clone, Serialize)]
47#[serde(rename_all = "camelCase")]
48pub struct SarifDriver {
49 pub name: String,
50 pub version: String,
51 pub information_uri: String,
52 #[serde(skip_serializing_if = "Vec::is_empty")]
53 pub rules: Vec<SarifRule>,
54}
55
56#[derive(Debug, Clone, Serialize)]
58#[serde(rename_all = "camelCase")]
59pub struct SarifRule {
60 pub id: String,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub short_description: Option<SarifMessage>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub default_configuration: Option<SarifRuleConfiguration>,
65}
66
67#[derive(Debug, Clone, Serialize)]
69pub struct SarifRuleConfiguration {
70 pub level: SarifLevel,
71}
72
73#[derive(Debug, Clone, Serialize)]
75#[serde(rename_all = "camelCase")]
76pub struct SarifResult {
77 pub rule_id: String,
78 pub level: SarifLevel,
79 pub message: SarifMessage,
80 pub locations: Vec<SarifLocation>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub partial_fingerprints: Option<BTreeMap<String, String>>,
83}
84
85#[derive(Debug, Clone, Copy, Serialize)]
87#[serde(rename_all = "lowercase")]
88pub enum SarifLevel {
89 Error,
90 Warning,
91 Note,
92 None,
93}
94
95impl From<Severity> for SarifLevel {
96 fn from(s: Severity) -> Self {
97 match s {
98 Severity::Error => SarifLevel::Error,
99 Severity::Warn => SarifLevel::Warning,
100 Severity::Info => SarifLevel::Note,
101 }
102 }
103}
104
105#[derive(Debug, Clone, Serialize)]
107pub struct SarifMessage {
108 pub text: String,
109}
110
111#[derive(Debug, Clone, Serialize)]
113#[serde(rename_all = "camelCase")]
114pub struct SarifLocation {
115 pub physical_location: SarifPhysicalLocation,
116}
117
118#[derive(Debug, Clone, Serialize)]
120#[serde(rename_all = "camelCase")]
121pub struct SarifPhysicalLocation {
122 pub artifact_location: SarifArtifactLocation,
123 #[serde(skip_serializing_if = "Option::is_none")]
124 pub region: Option<SarifRegion>,
125}
126
127#[derive(Debug, Clone, Serialize)]
129#[serde(rename_all = "camelCase")]
130pub struct SarifArtifactLocation {
131 pub uri: String,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub uri_base_id: Option<String>,
134}
135
136#[derive(Debug, Clone, Serialize)]
138#[serde(rename_all = "camelCase")]
139pub struct SarifRegion {
140 pub start_line: u32,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub start_column: Option<u32>,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub snippet: Option<SarifSnippet>,
145}
146
147#[derive(Debug, Clone, Serialize)]
149pub struct SarifSnippet {
150 pub text: String,
151}
152
153#[derive(Debug, Clone, Serialize)]
155#[serde(rename_all = "camelCase")]
156pub struct SarifInvocation {
157 pub execution_successful: bool,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub command_line: Option<String>,
160}
161
162pub fn render_sarif_for_receipt(receipt: &CheckReceipt) -> SarifReport {
164 let rules = collect_rules_from_findings(&receipt.findings);
166
167 let results: Vec<SarifResult> = receipt
169 .findings
170 .iter()
171 .map(finding_to_sarif_result)
172 .collect();
173
174 SarifReport {
175 schema: SARIF_SCHEMA.to_string(),
176 version: SARIF_VERSION.to_string(),
177 runs: vec![SarifRun {
178 tool: SarifTool {
179 driver: SarifDriver {
180 name: receipt.tool.name.clone(),
181 version: receipt.tool.version.clone(),
182 information_uri: DIFFGUARD_INFO_URI.to_string(),
183 rules,
184 },
185 },
186 results,
187 invocations: None,
188 }],
189 }
190}
191
192pub fn render_sarif_json(receipt: &CheckReceipt) -> Result<String, serde_json::Error> {
194 let report = render_sarif_for_receipt(receipt);
195 serde_json::to_string_pretty(&report)
196}
197
198fn collect_rules_from_findings(findings: &[Finding]) -> Vec<SarifRule> {
200 let mut seen = BTreeMap::new();
201
202 for f in findings {
203 if !seen.contains_key(&f.rule_id) {
204 seen.insert(
205 f.rule_id.clone(),
206 SarifRule {
207 id: f.rule_id.clone(),
208 short_description: Some(SarifMessage {
209 text: f.message.clone(),
210 }),
211 default_configuration: Some(SarifRuleConfiguration {
212 level: f.severity.into(),
213 }),
214 },
215 );
216 }
217 }
218
219 seen.into_values().collect()
220}
221
222fn finding_to_sarif_result(f: &Finding) -> SarifResult {
224 let mut fingerprints = BTreeMap::new();
226 fingerprints.insert(
227 "primaryLocationLineHash".to_string(),
228 format!("{}:{}:{}", f.rule_id, f.path, f.line),
229 );
230
231 SarifResult {
232 rule_id: f.rule_id.clone(),
233 level: f.severity.into(),
234 message: SarifMessage {
235 text: f.message.clone(),
236 },
237 locations: vec![SarifLocation {
238 physical_location: SarifPhysicalLocation {
239 artifact_location: SarifArtifactLocation {
240 uri: f.path.clone(),
241 uri_base_id: Some("%SRCROOT%".to_string()),
242 },
243 region: Some(SarifRegion {
244 start_line: f.line,
245 start_column: f.column,
246 snippet: Some(SarifSnippet {
247 text: f.snippet.clone(),
248 }),
249 }),
250 },
251 }],
252 partial_fingerprints: Some(fingerprints),
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use diffguard_types::{
260 CHECK_SCHEMA_V1, CheckReceipt, DiffMeta, Finding, Scope, ToolMeta, Verdict, VerdictCounts,
261 VerdictStatus,
262 };
263
264 fn create_test_receipt_with_findings() -> CheckReceipt {
266 CheckReceipt {
267 schema: CHECK_SCHEMA_V1.to_string(),
268 tool: ToolMeta {
269 name: "diffguard".to_string(),
270 version: "0.1.0".to_string(),
271 },
272 diff: DiffMeta {
273 base: "origin/main".to_string(),
274 head: "HEAD".to_string(),
275 context_lines: 0,
276 scope: Scope::Added,
277 files_scanned: 3,
278 lines_scanned: 42,
279 },
280 findings: vec![
281 Finding {
282 rule_id: "rust.no_unwrap".to_string(),
283 severity: Severity::Error,
284 message: "Avoid unwrap/expect in production code.".to_string(),
285 path: "src/lib.rs".to_string(),
286 line: 15,
287 column: Some(10),
288 match_text: ".unwrap()".to_string(),
289 snippet: "let value = result.unwrap();".to_string(),
290 },
291 Finding {
292 rule_id: "rust.no_dbg".to_string(),
293 severity: Severity::Warn,
294 message: "Remove dbg!/println! before merging.".to_string(),
295 path: "src/main.rs".to_string(),
296 line: 23,
297 column: Some(5),
298 match_text: "dbg!".to_string(),
299 snippet: " dbg!(config);".to_string(),
300 },
301 Finding {
302 rule_id: "python.no_print".to_string(),
303 severity: Severity::Warn,
304 message: "Remove print() before merging.".to_string(),
305 path: "scripts/deploy.py".to_string(),
306 line: 8,
307 column: None,
308 match_text: "print(".to_string(),
309 snippet: "print(\"Deploying...\")".to_string(),
310 },
311 ],
312 verdict: Verdict {
313 status: VerdictStatus::Fail,
314 counts: VerdictCounts {
315 info: 0,
316 warn: 2,
317 error: 1,
318 ..Default::default()
319 },
320 reasons: vec![
321 "1 error-level finding".to_string(),
322 "2 warning-level findings".to_string(),
323 ],
324 },
325 timing: None,
326 }
327 }
328
329 fn create_test_receipt_empty() -> CheckReceipt {
331 CheckReceipt {
332 schema: CHECK_SCHEMA_V1.to_string(),
333 tool: ToolMeta {
334 name: "diffguard".to_string(),
335 version: "0.1.0".to_string(),
336 },
337 diff: DiffMeta {
338 base: "origin/main".to_string(),
339 head: "HEAD".to_string(),
340 context_lines: 0,
341 scope: Scope::Added,
342 files_scanned: 5,
343 lines_scanned: 120,
344 },
345 findings: vec![],
346 verdict: Verdict {
347 status: VerdictStatus::Pass,
348 counts: VerdictCounts {
349 info: 0,
350 warn: 0,
351 error: 0,
352 ..Default::default()
353 },
354 reasons: vec![],
355 },
356 timing: None,
357 }
358 }
359
360 fn create_test_receipt_info_findings() -> CheckReceipt {
362 CheckReceipt {
363 schema: CHECK_SCHEMA_V1.to_string(),
364 tool: ToolMeta {
365 name: "diffguard".to_string(),
366 version: "0.1.0".to_string(),
367 },
368 diff: DiffMeta {
369 base: "origin/main".to_string(),
370 head: "HEAD".to_string(),
371 context_lines: 0,
372 scope: Scope::Added,
373 files_scanned: 1,
374 lines_scanned: 10,
375 },
376 findings: vec![Finding {
377 rule_id: "info.todo".to_string(),
378 severity: Severity::Info,
379 message: "Found a TODO comment.".to_string(),
380 path: "src/lib.rs".to_string(),
381 line: 5,
382 column: None,
383 match_text: "TODO".to_string(),
384 snippet: "// TODO: refactor this".to_string(),
385 }],
386 verdict: Verdict {
387 status: VerdictStatus::Pass,
388 counts: VerdictCounts {
389 info: 1,
390 warn: 0,
391 error: 0,
392 ..Default::default()
393 },
394 reasons: vec![],
395 },
396 timing: None,
397 }
398 }
399
400 #[test]
401 fn sarif_has_correct_schema_and_version() {
402 let receipt = create_test_receipt_empty();
403 let sarif = render_sarif_for_receipt(&receipt);
404
405 assert_eq!(sarif.schema, SARIF_SCHEMA);
406 assert_eq!(sarif.version, SARIF_VERSION);
407 }
408
409 #[test]
410 fn sarif_tool_info_is_correct() {
411 let receipt = create_test_receipt_with_findings();
412 let sarif = render_sarif_for_receipt(&receipt);
413
414 assert_eq!(sarif.runs.len(), 1);
415 let driver = &sarif.runs[0].tool.driver;
416 assert_eq!(driver.name, "diffguard");
417 assert_eq!(driver.version, "0.1.0");
418 assert_eq!(driver.information_uri, DIFFGUARD_INFO_URI);
419 }
420
421 #[test]
422 fn sarif_contains_all_findings() {
423 let receipt = create_test_receipt_with_findings();
424 let sarif = render_sarif_for_receipt(&receipt);
425
426 assert_eq!(sarif.runs[0].results.len(), 3);
427 }
428
429 #[test]
430 fn sarif_severity_mapping_error() {
431 let receipt = create_test_receipt_with_findings();
432 let sarif = render_sarif_for_receipt(&receipt);
433
434 let error_result = &sarif.runs[0].results[0];
435 assert!(matches!(error_result.level, SarifLevel::Error));
436 }
437
438 #[test]
439 fn sarif_severity_mapping_warning() {
440 let receipt = create_test_receipt_with_findings();
441 let sarif = render_sarif_for_receipt(&receipt);
442
443 let warn_result = &sarif.runs[0].results[1];
444 assert!(matches!(warn_result.level, SarifLevel::Warning));
445 }
446
447 #[test]
448 fn sarif_severity_mapping_note() {
449 let receipt = create_test_receipt_info_findings();
450 let sarif = render_sarif_for_receipt(&receipt);
451
452 let info_result = &sarif.runs[0].results[0];
453 assert!(matches!(info_result.level, SarifLevel::Note));
454 }
455
456 #[test]
457 fn sarif_location_includes_line_and_column() {
458 let receipt = create_test_receipt_with_findings();
459 let sarif = render_sarif_for_receipt(&receipt);
460
461 let result = &sarif.runs[0].results[0];
462 let location = &result.locations[0];
463 let region = location.physical_location.region.as_ref().unwrap();
464
465 assert_eq!(region.start_line, 15);
466 assert_eq!(region.start_column, Some(10));
467 }
468
469 #[test]
470 fn sarif_location_without_column() {
471 let receipt = create_test_receipt_with_findings();
472 let sarif = render_sarif_for_receipt(&receipt);
473
474 let result = &sarif.runs[0].results[2];
476 let location = &result.locations[0];
477 let region = location.physical_location.region.as_ref().unwrap();
478
479 assert_eq!(region.start_line, 8);
480 assert_eq!(region.start_column, None);
481 }
482
483 #[test]
484 fn sarif_empty_receipt_has_no_results() {
485 let receipt = create_test_receipt_empty();
486 let sarif = render_sarif_for_receipt(&receipt);
487
488 assert!(sarif.runs[0].results.is_empty());
489 assert!(sarif.runs[0].tool.driver.rules.is_empty());
490 }
491
492 #[test]
493 fn sarif_rules_are_deduplicated() {
494 let mut receipt = create_test_receipt_with_findings();
496 receipt.findings.push(Finding {
497 rule_id: "rust.no_unwrap".to_string(), severity: Severity::Error,
499 message: "Avoid unwrap/expect in production code.".to_string(),
500 path: "src/other.rs".to_string(),
501 line: 100,
502 column: None,
503 match_text: ".unwrap()".to_string(),
504 snippet: "x.unwrap()".to_string(),
505 });
506
507 let sarif = render_sarif_for_receipt(&receipt);
508
509 assert_eq!(sarif.runs[0].tool.driver.rules.len(), 3);
511 }
512
513 #[test]
514 fn sarif_json_is_valid() {
515 let receipt = create_test_receipt_with_findings();
516 let json = render_sarif_json(&receipt).expect("should serialize");
517
518 let _: serde_json::Value = serde_json::from_str(&json).expect("should be valid JSON");
520 }
521
522 #[test]
524 fn snapshot_sarif_with_findings() {
525 let receipt = create_test_receipt_with_findings();
526 let json = render_sarif_json(&receipt).expect("should serialize");
527 insta::assert_snapshot!(json);
528 }
529
530 #[test]
532 fn snapshot_sarif_no_findings() {
533 let receipt = create_test_receipt_empty();
534 let json = render_sarif_json(&receipt).expect("should serialize");
535 insta::assert_snapshot!(json);
536 }
537
538 #[test]
540 fn snapshot_sarif_info_findings() {
541 let receipt = create_test_receipt_info_findings();
542 let json = render_sarif_json(&receipt).expect("should serialize");
543 insta::assert_snapshot!(json);
544 }
545}