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 {
76 let mut results = Vec::new();
77 let mut rules = std::collections::HashMap::new();
78
79 for (file_path, warnings) in all_warnings {
81 for warning in warnings {
82 let rule_id = warning.rule_name.as_deref().unwrap_or("unknown");
83
84 rules.entry(rule_id).or_insert_with(|| {
86 json!({
87 "id": rule_id,
88 "name": rule_id,
89 "shortDescription": {
90 "text": format!("Markdown rule {}", rule_id)
91 },
92 "fullDescription": {
93 "text": format!("Markdown linting rule {}", rule_id)
94 }
95 })
96 });
97
98 let level = match warning.severity {
99 crate::rule::Severity::Error => "error",
100 crate::rule::Severity::Warning => "warning",
101 crate::rule::Severity::Info => "note",
102 };
103 let result = json!({
104 "ruleId": rule_id,
105 "level": level,
106 "message": {
107 "text": warning.message
108 },
109 "locations": [{
110 "physicalLocation": {
111 "artifactLocation": {
112 "uri": file_path
113 },
114 "region": {
115 "startLine": warning.line,
116 "startColumn": warning.column
117 }
118 }
119 }]
120 });
121
122 results.push(result);
123 }
124 }
125
126 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 {
230 range: 100..110,
231 replacement: "## Heading".to_string(),
232 }),
233 }];
234
235 let output = formatter.format_warnings(&warnings, "README.md");
236 let sarif: Value = serde_json::from_str(&output).unwrap();
237
238 let results = sarif["runs"][0]["results"].as_array().unwrap();
240 assert_eq!(results.len(), 1);
241 assert_eq!(results[0]["ruleId"], "MD001");
242 }
243
244 #[test]
245 fn test_format_multiple_warnings() {
246 let formatter = SarifFormatter::new();
247 let warnings = vec![
248 LintWarning {
249 line: 5,
250 column: 1,
251 end_line: 5,
252 end_column: 10,
253 rule_name: Some("MD001".to_string()),
254 message: "First warning".to_string(),
255 severity: Severity::Warning,
256 fix: None,
257 },
258 LintWarning {
259 line: 10,
260 column: 3,
261 end_line: 10,
262 end_column: 20,
263 rule_name: Some("MD013".to_string()),
264 message: "Second warning".to_string(),
265 severity: Severity::Error,
266 fix: None,
267 },
268 ];
269
270 let output = formatter.format_warnings(&warnings, "test.md");
271 let sarif: Value = serde_json::from_str(&output).unwrap();
272
273 let results = sarif["runs"][0]["results"].as_array().unwrap();
274 assert_eq!(results.len(), 2);
275 assert_eq!(results[0]["ruleId"], "MD001");
276 assert_eq!(results[0]["level"], "warning");
277 assert_eq!(results[0]["locations"][0]["physicalLocation"]["region"]["startLine"], 5);
278 assert_eq!(results[1]["ruleId"], "MD013");
279 assert_eq!(results[1]["level"], "error");
280 assert_eq!(
281 results[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
282 10
283 );
284 }
285
286 #[test]
287 fn test_format_warning_unknown_rule() {
288 let formatter = SarifFormatter::new();
289 let warnings = vec![LintWarning {
290 line: 1,
291 column: 1,
292 end_line: 1,
293 end_column: 5,
294 rule_name: None,
295 message: "Unknown rule warning".to_string(),
296 severity: Severity::Warning,
297 fix: None,
298 }];
299
300 let output = formatter.format_warnings(&warnings, "file.md");
301 let sarif: Value = serde_json::from_str(&output).unwrap();
302
303 let results = sarif["runs"][0]["results"].as_array().unwrap();
304 assert_eq!(results[0]["ruleId"], "unknown");
305 }
306
307 #[test]
308 fn test_tool_information() {
309 let formatter = SarifFormatter::new();
310 let warnings = vec![];
311 let output = formatter.format_warnings(&warnings, "test.md");
312
313 let sarif: Value = serde_json::from_str(&output).unwrap();
314 let driver = &sarif["runs"][0]["tool"]["driver"];
315
316 assert_eq!(driver["name"], "rumdl");
317 assert_eq!(driver["version"], env!("CARGO_PKG_VERSION"));
318 assert_eq!(driver["informationUri"], "https://github.com/rvben/rumdl");
319 }
320
321 #[test]
322 fn test_sarif_report_empty() {
323 let warnings = vec![];
324 let output = format_sarif_report(&warnings);
325
326 let sarif: Value = serde_json::from_str(&output).unwrap();
327 assert_eq!(sarif["version"], "2.1.0");
328 assert_eq!(sarif["runs"][0]["results"].as_array().unwrap().len(), 0);
329 }
330
331 #[test]
332 fn test_sarif_report_single_file() {
333 let warnings = vec![(
334 "test.md".to_string(),
335 vec![LintWarning {
336 line: 10,
337 column: 5,
338 end_line: 10,
339 end_column: 15,
340 rule_name: Some("MD001".to_string()),
341 message: "Test warning".to_string(),
342 severity: Severity::Warning,
343 fix: None,
344 }],
345 )];
346
347 let output = format_sarif_report(&warnings);
348 let sarif: Value = serde_json::from_str(&output).unwrap();
349
350 let results = sarif["runs"][0]["results"].as_array().unwrap();
351 assert_eq!(results.len(), 1);
352 assert_eq!(
353 results[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
354 "test.md"
355 );
356
357 let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
359 assert_eq!(rules.len(), 1);
360 assert_eq!(rules[0]["id"], "MD001");
361 }
362
363 #[test]
364 fn test_sarif_report_multiple_files() {
365 let warnings = vec![
366 (
367 "file1.md".to_string(),
368 vec![LintWarning {
369 line: 1,
370 column: 1,
371 end_line: 1,
372 end_column: 5,
373 rule_name: Some("MD001".to_string()),
374 message: "Warning in file 1".to_string(),
375 severity: Severity::Warning,
376 fix: None,
377 }],
378 ),
379 (
380 "file2.md".to_string(),
381 vec![
382 LintWarning {
383 line: 5,
384 column: 1,
385 end_line: 5,
386 end_column: 10,
387 rule_name: Some("MD013".to_string()),
388 message: "Warning 1 in file 2".to_string(),
389 severity: Severity::Warning,
390 fix: None,
391 },
392 LintWarning {
393 line: 10,
394 column: 1,
395 end_line: 10,
396 end_column: 10,
397 rule_name: Some("MD022".to_string()),
398 message: "Warning 2 in file 2".to_string(),
399 severity: Severity::Error,
400 fix: None,
401 },
402 ],
403 ),
404 ];
405
406 let output = format_sarif_report(&warnings);
407 let sarif: Value = serde_json::from_str(&output).unwrap();
408
409 let results = sarif["runs"][0]["results"].as_array().unwrap();
410 assert_eq!(results.len(), 3);
411
412 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();
419 assert_eq!(rules.len(), 3);
420
421 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
422 assert!(rule_ids.contains(&"MD001"));
423 assert!(rule_ids.contains(&"MD013"));
424 assert!(rule_ids.contains(&"MD022"));
425 }
426
427 #[test]
428 fn test_rule_deduplication() {
429 let warnings = vec![(
430 "test.md".to_string(),
431 vec![
432 LintWarning {
433 line: 1,
434 column: 1,
435 end_line: 1,
436 end_column: 5,
437 rule_name: Some("MD001".to_string()),
438 message: "First MD001".to_string(),
439 severity: Severity::Warning,
440 fix: None,
441 },
442 LintWarning {
443 line: 10,
444 column: 1,
445 end_line: 10,
446 end_column: 5,
447 rule_name: Some("MD001".to_string()),
448 message: "Second MD001".to_string(),
449 severity: Severity::Warning,
450 fix: None,
451 },
452 ],
453 )];
454
455 let output = format_sarif_report(&warnings);
456 let sarif: Value = serde_json::from_str(&output).unwrap();
457
458 let results = sarif["runs"][0]["results"].as_array().unwrap();
460 assert_eq!(results.len(), 2);
461
462 let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
463 assert_eq!(rules.len(), 1);
464 assert_eq!(rules[0]["id"], "MD001");
465 }
466
467 #[test]
468 fn test_severity_mapping() {
469 let formatter = SarifFormatter::new();
470
471 let warnings = vec![
472 LintWarning {
473 line: 1,
474 column: 1,
475 end_line: 1,
476 end_column: 5,
477 rule_name: Some("MD001".to_string()),
478 message: "Warning severity".to_string(),
479 severity: Severity::Warning,
480 fix: None,
481 },
482 LintWarning {
483 line: 2,
484 column: 1,
485 end_line: 2,
486 end_column: 5,
487 rule_name: Some("MD032".to_string()),
488 message: "Error severity".to_string(),
489 severity: Severity::Error,
490 fix: None,
491 },
492 ];
493
494 let output = formatter.format_warnings(&warnings, "test.md");
495 let sarif: Value = serde_json::from_str(&output).unwrap();
496
497 let results = sarif["runs"][0]["results"].as_array().unwrap();
498 assert_eq!(results[0]["level"], "warning"); assert_eq!(results[1]["level"], "error"); }
501
502 #[test]
503 fn test_sarif_report_severity_mapping() {
504 let warnings = vec![
505 (
506 "file1.md".to_string(),
507 vec![LintWarning {
508 line: 1,
509 column: 1,
510 end_line: 1,
511 end_column: 5,
512 rule_name: Some("MD001".to_string()),
513 message: "Warning".to_string(),
514 severity: Severity::Warning,
515 fix: None,
516 }],
517 ),
518 (
519 "file2.md".to_string(),
520 vec![LintWarning {
521 line: 5,
522 column: 1,
523 end_line: 5,
524 end_column: 10,
525 rule_name: Some("MD032".to_string()),
526 message: "Error".to_string(),
527 severity: Severity::Error,
528 fix: None,
529 }],
530 ),
531 ];
532
533 let output = format_sarif_report(&warnings);
534 let sarif: Value = serde_json::from_str(&output).unwrap();
535
536 let results = sarif["runs"][0]["results"].as_array().unwrap();
537 assert_eq!(results.len(), 2);
538 assert_eq!(results[0]["level"], "warning");
539 assert_eq!(results[1]["level"], "error");
540 }
541
542 #[test]
543 fn test_special_characters_in_message() {
544 let formatter = SarifFormatter::new();
545 let warnings = vec![LintWarning {
546 line: 1,
547 column: 1,
548 end_line: 1,
549 end_column: 5,
550 rule_name: Some("MD001".to_string()),
551 message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
552 severity: Severity::Warning,
553 fix: None,
554 }];
555
556 let output = formatter.format_warnings(&warnings, "test.md");
557 let sarif: Value = serde_json::from_str(&output).unwrap();
558
559 let results = sarif["runs"][0]["results"].as_array().unwrap();
560 assert_eq!(
562 results[0]["message"]["text"],
563 "Warning with \"quotes\" and 'apostrophes' and \n newline"
564 );
565 }
566
567 #[test]
568 fn test_special_characters_in_file_path() {
569 let formatter = SarifFormatter::new();
570 let warnings = vec![LintWarning {
571 line: 1,
572 column: 1,
573 end_line: 1,
574 end_column: 5,
575 rule_name: Some("MD001".to_string()),
576 message: "Test".to_string(),
577 severity: Severity::Warning,
578 fix: None,
579 }];
580
581 let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
582 let sarif: Value = serde_json::from_str(&output).unwrap();
583
584 let results = sarif["runs"][0]["results"].as_array().unwrap();
585 assert_eq!(
586 results[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
587 "path/with spaces/and-dashes.md"
588 );
589 }
590
591 #[test]
592 fn test_sarif_schema_version() {
593 let formatter = SarifFormatter::new();
594 let warnings = vec![];
595 let output = formatter.format_warnings(&warnings, "test.md");
596
597 let sarif: Value = serde_json::from_str(&output).unwrap();
598 assert_eq!(
599 sarif["$schema"],
600 "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"
601 );
602 assert_eq!(sarif["version"], "2.1.0");
603 }
604
605 #[test]
608 fn test_md032_integration_produces_warning_level() {
609 let content = "# Heading\n- List item without blank line before";
611 let rule = MD032BlanksAroundLists::default();
612 let ctx = LintContext::new(content, MarkdownFlavor::Standard, Some(PathBuf::from("test.md")));
613 let warnings = rule.check(&ctx).expect("MD032 check should succeed");
614
615 assert!(!warnings.is_empty(), "MD032 should flag list without blank line");
617
618 let formatter = SarifFormatter::new();
619 let output = formatter.format_warnings(&warnings, "test.md");
620 let sarif: Value = serde_json::from_str(&output).unwrap();
621
622 let results = sarif["runs"][0]["results"].as_array().unwrap();
623 assert!(
625 results.iter().any(|r| r["level"] == "warning"),
626 "MD032 violations should produce 'warning' level in SARIF output"
627 );
628 assert!(
630 results.iter().any(|r| r["ruleId"] == "MD032"),
631 "Results should include MD032 rule"
632 );
633 }
634
635 #[test]
636 fn test_all_warnings_no_errors() {
637 let formatter = SarifFormatter::new();
639 let warnings = vec![
640 LintWarning {
641 line: 1,
642 column: 1,
643 end_line: 1,
644 end_column: 5,
645 rule_name: Some("MD001".to_string()),
646 message: "First warning".to_string(),
647 severity: Severity::Warning,
648 fix: None,
649 },
650 LintWarning {
651 line: 2,
652 column: 1,
653 end_line: 2,
654 end_column: 5,
655 rule_name: Some("MD013".to_string()),
656 message: "Second warning".to_string(),
657 severity: Severity::Warning,
658 fix: None,
659 },
660 LintWarning {
661 line: 3,
662 column: 1,
663 end_line: 3,
664 end_column: 5,
665 rule_name: Some("MD041".to_string()),
666 message: "Third warning".to_string(),
667 severity: Severity::Warning,
668 fix: None,
669 },
670 ];
671
672 let output = formatter.format_warnings(&warnings, "test.md");
673 let sarif: Value = serde_json::from_str(&output).unwrap();
674
675 let results = sarif["runs"][0]["results"].as_array().unwrap();
676 assert_eq!(results.len(), 3);
677 assert!(results.iter().all(|r| r["level"] == "warning"));
679 assert!(!results.iter().any(|r| r["level"] == "error"));
681 }
682
683 #[test]
684 fn test_all_errors_no_warnings() {
685 let formatter = SarifFormatter::new();
687 let warnings = vec![
688 LintWarning {
689 line: 1,
690 column: 1,
691 end_line: 1,
692 end_column: 5,
693 rule_name: Some("MD032".to_string()),
694 message: "First error".to_string(),
695 severity: Severity::Error,
696 fix: None,
697 },
698 LintWarning {
699 line: 2,
700 column: 1,
701 end_line: 2,
702 end_column: 5,
703 rule_name: Some("MD032".to_string()),
704 message: "Second error".to_string(),
705 severity: Severity::Error,
706 fix: None,
707 },
708 ];
709
710 let output = formatter.format_warnings(&warnings, "test.md");
711 let sarif: Value = serde_json::from_str(&output).unwrap();
712
713 let results = sarif["runs"][0]["results"].as_array().unwrap();
714 assert_eq!(results.len(), 2);
715 assert!(results.iter().all(|r| r["level"] == "error"));
717 assert!(!results.iter().any(|r| r["level"] == "warning"));
719 }
720
721 #[test]
722 fn test_mixed_severities_same_file() {
723 let formatter = SarifFormatter::new();
725 let warnings = vec![
726 LintWarning {
727 line: 1,
728 column: 1,
729 end_line: 1,
730 end_column: 5,
731 rule_name: Some("MD001".to_string()),
732 message: "Warning".to_string(),
733 severity: Severity::Warning,
734 fix: None,
735 },
736 LintWarning {
737 line: 2,
738 column: 1,
739 end_line: 2,
740 end_column: 5,
741 rule_name: Some("MD032".to_string()),
742 message: "Error".to_string(),
743 severity: Severity::Error,
744 fix: None,
745 },
746 LintWarning {
747 line: 3,
748 column: 1,
749 end_line: 3,
750 end_column: 5,
751 rule_name: Some("MD013".to_string()),
752 message: "Warning".to_string(),
753 severity: Severity::Warning,
754 fix: None,
755 },
756 LintWarning {
757 line: 4,
758 column: 1,
759 end_line: 4,
760 end_column: 5,
761 rule_name: Some("MD032".to_string()),
762 message: "Error".to_string(),
763 severity: Severity::Error,
764 fix: None,
765 },
766 ];
767
768 let output = formatter.format_warnings(&warnings, "test.md");
769 let sarif: Value = serde_json::from_str(&output).unwrap();
770
771 let results = sarif["runs"][0]["results"].as_array().unwrap();
772 assert_eq!(results.len(), 4);
773
774 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();
782 let error_count = results.iter().filter(|r| r["level"] == "error").count();
783 assert_eq!(warning_count, 2);
784 assert_eq!(error_count, 2);
785 }
786
787 #[test]
788 fn test_rule_deduplication_preserves_severity() {
789 let warnings = vec![(
792 "test.md".to_string(),
793 vec![
794 LintWarning {
795 line: 1,
796 column: 1,
797 end_line: 1,
798 end_column: 5,
799 rule_name: Some("MD032".to_string()),
800 message: "First MD032 error".to_string(),
801 severity: Severity::Error,
802 fix: None,
803 },
804 LintWarning {
805 line: 5,
806 column: 1,
807 end_line: 5,
808 end_column: 5,
809 rule_name: Some("MD032".to_string()),
810 message: "Second MD032 error".to_string(),
811 severity: Severity::Error,
812 fix: None,
813 },
814 LintWarning {
815 line: 10,
816 column: 1,
817 end_line: 10,
818 end_column: 5,
819 rule_name: Some("MD032".to_string()),
820 message: "Third MD032 error".to_string(),
821 severity: Severity::Error,
822 fix: None,
823 },
824 ],
825 )];
826
827 let output = format_sarif_report(&warnings);
828 let sarif: Value = serde_json::from_str(&output).unwrap();
829
830 let results = sarif["runs"][0]["results"].as_array().unwrap();
832 assert_eq!(results.len(), 3);
833 assert!(results.iter().all(|r| r["level"] == "error"));
834 assert!(results.iter().all(|r| r["ruleId"] == "MD032"));
835
836 let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
838 assert_eq!(rules.len(), 1);
839 assert_eq!(rules[0]["id"], "MD032");
840 assert!(rules[0].get("defaultConfiguration").is_none());
842 }
843
844 #[test]
845 fn test_sarif_output_valid_json_schema() {
846 let formatter = SarifFormatter::new();
848 let warnings = vec![LintWarning {
849 line: 1,
850 column: 1,
851 end_line: 1,
852 end_column: 5,
853 rule_name: Some("MD001".to_string()),
854 message: "Test".to_string(),
855 severity: Severity::Warning,
856 fix: None,
857 }];
858
859 let output = formatter.format_warnings(&warnings, "test.md");
860
861 let sarif: Value = serde_json::from_str(&output).expect("SARIF output must be valid JSON");
863
864 assert!(sarif.get("version").is_some(), "Must have version field");
866 assert!(sarif.get("$schema").is_some(), "Must have $schema field");
867 assert!(sarif.get("runs").is_some(), "Must have runs field");
868
869 let runs = sarif["runs"].as_array().expect("runs must be an array");
871 assert!(!runs.is_empty(), "Must have at least one run");
872
873 let run = &runs[0];
875 assert!(run.get("tool").is_some(), "Run must have tool field");
876 assert!(run.get("results").is_some(), "Run must have results field");
877
878 assert!(run["tool"].get("driver").is_some(), "Tool must have driver field");
880
881 assert!(run["results"].is_array(), "Results must be an array");
883
884 let results = run["results"].as_array().unwrap();
886 for result in results {
887 assert!(result.get("ruleId").is_some(), "Result must have ruleId");
888 assert!(result.get("level").is_some(), "Result must have level");
889 assert!(result.get("message").is_some(), "Result must have message");
890 assert!(result.get("locations").is_some(), "Result must have locations");
891
892 let level = result["level"].as_str().unwrap();
894 assert!(
895 matches!(level, "warning" | "error" | "note" | "none" | "open"),
896 "Level must be valid SARIF level, got: {level}"
897 );
898 }
899 }
900
901 #[test]
902 fn test_default_configuration_removed() {
903 let warnings = vec![(
906 "test.md".to_string(),
907 vec![
908 LintWarning {
909 line: 1,
910 column: 1,
911 end_line: 1,
912 end_column: 5,
913 rule_name: Some("MD001".to_string()),
914 message: "Warning".to_string(),
915 severity: Severity::Warning,
916 fix: None,
917 },
918 LintWarning {
919 line: 2,
920 column: 1,
921 end_line: 2,
922 end_column: 5,
923 rule_name: Some("MD032".to_string()),
924 message: "Error".to_string(),
925 severity: Severity::Error,
926 fix: None,
927 },
928 ],
929 )];
930
931 let output = format_sarif_report(&warnings);
932 let sarif: Value = serde_json::from_str(&output).unwrap();
933
934 let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
935 assert_eq!(rules.len(), 2);
936
937 for rule in rules {
939 assert!(
940 rule.get("defaultConfiguration").is_none(),
941 "Rule {} should not have defaultConfiguration (it's instance-specific, not rule-specific)",
942 rule["id"]
943 );
944 }
945 }
946
947 #[test]
948 fn test_unknown_rule_with_error_severity() {
949 let formatter = SarifFormatter::new();
951 let warnings = vec![LintWarning {
952 line: 1,
953 column: 1,
954 end_line: 1,
955 end_column: 5,
956 rule_name: None,
957 message: "Unknown error".to_string(),
958 severity: Severity::Error,
959 fix: None,
960 }];
961
962 let output = formatter.format_warnings(&warnings, "test.md");
963 let sarif: Value = serde_json::from_str(&output).unwrap();
964
965 let results = sarif["runs"][0]["results"].as_array().unwrap();
966 assert_eq!(results.len(), 1);
967 assert_eq!(results[0]["ruleId"], "unknown");
968 assert_eq!(results[0]["level"], "error"); }
970
971 #[test]
972 fn test_exhaustive_severity_mapping() {
973 let formatter = SarifFormatter::new();
976
977 let all_severities = vec![(Severity::Warning, "warning"), (Severity::Error, "error")];
979
980 for (severity, expected_level) in all_severities {
981 let warnings = vec![LintWarning {
982 line: 1,
983 column: 1,
984 end_line: 1,
985 end_column: 5,
986 rule_name: Some("TEST".to_string()),
987 message: format!("Test {severity:?}"),
988 severity,
989 fix: None,
990 }];
991
992 let output = formatter.format_warnings(&warnings, "test.md");
993 let sarif: Value = serde_json::from_str(&output).unwrap();
994
995 let results = sarif["runs"][0]["results"].as_array().unwrap();
996 assert_eq!(
997 results[0]["level"], expected_level,
998 "Severity::{severity:?} should map to SARIF level '{expected_level}'"
999 );
1000 }
1001 }
1002}