1use crate::output::OutputFormatter;
4use crate::rule::LintWarning;
5use serde_json::json;
6
7pub struct SarifFormatter;
9
10impl Default for SarifFormatter {
11 fn default() -> Self {
12 Self
13 }
14}
15
16impl SarifFormatter {
17 pub fn new() -> Self {
18 Self
19 }
20}
21
22impl OutputFormatter for SarifFormatter {
23 fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String {
24 let results: Vec<_> = warnings
26 .iter()
27 .map(|warning| {
28 let rule_id = warning.rule_name.as_deref().unwrap_or("unknown");
29 let level = match warning.severity {
30 crate::rule::Severity::Error => "error",
31 crate::rule::Severity::Warning => "warning",
32 crate::rule::Severity::Info => "note",
33 };
34 json!({
35 "ruleId": rule_id,
36 "level": level,
37 "message": {
38 "text": warning.message
39 },
40 "locations": [{
41 "physicalLocation": {
42 "artifactLocation": {
43 "uri": file_path
44 },
45 "region": {
46 "startLine": warning.line,
47 "startColumn": warning.column
48 }
49 }
50 }]
51 })
52 })
53 .collect();
54
55 let sarif_doc = json!({
56 "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
57 "version": "2.1.0",
58 "runs": [{
59 "tool": {
60 "driver": {
61 "name": "rumdl",
62 "version": env!("CARGO_PKG_VERSION"),
63 "informationUri": "https://github.com/rvben/rumdl"
64 }
65 },
66 "results": results
67 }]
68 });
69
70 serde_json::to_string_pretty(&sarif_doc).unwrap_or_else(|_| r#"{"version":"2.1.0","runs":[]}"#.to_string())
71 }
72}
73
74pub fn format_sarif_report(all_warnings: &[(String, Vec<LintWarning>)]) -> String {
79 let mut results = Vec::new();
80 let mut rules = std::collections::HashMap::new();
81
82 for (file_path, warnings) in all_warnings {
83 for warning in warnings {
84 let rule_id = warning.rule_name.as_deref().unwrap_or("unknown");
85
86 rules.entry(rule_id).or_insert_with(|| {
87 json!({
88 "id": rule_id,
89 "name": rule_id,
90 "shortDescription": {
91 "text": format!("Markdown rule {}", rule_id)
92 },
93 "fullDescription": {
94 "text": format!("Markdown linting rule {}", rule_id)
95 }
96 })
97 });
98
99 let level = match warning.severity {
100 crate::rule::Severity::Error => "error",
101 crate::rule::Severity::Warning => "warning",
102 crate::rule::Severity::Info => "note",
103 };
104 let result = json!({
105 "ruleId": rule_id,
106 "level": level,
107 "message": {
108 "text": warning.message
109 },
110 "locations": [{
111 "physicalLocation": {
112 "artifactLocation": {
113 "uri": file_path
114 },
115 "region": {
116 "startLine": warning.line,
117 "startColumn": warning.column
118 }
119 }
120 }]
121 });
122
123 results.push(result);
124 }
125 }
126
127 let sarif_doc = json!({
128 "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
129 "version": "2.1.0",
130 "runs": [{
131 "tool": {
132 "driver": {
133 "name": "rumdl",
134 "version": env!("CARGO_PKG_VERSION"),
135 "informationUri": "https://github.com/rvben/rumdl",
136 "rules": rules.values().cloned().collect::<Vec<_>>()
137 }
138 },
139 "results": results
140 }]
141 });
142
143 serde_json::to_string_pretty(&sarif_doc).unwrap_or_else(|_| r#"{"version":"2.1.0","runs":[]}"#.to_string())
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use crate::config::MarkdownFlavor;
150 use crate::lint_context::LintContext;
151 use crate::rule::{Fix, Rule, Severity};
152 use crate::rules::MD032BlanksAroundLists;
153 use serde_json::Value;
154 use std::path::PathBuf;
155
156 #[test]
157 fn test_sarif_formatter_default() {
158 let _formatter = SarifFormatter;
159 }
161
162 #[test]
163 fn test_sarif_formatter_new() {
164 let _formatter = SarifFormatter::new();
165 }
167
168 #[test]
169 fn test_format_warnings_empty() {
170 let formatter = SarifFormatter::new();
171 let warnings = vec![];
172 let output = formatter.format_warnings(&warnings, "test.md");
173
174 let sarif: Value = serde_json::from_str(&output).unwrap();
175 assert_eq!(sarif["version"], "2.1.0");
176 assert_eq!(
177 sarif["$schema"],
178 "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"
179 );
180 assert_eq!(sarif["runs"][0]["results"].as_array().unwrap().len(), 0);
181 }
182
183 #[test]
184 fn test_format_single_warning() {
185 let formatter = SarifFormatter::new();
186 let warnings = vec![LintWarning {
187 line: 10,
188 column: 5,
189 end_line: 10,
190 end_column: 15,
191 rule_name: Some("MD001".to_string()),
192 message: "Heading levels should only increment by one level at a time".to_string(),
193 severity: Severity::Warning,
194 fix: None,
195 }];
196
197 let output = formatter.format_warnings(&warnings, "README.md");
198 let sarif: Value = serde_json::from_str(&output).unwrap();
199
200 let results = sarif["runs"][0]["results"].as_array().unwrap();
201 assert_eq!(results.len(), 1);
202
203 let result = &results[0];
204 assert_eq!(result["ruleId"], "MD001");
205 assert_eq!(result["level"], "warning");
206 assert_eq!(
207 result["message"]["text"],
208 "Heading levels should only increment by one level at a time"
209 );
210 assert_eq!(
211 result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
212 "README.md"
213 );
214 assert_eq!(result["locations"][0]["physicalLocation"]["region"]["startLine"], 10);
215 assert_eq!(result["locations"][0]["physicalLocation"]["region"]["startColumn"], 5);
216 }
217
218 #[test]
219 fn test_format_single_warning_with_fix() {
220 let formatter = SarifFormatter::new();
221 let warnings = vec![LintWarning {
222 line: 10,
223 column: 5,
224 end_line: 10,
225 end_column: 15,
226 rule_name: Some("MD001".to_string()),
227 message: "Heading levels should only increment by one level at a time".to_string(),
228 severity: Severity::Warning,
229 fix: Some(Fix::new(100..110, "## Heading".to_string())),
230 }];
231
232 let output = formatter.format_warnings(&warnings, "README.md");
233 let sarif: Value = serde_json::from_str(&output).unwrap();
234
235 let results = sarif["runs"][0]["results"].as_array().unwrap();
237 assert_eq!(results.len(), 1);
238 assert_eq!(results[0]["ruleId"], "MD001");
239 }
240
241 #[test]
242 fn test_format_multiple_warnings() {
243 let formatter = SarifFormatter::new();
244 let warnings = vec![
245 LintWarning {
246 line: 5,
247 column: 1,
248 end_line: 5,
249 end_column: 10,
250 rule_name: Some("MD001".to_string()),
251 message: "First warning".to_string(),
252 severity: Severity::Warning,
253 fix: None,
254 },
255 LintWarning {
256 line: 10,
257 column: 3,
258 end_line: 10,
259 end_column: 20,
260 rule_name: Some("MD013".to_string()),
261 message: "Second warning".to_string(),
262 severity: Severity::Error,
263 fix: None,
264 },
265 ];
266
267 let output = formatter.format_warnings(&warnings, "test.md");
268 let sarif: Value = serde_json::from_str(&output).unwrap();
269
270 let results = sarif["runs"][0]["results"].as_array().unwrap();
271 assert_eq!(results.len(), 2);
272 assert_eq!(results[0]["ruleId"], "MD001");
273 assert_eq!(results[0]["level"], "warning");
274 assert_eq!(results[0]["locations"][0]["physicalLocation"]["region"]["startLine"], 5);
275 assert_eq!(results[1]["ruleId"], "MD013");
276 assert_eq!(results[1]["level"], "error");
277 assert_eq!(
278 results[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
279 10
280 );
281 }
282
283 #[test]
284 fn test_format_warning_unknown_rule() {
285 let formatter = SarifFormatter::new();
286 let warnings = vec![LintWarning {
287 line: 1,
288 column: 1,
289 end_line: 1,
290 end_column: 5,
291 rule_name: None,
292 message: "Unknown rule warning".to_string(),
293 severity: Severity::Warning,
294 fix: None,
295 }];
296
297 let output = formatter.format_warnings(&warnings, "file.md");
298 let sarif: Value = serde_json::from_str(&output).unwrap();
299
300 let results = sarif["runs"][0]["results"].as_array().unwrap();
301 assert_eq!(results[0]["ruleId"], "unknown");
302 }
303
304 #[test]
305 fn test_tool_information() {
306 let formatter = SarifFormatter::new();
307 let warnings = vec![];
308 let output = formatter.format_warnings(&warnings, "test.md");
309
310 let sarif: Value = serde_json::from_str(&output).unwrap();
311 let driver = &sarif["runs"][0]["tool"]["driver"];
312
313 assert_eq!(driver["name"], "rumdl");
314 assert_eq!(driver["version"], env!("CARGO_PKG_VERSION"));
315 assert_eq!(driver["informationUri"], "https://github.com/rvben/rumdl");
316 }
317
318 #[test]
319 fn test_sarif_report_empty() {
320 let warnings = vec![];
321 let output = format_sarif_report(&warnings);
322
323 let sarif: Value = serde_json::from_str(&output).unwrap();
324 assert_eq!(sarif["version"], "2.1.0");
325 assert_eq!(sarif["runs"][0]["results"].as_array().unwrap().len(), 0);
326 }
327
328 #[test]
329 fn test_sarif_report_single_file() {
330 let warnings = vec![(
331 "test.md".to_string(),
332 vec![LintWarning {
333 line: 10,
334 column: 5,
335 end_line: 10,
336 end_column: 15,
337 rule_name: Some("MD001".to_string()),
338 message: "Test warning".to_string(),
339 severity: Severity::Warning,
340 fix: None,
341 }],
342 )];
343
344 let output = format_sarif_report(&warnings);
345 let sarif: Value = serde_json::from_str(&output).unwrap();
346
347 let results = sarif["runs"][0]["results"].as_array().unwrap();
348 assert_eq!(results.len(), 1);
349 assert_eq!(
350 results[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
351 "test.md"
352 );
353
354 let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
356 assert_eq!(rules.len(), 1);
357 assert_eq!(rules[0]["id"], "MD001");
358 }
359
360 #[test]
361 fn test_sarif_report_multiple_files() {
362 let warnings = vec![
363 (
364 "file1.md".to_string(),
365 vec![LintWarning {
366 line: 1,
367 column: 1,
368 end_line: 1,
369 end_column: 5,
370 rule_name: Some("MD001".to_string()),
371 message: "Warning in file 1".to_string(),
372 severity: Severity::Warning,
373 fix: None,
374 }],
375 ),
376 (
377 "file2.md".to_string(),
378 vec![
379 LintWarning {
380 line: 5,
381 column: 1,
382 end_line: 5,
383 end_column: 10,
384 rule_name: Some("MD013".to_string()),
385 message: "Warning 1 in file 2".to_string(),
386 severity: Severity::Warning,
387 fix: None,
388 },
389 LintWarning {
390 line: 10,
391 column: 1,
392 end_line: 10,
393 end_column: 10,
394 rule_name: Some("MD022".to_string()),
395 message: "Warning 2 in file 2".to_string(),
396 severity: Severity::Error,
397 fix: None,
398 },
399 ],
400 ),
401 ];
402
403 let output = format_sarif_report(&warnings);
404 let sarif: Value = serde_json::from_str(&output).unwrap();
405
406 let results = sarif["runs"][0]["results"].as_array().unwrap();
407 assert_eq!(results.len(), 3);
408
409 assert_eq!(results[0]["level"], "warning"); assert_eq!(results[1]["level"], "warning"); assert_eq!(results[2]["level"], "error"); let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
416 assert_eq!(rules.len(), 3);
417
418 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
419 assert!(rule_ids.contains(&"MD001"));
420 assert!(rule_ids.contains(&"MD013"));
421 assert!(rule_ids.contains(&"MD022"));
422 }
423
424 #[test]
425 fn test_rule_deduplication() {
426 let warnings = vec![(
427 "test.md".to_string(),
428 vec![
429 LintWarning {
430 line: 1,
431 column: 1,
432 end_line: 1,
433 end_column: 5,
434 rule_name: Some("MD001".to_string()),
435 message: "First MD001".to_string(),
436 severity: Severity::Warning,
437 fix: None,
438 },
439 LintWarning {
440 line: 10,
441 column: 1,
442 end_line: 10,
443 end_column: 5,
444 rule_name: Some("MD001".to_string()),
445 message: "Second MD001".to_string(),
446 severity: Severity::Warning,
447 fix: None,
448 },
449 ],
450 )];
451
452 let output = format_sarif_report(&warnings);
453 let sarif: Value = serde_json::from_str(&output).unwrap();
454
455 let results = sarif["runs"][0]["results"].as_array().unwrap();
457 assert_eq!(results.len(), 2);
458
459 let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
460 assert_eq!(rules.len(), 1);
461 assert_eq!(rules[0]["id"], "MD001");
462 }
463
464 #[test]
465 fn test_severity_mapping() {
466 let formatter = SarifFormatter::new();
467
468 let warnings = vec![
469 LintWarning {
470 line: 1,
471 column: 1,
472 end_line: 1,
473 end_column: 5,
474 rule_name: Some("MD001".to_string()),
475 message: "Warning severity".to_string(),
476 severity: Severity::Warning,
477 fix: None,
478 },
479 LintWarning {
480 line: 2,
481 column: 1,
482 end_line: 2,
483 end_column: 5,
484 rule_name: Some("MD032".to_string()),
485 message: "Error severity".to_string(),
486 severity: Severity::Error,
487 fix: None,
488 },
489 ];
490
491 let output = formatter.format_warnings(&warnings, "test.md");
492 let sarif: Value = serde_json::from_str(&output).unwrap();
493
494 let results = sarif["runs"][0]["results"].as_array().unwrap();
495 assert_eq!(results[0]["level"], "warning"); assert_eq!(results[1]["level"], "error"); }
498
499 #[test]
500 fn test_sarif_report_severity_mapping() {
501 let warnings = vec![
502 (
503 "file1.md".to_string(),
504 vec![LintWarning {
505 line: 1,
506 column: 1,
507 end_line: 1,
508 end_column: 5,
509 rule_name: Some("MD001".to_string()),
510 message: "Warning".to_string(),
511 severity: Severity::Warning,
512 fix: None,
513 }],
514 ),
515 (
516 "file2.md".to_string(),
517 vec![LintWarning {
518 line: 5,
519 column: 1,
520 end_line: 5,
521 end_column: 10,
522 rule_name: Some("MD032".to_string()),
523 message: "Error".to_string(),
524 severity: Severity::Error,
525 fix: None,
526 }],
527 ),
528 ];
529
530 let output = format_sarif_report(&warnings);
531 let sarif: Value = serde_json::from_str(&output).unwrap();
532
533 let results = sarif["runs"][0]["results"].as_array().unwrap();
534 assert_eq!(results.len(), 2);
535 assert_eq!(results[0]["level"], "warning");
536 assert_eq!(results[1]["level"], "error");
537 }
538
539 #[test]
540 fn test_special_characters_in_message() {
541 let formatter = SarifFormatter::new();
542 let warnings = vec![LintWarning {
543 line: 1,
544 column: 1,
545 end_line: 1,
546 end_column: 5,
547 rule_name: Some("MD001".to_string()),
548 message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
549 severity: Severity::Warning,
550 fix: None,
551 }];
552
553 let output = formatter.format_warnings(&warnings, "test.md");
554 let sarif: Value = serde_json::from_str(&output).unwrap();
555
556 let results = sarif["runs"][0]["results"].as_array().unwrap();
557 assert_eq!(
559 results[0]["message"]["text"],
560 "Warning with \"quotes\" and 'apostrophes' and \n newline"
561 );
562 }
563
564 #[test]
565 fn test_special_characters_in_file_path() {
566 let formatter = SarifFormatter::new();
567 let warnings = vec![LintWarning {
568 line: 1,
569 column: 1,
570 end_line: 1,
571 end_column: 5,
572 rule_name: Some("MD001".to_string()),
573 message: "Test".to_string(),
574 severity: Severity::Warning,
575 fix: None,
576 }];
577
578 let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
579 let sarif: Value = serde_json::from_str(&output).unwrap();
580
581 let results = sarif["runs"][0]["results"].as_array().unwrap();
582 assert_eq!(
583 results[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
584 "path/with spaces/and-dashes.md"
585 );
586 }
587
588 #[test]
589 fn test_sarif_schema_version() {
590 let formatter = SarifFormatter::new();
591 let warnings = vec![];
592 let output = formatter.format_warnings(&warnings, "test.md");
593
594 let sarif: Value = serde_json::from_str(&output).unwrap();
595 assert_eq!(
596 sarif["$schema"],
597 "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"
598 );
599 assert_eq!(sarif["version"], "2.1.0");
600 }
601
602 #[test]
605 fn test_md032_integration_produces_warning_level() {
606 let content = "# Heading\n- List item without blank line before";
608 let rule = MD032BlanksAroundLists::default();
609 let ctx = LintContext::new(content, MarkdownFlavor::Standard, Some(PathBuf::from("test.md")));
610 let warnings = rule.check(&ctx).expect("MD032 check should succeed");
611
612 assert!(!warnings.is_empty(), "MD032 should flag list without blank line");
614
615 let formatter = SarifFormatter::new();
616 let output = formatter.format_warnings(&warnings, "test.md");
617 let sarif: Value = serde_json::from_str(&output).unwrap();
618
619 let results = sarif["runs"][0]["results"].as_array().unwrap();
620 assert!(
622 results.iter().any(|r| r["level"] == "warning"),
623 "MD032 violations should produce 'warning' level in SARIF output"
624 );
625 assert!(
627 results.iter().any(|r| r["ruleId"] == "MD032"),
628 "Results should include MD032 rule"
629 );
630 }
631
632 #[test]
633 fn test_all_warnings_no_errors() {
634 let formatter = SarifFormatter::new();
636 let warnings = vec![
637 LintWarning {
638 line: 1,
639 column: 1,
640 end_line: 1,
641 end_column: 5,
642 rule_name: Some("MD001".to_string()),
643 message: "First warning".to_string(),
644 severity: Severity::Warning,
645 fix: None,
646 },
647 LintWarning {
648 line: 2,
649 column: 1,
650 end_line: 2,
651 end_column: 5,
652 rule_name: Some("MD013".to_string()),
653 message: "Second warning".to_string(),
654 severity: Severity::Warning,
655 fix: None,
656 },
657 LintWarning {
658 line: 3,
659 column: 1,
660 end_line: 3,
661 end_column: 5,
662 rule_name: Some("MD041".to_string()),
663 message: "Third warning".to_string(),
664 severity: Severity::Warning,
665 fix: None,
666 },
667 ];
668
669 let output = formatter.format_warnings(&warnings, "test.md");
670 let sarif: Value = serde_json::from_str(&output).unwrap();
671
672 let results = sarif["runs"][0]["results"].as_array().unwrap();
673 assert_eq!(results.len(), 3);
674 assert!(results.iter().all(|r| r["level"] == "warning"));
676 assert!(!results.iter().any(|r| r["level"] == "error"));
678 }
679
680 #[test]
681 fn test_all_errors_no_warnings() {
682 let formatter = SarifFormatter::new();
684 let warnings = vec![
685 LintWarning {
686 line: 1,
687 column: 1,
688 end_line: 1,
689 end_column: 5,
690 rule_name: Some("MD032".to_string()),
691 message: "First error".to_string(),
692 severity: Severity::Error,
693 fix: None,
694 },
695 LintWarning {
696 line: 2,
697 column: 1,
698 end_line: 2,
699 end_column: 5,
700 rule_name: Some("MD032".to_string()),
701 message: "Second error".to_string(),
702 severity: Severity::Error,
703 fix: None,
704 },
705 ];
706
707 let output = formatter.format_warnings(&warnings, "test.md");
708 let sarif: Value = serde_json::from_str(&output).unwrap();
709
710 let results = sarif["runs"][0]["results"].as_array().unwrap();
711 assert_eq!(results.len(), 2);
712 assert!(results.iter().all(|r| r["level"] == "error"));
714 assert!(!results.iter().any(|r| r["level"] == "warning"));
716 }
717
718 #[test]
719 fn test_mixed_severities_same_file() {
720 let formatter = SarifFormatter::new();
722 let warnings = vec![
723 LintWarning {
724 line: 1,
725 column: 1,
726 end_line: 1,
727 end_column: 5,
728 rule_name: Some("MD001".to_string()),
729 message: "Warning".to_string(),
730 severity: Severity::Warning,
731 fix: None,
732 },
733 LintWarning {
734 line: 2,
735 column: 1,
736 end_line: 2,
737 end_column: 5,
738 rule_name: Some("MD032".to_string()),
739 message: "Error".to_string(),
740 severity: Severity::Error,
741 fix: None,
742 },
743 LintWarning {
744 line: 3,
745 column: 1,
746 end_line: 3,
747 end_column: 5,
748 rule_name: Some("MD013".to_string()),
749 message: "Warning".to_string(),
750 severity: Severity::Warning,
751 fix: None,
752 },
753 LintWarning {
754 line: 4,
755 column: 1,
756 end_line: 4,
757 end_column: 5,
758 rule_name: Some("MD032".to_string()),
759 message: "Error".to_string(),
760 severity: Severity::Error,
761 fix: None,
762 },
763 ];
764
765 let output = formatter.format_warnings(&warnings, "test.md");
766 let sarif: Value = serde_json::from_str(&output).unwrap();
767
768 let results = sarif["runs"][0]["results"].as_array().unwrap();
769 assert_eq!(results.len(), 4);
770
771 assert_eq!(results[0]["level"], "warning"); assert_eq!(results[1]["level"], "error"); assert_eq!(results[2]["level"], "warning"); assert_eq!(results[3]["level"], "error"); let warning_count = results.iter().filter(|r| r["level"] == "warning").count();
779 let error_count = results.iter().filter(|r| r["level"] == "error").count();
780 assert_eq!(warning_count, 2);
781 assert_eq!(error_count, 2);
782 }
783
784 #[test]
785 fn test_rule_deduplication_preserves_severity() {
786 let warnings = vec![(
789 "test.md".to_string(),
790 vec![
791 LintWarning {
792 line: 1,
793 column: 1,
794 end_line: 1,
795 end_column: 5,
796 rule_name: Some("MD032".to_string()),
797 message: "First MD032 error".to_string(),
798 severity: Severity::Error,
799 fix: None,
800 },
801 LintWarning {
802 line: 5,
803 column: 1,
804 end_line: 5,
805 end_column: 5,
806 rule_name: Some("MD032".to_string()),
807 message: "Second MD032 error".to_string(),
808 severity: Severity::Error,
809 fix: None,
810 },
811 LintWarning {
812 line: 10,
813 column: 1,
814 end_line: 10,
815 end_column: 5,
816 rule_name: Some("MD032".to_string()),
817 message: "Third MD032 error".to_string(),
818 severity: Severity::Error,
819 fix: None,
820 },
821 ],
822 )];
823
824 let output = format_sarif_report(&warnings);
825 let sarif: Value = serde_json::from_str(&output).unwrap();
826
827 let results = sarif["runs"][0]["results"].as_array().unwrap();
829 assert_eq!(results.len(), 3);
830 assert!(results.iter().all(|r| r["level"] == "error"));
831 assert!(results.iter().all(|r| r["ruleId"] == "MD032"));
832
833 let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
835 assert_eq!(rules.len(), 1);
836 assert_eq!(rules[0]["id"], "MD032");
837 assert!(rules[0].get("defaultConfiguration").is_none());
839 }
840
841 #[test]
842 fn test_sarif_output_valid_json_schema() {
843 let formatter = SarifFormatter::new();
845 let warnings = vec![LintWarning {
846 line: 1,
847 column: 1,
848 end_line: 1,
849 end_column: 5,
850 rule_name: Some("MD001".to_string()),
851 message: "Test".to_string(),
852 severity: Severity::Warning,
853 fix: None,
854 }];
855
856 let output = formatter.format_warnings(&warnings, "test.md");
857
858 let sarif: Value = serde_json::from_str(&output).expect("SARIF output must be valid JSON");
860
861 assert!(sarif.get("version").is_some(), "Must have version field");
863 assert!(sarif.get("$schema").is_some(), "Must have $schema field");
864 assert!(sarif.get("runs").is_some(), "Must have runs field");
865
866 let runs = sarif["runs"].as_array().expect("runs must be an array");
868 assert!(!runs.is_empty(), "Must have at least one run");
869
870 let run = &runs[0];
872 assert!(run.get("tool").is_some(), "Run must have tool field");
873 assert!(run.get("results").is_some(), "Run must have results field");
874
875 assert!(run["tool"].get("driver").is_some(), "Tool must have driver field");
877
878 assert!(run["results"].is_array(), "Results must be an array");
880
881 let results = run["results"].as_array().unwrap();
883 for result in results {
884 assert!(result.get("ruleId").is_some(), "Result must have ruleId");
885 assert!(result.get("level").is_some(), "Result must have level");
886 assert!(result.get("message").is_some(), "Result must have message");
887 assert!(result.get("locations").is_some(), "Result must have locations");
888
889 let level = result["level"].as_str().unwrap();
891 assert!(
892 matches!(level, "warning" | "error" | "note" | "none" | "open"),
893 "Level must be valid SARIF level, got: {level}"
894 );
895 }
896 }
897
898 #[test]
899 fn test_default_configuration_removed() {
900 let warnings = vec![(
903 "test.md".to_string(),
904 vec![
905 LintWarning {
906 line: 1,
907 column: 1,
908 end_line: 1,
909 end_column: 5,
910 rule_name: Some("MD001".to_string()),
911 message: "Warning".to_string(),
912 severity: Severity::Warning,
913 fix: None,
914 },
915 LintWarning {
916 line: 2,
917 column: 1,
918 end_line: 2,
919 end_column: 5,
920 rule_name: Some("MD032".to_string()),
921 message: "Error".to_string(),
922 severity: Severity::Error,
923 fix: None,
924 },
925 ],
926 )];
927
928 let output = format_sarif_report(&warnings);
929 let sarif: Value = serde_json::from_str(&output).unwrap();
930
931 let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
932 assert_eq!(rules.len(), 2);
933
934 for rule in rules {
936 assert!(
937 rule.get("defaultConfiguration").is_none(),
938 "Rule {} should not have defaultConfiguration (it's instance-specific, not rule-specific)",
939 rule["id"]
940 );
941 }
942 }
943
944 #[test]
945 fn test_unknown_rule_with_error_severity() {
946 let formatter = SarifFormatter::new();
948 let warnings = vec![LintWarning {
949 line: 1,
950 column: 1,
951 end_line: 1,
952 end_column: 5,
953 rule_name: None,
954 message: "Unknown error".to_string(),
955 severity: Severity::Error,
956 fix: None,
957 }];
958
959 let output = formatter.format_warnings(&warnings, "test.md");
960 let sarif: Value = serde_json::from_str(&output).unwrap();
961
962 let results = sarif["runs"][0]["results"].as_array().unwrap();
963 assert_eq!(results.len(), 1);
964 assert_eq!(results[0]["ruleId"], "unknown");
965 assert_eq!(results[0]["level"], "error"); }
967
968 #[test]
969 fn test_exhaustive_severity_mapping() {
970 let formatter = SarifFormatter::new();
973
974 let all_severities = vec![(Severity::Warning, "warning"), (Severity::Error, "error")];
976
977 for (severity, expected_level) in all_severities {
978 let warnings = vec![LintWarning {
979 line: 1,
980 column: 1,
981 end_line: 1,
982 end_column: 5,
983 rule_name: Some("TEST".to_string()),
984 message: format!("Test {severity:?}"),
985 severity,
986 fix: None,
987 }];
988
989 let output = formatter.format_warnings(&warnings, "test.md");
990 let sarif: Value = serde_json::from_str(&output).unwrap();
991
992 let results = sarif["runs"][0]["results"].as_array().unwrap();
993 assert_eq!(
994 results[0]["level"], expected_level,
995 "Severity::{severity:?} should map to SARIF level '{expected_level}'"
996 );
997 }
998 }
999}