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