1use crate::output::OutputFormatter;
4use crate::rule::LintWarning;
5use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
6use serde_json::json;
7
8const SARIF_SCHEMA_URI: &str =
9 "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json";
10
11const SARIF_PATH_ENCODE_SET: &AsciiSet = &CONTROLS
15 .add(b' ')
16 .add(b'"')
17 .add(b'#')
18 .add(b'%')
19 .add(b'<')
20 .add(b'>')
21 .add(b'?')
22 .add(b'[')
23 .add(b'\\')
24 .add(b']')
25 .add(b'^')
26 .add(b'`')
27 .add(b'{')
28 .add(b'|')
29 .add(b'}');
30
31fn artifact_uri(file_path: &str) -> String {
32 let file_path = crate::discovery::strip_verbatim_prefix(file_path);
37 let encoded = utf8_percent_encode(&file_path, SARIF_PATH_ENCODE_SET).to_string();
38
39 if file_path.starts_with("//") {
40 format!("file:{encoded}")
42 } else if file_path.starts_with('/') {
43 format!("file://{encoded}")
45 } else if file_path.as_bytes().get(1) == Some(&b':') {
46 format!("file:///{encoded}")
48 } else {
49 encoded
50 }
51}
52
53pub struct SarifFormatter;
55
56impl Default for SarifFormatter {
57 fn default() -> Self {
58 Self
59 }
60}
61
62impl SarifFormatter {
63 pub fn new() -> Self {
64 Self
65 }
66}
67
68impl OutputFormatter for SarifFormatter {
69 fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String {
70 let results: Vec<_> = warnings
72 .iter()
73 .map(|warning| {
74 let rule_id = warning.rule_name.as_deref().unwrap_or("unknown");
75 let level = match warning.severity {
76 crate::rule::Severity::Error => "error",
77 crate::rule::Severity::Warning => "warning",
78 crate::rule::Severity::Info => "note",
79 };
80 json!({
81 "ruleId": rule_id,
82 "level": level,
83 "message": {
84 "text": warning.message
85 },
86 "locations": [{
87 "physicalLocation": {
88 "artifactLocation": {
89 "uri": artifact_uri(file_path)
90 },
91 "region": {
92 "startLine": warning.line,
93 "startColumn": warning.column
94 }
95 }
96 }]
97 })
98 })
99 .collect();
100
101 let sarif_doc = json!({
102 "$schema": SARIF_SCHEMA_URI,
103 "version": "2.1.0",
104 "runs": [{
105 "tool": {
106 "driver": {
107 "name": "rumdl",
108 "version": env!("CARGO_PKG_VERSION"),
109 "informationUri": "https://github.com/rvben/rumdl"
110 }
111 },
112 "results": results
113 }]
114 });
115
116 serde_json::to_string_pretty(&sarif_doc).unwrap_or_else(|_| r#"{"version":"2.1.0","runs":[]}"#.to_string())
117 }
118}
119
120pub fn format_sarif_report(all_warnings: &[(String, Vec<LintWarning>)]) -> String {
125 format_sarif_report_with_invocation(all_warnings, None)
126}
127
128pub fn format_sarif_report_with_invocation(
130 all_warnings: &[(String, Vec<LintWarning>)],
131 invocation_error: Option<&str>,
132) -> String {
133 let mut results = Vec::new();
134 let mut rules = std::collections::HashMap::new();
135
136 for (file_path, warnings) in all_warnings {
137 for warning in warnings {
138 let rule_id = warning.rule_name.as_deref().unwrap_or("unknown");
139
140 rules.entry(rule_id).or_insert_with(|| {
141 json!({
142 "id": rule_id,
143 "name": rule_id,
144 "shortDescription": {
145 "text": format!("Markdown rule {}", rule_id)
146 },
147 "fullDescription": {
148 "text": format!("Markdown linting rule {}", rule_id)
149 }
150 })
151 });
152
153 let level = match warning.severity {
154 crate::rule::Severity::Error => "error",
155 crate::rule::Severity::Warning => "warning",
156 crate::rule::Severity::Info => "note",
157 };
158 let result = json!({
159 "ruleId": rule_id,
160 "level": level,
161 "message": {
162 "text": warning.message
163 },
164 "locations": [{
165 "physicalLocation": {
166 "artifactLocation": {
167 "uri": artifact_uri(file_path)
168 },
169 "region": {
170 "startLine": warning.line,
171 "startColumn": warning.column
172 }
173 }
174 }]
175 });
176
177 results.push(result);
178 }
179 }
180
181 let mut sarif_doc = json!({
182 "$schema": SARIF_SCHEMA_URI,
183 "version": "2.1.0",
184 "runs": [{
185 "tool": {
186 "driver": {
187 "name": "rumdl",
188 "version": env!("CARGO_PKG_VERSION"),
189 "informationUri": "https://github.com/rvben/rumdl",
190 "rules": rules.values().cloned().collect::<Vec<_>>()
191 }
192 },
193 "results": results
194 }]
195 });
196
197 if let Some(message) = invocation_error {
198 sarif_doc["runs"][0]["invocations"] = json!([{
199 "executionSuccessful": false,
200 "toolExecutionNotifications": [{
201 "level": "error",
202 "descriptor": {"id": "code-block-tools"},
203 "message": {"text": message}
204 }]
205 }]);
206 }
207
208 serde_json::to_string_pretty(&sarif_doc).unwrap_or_else(|_| r#"{"version":"2.1.0","runs":[]}"#.to_string())
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use crate::config::MarkdownFlavor;
215 use crate::lint_context::LintContext;
216 use crate::rule::{Fix, Rule, Severity};
217 use crate::rules::MD032BlanksAroundLists;
218 use serde_json::Value;
219 use std::path::PathBuf;
220
221 #[test]
222 fn test_sarif_formatter_default() {
223 let _formatter = SarifFormatter;
224 }
226
227 #[test]
228 fn test_sarif_formatter_new() {
229 let _formatter = SarifFormatter::new();
230 }
232
233 #[test]
234 fn test_format_warnings_empty() {
235 let formatter = SarifFormatter::new();
236 let warnings = vec![];
237 let output = formatter.format_warnings(&warnings, "test.md");
238
239 let sarif: Value = serde_json::from_str(&output).unwrap();
240 assert_eq!(sarif["version"], "2.1.0");
241 assert_eq!(sarif["$schema"], SARIF_SCHEMA_URI);
242 assert_eq!(sarif["runs"][0]["results"].as_array().unwrap().len(), 0);
243 }
244
245 #[test]
246 fn test_format_single_warning() {
247 let formatter = SarifFormatter::new();
248 let warnings = vec![LintWarning {
249 line: 10,
250 column: 5,
251 end_line: 10,
252 end_column: 15,
253 rule_name: Some("MD001".to_string()),
254 message: "Heading levels should only increment by one level at a time".to_string(),
255 severity: Severity::Warning,
256 fix: None,
257 }];
258
259 let output = formatter.format_warnings(&warnings, "README.md");
260 let sarif: Value = serde_json::from_str(&output).unwrap();
261
262 let results = sarif["runs"][0]["results"].as_array().unwrap();
263 assert_eq!(results.len(), 1);
264
265 let result = &results[0];
266 assert_eq!(result["ruleId"], "MD001");
267 assert_eq!(result["level"], "warning");
268 assert_eq!(
269 result["message"]["text"],
270 "Heading levels should only increment by one level at a time"
271 );
272 assert_eq!(
273 result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
274 "README.md"
275 );
276 assert_eq!(result["locations"][0]["physicalLocation"]["region"]["startLine"], 10);
277 assert_eq!(result["locations"][0]["physicalLocation"]["region"]["startColumn"], 5);
278 }
279
280 #[test]
281 fn test_format_single_warning_with_fix() {
282 let formatter = SarifFormatter::new();
283 let warnings = vec![LintWarning {
284 line: 10,
285 column: 5,
286 end_line: 10,
287 end_column: 15,
288 rule_name: Some("MD001".to_string()),
289 message: "Heading levels should only increment by one level at a time".to_string(),
290 severity: Severity::Warning,
291 fix: Some(Fix::new(100..110, "## Heading".to_string())),
292 }];
293
294 let output = formatter.format_warnings(&warnings, "README.md");
295 let sarif: Value = serde_json::from_str(&output).unwrap();
296
297 let results = sarif["runs"][0]["results"].as_array().unwrap();
299 assert_eq!(results.len(), 1);
300 assert_eq!(results[0]["ruleId"], "MD001");
301 }
302
303 #[test]
304 fn test_format_multiple_warnings() {
305 let formatter = SarifFormatter::new();
306 let warnings = vec![
307 LintWarning {
308 line: 5,
309 column: 1,
310 end_line: 5,
311 end_column: 10,
312 rule_name: Some("MD001".to_string()),
313 message: "First warning".to_string(),
314 severity: Severity::Warning,
315 fix: None,
316 },
317 LintWarning {
318 line: 10,
319 column: 3,
320 end_line: 10,
321 end_column: 20,
322 rule_name: Some("MD013".to_string()),
323 message: "Second warning".to_string(),
324 severity: Severity::Error,
325 fix: None,
326 },
327 ];
328
329 let output = formatter.format_warnings(&warnings, "test.md");
330 let sarif: Value = serde_json::from_str(&output).unwrap();
331
332 let results = sarif["runs"][0]["results"].as_array().unwrap();
333 assert_eq!(results.len(), 2);
334 assert_eq!(results[0]["ruleId"], "MD001");
335 assert_eq!(results[0]["level"], "warning");
336 assert_eq!(results[0]["locations"][0]["physicalLocation"]["region"]["startLine"], 5);
337 assert_eq!(results[1]["ruleId"], "MD013");
338 assert_eq!(results[1]["level"], "error");
339 assert_eq!(
340 results[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
341 10
342 );
343 }
344
345 #[test]
346 fn test_format_warning_unknown_rule() {
347 let formatter = SarifFormatter::new();
348 let warnings = vec![LintWarning {
349 line: 1,
350 column: 1,
351 end_line: 1,
352 end_column: 5,
353 rule_name: None,
354 message: "Unknown rule warning".to_string(),
355 severity: Severity::Warning,
356 fix: None,
357 }];
358
359 let output = formatter.format_warnings(&warnings, "file.md");
360 let sarif: Value = serde_json::from_str(&output).unwrap();
361
362 let results = sarif["runs"][0]["results"].as_array().unwrap();
363 assert_eq!(results[0]["ruleId"], "unknown");
364 }
365
366 #[test]
367 fn test_tool_information() {
368 let formatter = SarifFormatter::new();
369 let warnings = vec![];
370 let output = formatter.format_warnings(&warnings, "test.md");
371
372 let sarif: Value = serde_json::from_str(&output).unwrap();
373 let driver = &sarif["runs"][0]["tool"]["driver"];
374
375 assert_eq!(driver["name"], "rumdl");
376 assert_eq!(driver["version"], env!("CARGO_PKG_VERSION"));
377 assert_eq!(driver["informationUri"], "https://github.com/rvben/rumdl");
378 }
379
380 #[test]
381 fn test_sarif_report_empty() {
382 let warnings = vec![];
383 let output = format_sarif_report(&warnings);
384
385 let sarif: Value = serde_json::from_str(&output).unwrap();
386 assert_eq!(sarif["version"], "2.1.0");
387 assert_eq!(sarif["runs"][0]["results"].as_array().unwrap().len(), 0);
388 }
389
390 #[test]
391 fn test_sarif_report_single_file() {
392 let warnings = vec![(
393 "test.md".to_string(),
394 vec![LintWarning {
395 line: 10,
396 column: 5,
397 end_line: 10,
398 end_column: 15,
399 rule_name: Some("MD001".to_string()),
400 message: "Test warning".to_string(),
401 severity: Severity::Warning,
402 fix: None,
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(), 1);
411 assert_eq!(
412 results[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
413 "test.md"
414 );
415
416 let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
418 assert_eq!(rules.len(), 1);
419 assert_eq!(rules[0]["id"], "MD001");
420 }
421
422 #[test]
423 fn test_sarif_report_multiple_files() {
424 let warnings = vec![
425 (
426 "file1.md".to_string(),
427 vec![LintWarning {
428 line: 1,
429 column: 1,
430 end_line: 1,
431 end_column: 5,
432 rule_name: Some("MD001".to_string()),
433 message: "Warning in file 1".to_string(),
434 severity: Severity::Warning,
435 fix: None,
436 }],
437 ),
438 (
439 "file2.md".to_string(),
440 vec![
441 LintWarning {
442 line: 5,
443 column: 1,
444 end_line: 5,
445 end_column: 10,
446 rule_name: Some("MD013".to_string()),
447 message: "Warning 1 in file 2".to_string(),
448 severity: Severity::Warning,
449 fix: None,
450 },
451 LintWarning {
452 line: 10,
453 column: 1,
454 end_line: 10,
455 end_column: 10,
456 rule_name: Some("MD022".to_string()),
457 message: "Warning 2 in file 2".to_string(),
458 severity: Severity::Error,
459 fix: None,
460 },
461 ],
462 ),
463 ];
464
465 let output = format_sarif_report(&warnings);
466 let sarif: Value = serde_json::from_str(&output).unwrap();
467
468 let results = sarif["runs"][0]["results"].as_array().unwrap();
469 assert_eq!(results.len(), 3);
470
471 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();
478 assert_eq!(rules.len(), 3);
479
480 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
481 assert!(rule_ids.contains(&"MD001"));
482 assert!(rule_ids.contains(&"MD013"));
483 assert!(rule_ids.contains(&"MD022"));
484 }
485
486 #[test]
487 fn test_rule_deduplication() {
488 let warnings = vec![(
489 "test.md".to_string(),
490 vec![
491 LintWarning {
492 line: 1,
493 column: 1,
494 end_line: 1,
495 end_column: 5,
496 rule_name: Some("MD001".to_string()),
497 message: "First MD001".to_string(),
498 severity: Severity::Warning,
499 fix: None,
500 },
501 LintWarning {
502 line: 10,
503 column: 1,
504 end_line: 10,
505 end_column: 5,
506 rule_name: Some("MD001".to_string()),
507 message: "Second MD001".to_string(),
508 severity: Severity::Warning,
509 fix: None,
510 },
511 ],
512 )];
513
514 let output = format_sarif_report(&warnings);
515 let sarif: Value = serde_json::from_str(&output).unwrap();
516
517 let results = sarif["runs"][0]["results"].as_array().unwrap();
519 assert_eq!(results.len(), 2);
520
521 let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
522 assert_eq!(rules.len(), 1);
523 assert_eq!(rules[0]["id"], "MD001");
524 }
525
526 #[test]
527 fn test_severity_mapping() {
528 let formatter = SarifFormatter::new();
529
530 let warnings = vec![
531 LintWarning {
532 line: 1,
533 column: 1,
534 end_line: 1,
535 end_column: 5,
536 rule_name: Some("MD001".to_string()),
537 message: "Warning severity".to_string(),
538 severity: Severity::Warning,
539 fix: None,
540 },
541 LintWarning {
542 line: 2,
543 column: 1,
544 end_line: 2,
545 end_column: 5,
546 rule_name: Some("MD032".to_string()),
547 message: "Error severity".to_string(),
548 severity: Severity::Error,
549 fix: None,
550 },
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!(results[0]["level"], "warning"); assert_eq!(results[1]["level"], "error"); }
560
561 #[test]
562 fn test_sarif_report_severity_mapping() {
563 let warnings = vec![
564 (
565 "file1.md".to_string(),
566 vec![LintWarning {
567 line: 1,
568 column: 1,
569 end_line: 1,
570 end_column: 5,
571 rule_name: Some("MD001".to_string()),
572 message: "Warning".to_string(),
573 severity: Severity::Warning,
574 fix: None,
575 }],
576 ),
577 (
578 "file2.md".to_string(),
579 vec![LintWarning {
580 line: 5,
581 column: 1,
582 end_line: 5,
583 end_column: 10,
584 rule_name: Some("MD032".to_string()),
585 message: "Error".to_string(),
586 severity: Severity::Error,
587 fix: None,
588 }],
589 ),
590 ];
591
592 let output = format_sarif_report(&warnings);
593 let sarif: Value = serde_json::from_str(&output).unwrap();
594
595 let results = sarif["runs"][0]["results"].as_array().unwrap();
596 assert_eq!(results.len(), 2);
597 assert_eq!(results[0]["level"], "warning");
598 assert_eq!(results[1]["level"], "error");
599 }
600
601 #[test]
602 fn test_special_characters_in_message() {
603 let formatter = SarifFormatter::new();
604 let warnings = vec![LintWarning {
605 line: 1,
606 column: 1,
607 end_line: 1,
608 end_column: 5,
609 rule_name: Some("MD001".to_string()),
610 message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
611 severity: Severity::Warning,
612 fix: None,
613 }];
614
615 let output = formatter.format_warnings(&warnings, "test.md");
616 let sarif: Value = serde_json::from_str(&output).unwrap();
617
618 let results = sarif["runs"][0]["results"].as_array().unwrap();
619 assert_eq!(
621 results[0]["message"]["text"],
622 "Warning with \"quotes\" and 'apostrophes' and \n newline"
623 );
624 }
625
626 #[test]
627 fn test_special_characters_in_file_path() {
628 let formatter = SarifFormatter::new();
629 let warnings = vec![LintWarning {
630 line: 1,
631 column: 1,
632 end_line: 1,
633 end_column: 5,
634 rule_name: Some("MD001".to_string()),
635 message: "Test".to_string(),
636 severity: Severity::Warning,
637 fix: None,
638 }];
639
640 let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
641 let sarif: Value = serde_json::from_str(&output).unwrap();
642
643 let results = sarif["runs"][0]["results"].as_array().unwrap();
644 assert_eq!(
645 results[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
646 "path/with%20spaces/and-dashes.md"
647 );
648 }
649
650 #[test]
651 fn test_windows_verbatim_paths_become_drive_and_share_uris() {
652 let formatter = SarifFormatter::new();
653 let warnings = vec![LintWarning {
654 line: 1,
655 column: 1,
656 end_line: 1,
657 end_column: 5,
658 rule_name: Some("MD001".to_string()),
659 message: "Test".to_string(),
660 severity: Severity::Warning,
661 fix: None,
662 }];
663
664 for (display_path, expected_uri) in [
667 ("//?/C:/Users/dev/docs/guide.md", "file:///C:/Users/dev/docs/guide.md"),
668 (
669 "//?/UNC/server/share/docs/guide.md",
670 "file://server/share/docs/guide.md",
671 ),
672 ("C:/Users/dev/docs/guide.md", "file:///C:/Users/dev/docs/guide.md"),
674 ("//server/share/docs/guide.md", "file://server/share/docs/guide.md"),
675 ] {
676 let output = formatter.format_warnings(&warnings, display_path);
677 let sarif: Value = serde_json::from_str(&output).unwrap();
678 let uri = sarif["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
679 .as_str()
680 .unwrap();
681 assert_eq!(uri, expected_uri, "URI for {display_path}");
682 let parsed = url::Url::parse(uri).unwrap_or_else(|error| panic!("{display_path}: {error}: {uri}"));
683 assert_eq!(parsed.scheme(), "file", "{display_path}");
684 }
685 }
686
687 #[test]
688 fn test_sarif_schema_version() {
689 let formatter = SarifFormatter::new();
690 let warnings = vec![];
691 let output = formatter.format_warnings(&warnings, "test.md");
692
693 let sarif: Value = serde_json::from_str(&output).unwrap();
694 assert_eq!(sarif["$schema"], SARIF_SCHEMA_URI);
695 assert_eq!(sarif["version"], "2.1.0");
696 }
697
698 #[test]
701 fn test_md032_integration_produces_warning_level() {
702 let content = "# Heading\n- List item without blank line before";
704 let rule = MD032BlanksAroundLists::default();
705 let ctx = LintContext::new(content, MarkdownFlavor::Standard, Some(PathBuf::from("test.md")));
706 let warnings = rule.check(&ctx).expect("MD032 check should succeed");
707
708 assert!(!warnings.is_empty(), "MD032 should flag list without blank line");
710
711 let formatter = SarifFormatter::new();
712 let output = formatter.format_warnings(&warnings, "test.md");
713 let sarif: Value = serde_json::from_str(&output).unwrap();
714
715 let results = sarif["runs"][0]["results"].as_array().unwrap();
716 assert!(
718 results.iter().any(|r| r["level"] == "warning"),
719 "MD032 violations should produce 'warning' level in SARIF output"
720 );
721 assert!(
723 results.iter().any(|r| r["ruleId"] == "MD032"),
724 "Results should include MD032 rule"
725 );
726 }
727
728 #[test]
729 fn test_all_warnings_no_errors() {
730 let formatter = SarifFormatter::new();
732 let warnings = vec![
733 LintWarning {
734 line: 1,
735 column: 1,
736 end_line: 1,
737 end_column: 5,
738 rule_name: Some("MD001".to_string()),
739 message: "First warning".to_string(),
740 severity: Severity::Warning,
741 fix: None,
742 },
743 LintWarning {
744 line: 2,
745 column: 1,
746 end_line: 2,
747 end_column: 5,
748 rule_name: Some("MD013".to_string()),
749 message: "Second warning".to_string(),
750 severity: Severity::Warning,
751 fix: None,
752 },
753 LintWarning {
754 line: 3,
755 column: 1,
756 end_line: 3,
757 end_column: 5,
758 rule_name: Some("MD041".to_string()),
759 message: "Third warning".to_string(),
760 severity: Severity::Warning,
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(), 3);
770 assert!(results.iter().all(|r| r["level"] == "warning"));
772 assert!(!results.iter().any(|r| r["level"] == "error"));
774 }
775
776 #[test]
777 fn test_all_errors_no_warnings() {
778 let formatter = SarifFormatter::new();
780 let warnings = vec![
781 LintWarning {
782 line: 1,
783 column: 1,
784 end_line: 1,
785 end_column: 5,
786 rule_name: Some("MD032".to_string()),
787 message: "First error".to_string(),
788 severity: Severity::Error,
789 fix: None,
790 },
791 LintWarning {
792 line: 2,
793 column: 1,
794 end_line: 2,
795 end_column: 5,
796 rule_name: Some("MD032".to_string()),
797 message: "Second error".to_string(),
798 severity: Severity::Error,
799 fix: None,
800 },
801 ];
802
803 let output = formatter.format_warnings(&warnings, "test.md");
804 let sarif: Value = serde_json::from_str(&output).unwrap();
805
806 let results = sarif["runs"][0]["results"].as_array().unwrap();
807 assert_eq!(results.len(), 2);
808 assert!(results.iter().all(|r| r["level"] == "error"));
810 assert!(!results.iter().any(|r| r["level"] == "warning"));
812 }
813
814 #[test]
815 fn test_mixed_severities_same_file() {
816 let formatter = SarifFormatter::new();
818 let warnings = vec![
819 LintWarning {
820 line: 1,
821 column: 1,
822 end_line: 1,
823 end_column: 5,
824 rule_name: Some("MD001".to_string()),
825 message: "Warning".to_string(),
826 severity: Severity::Warning,
827 fix: None,
828 },
829 LintWarning {
830 line: 2,
831 column: 1,
832 end_line: 2,
833 end_column: 5,
834 rule_name: Some("MD032".to_string()),
835 message: "Error".to_string(),
836 severity: Severity::Error,
837 fix: None,
838 },
839 LintWarning {
840 line: 3,
841 column: 1,
842 end_line: 3,
843 end_column: 5,
844 rule_name: Some("MD013".to_string()),
845 message: "Warning".to_string(),
846 severity: Severity::Warning,
847 fix: None,
848 },
849 LintWarning {
850 line: 4,
851 column: 1,
852 end_line: 4,
853 end_column: 5,
854 rule_name: Some("MD032".to_string()),
855 message: "Error".to_string(),
856 severity: Severity::Error,
857 fix: None,
858 },
859 ];
860
861 let output = formatter.format_warnings(&warnings, "test.md");
862 let sarif: Value = serde_json::from_str(&output).unwrap();
863
864 let results = sarif["runs"][0]["results"].as_array().unwrap();
865 assert_eq!(results.len(), 4);
866
867 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();
875 let error_count = results.iter().filter(|r| r["level"] == "error").count();
876 assert_eq!(warning_count, 2);
877 assert_eq!(error_count, 2);
878 }
879
880 #[test]
881 fn test_rule_deduplication_preserves_severity() {
882 let warnings = vec![(
885 "test.md".to_string(),
886 vec![
887 LintWarning {
888 line: 1,
889 column: 1,
890 end_line: 1,
891 end_column: 5,
892 rule_name: Some("MD032".to_string()),
893 message: "First MD032 error".to_string(),
894 severity: Severity::Error,
895 fix: None,
896 },
897 LintWarning {
898 line: 5,
899 column: 1,
900 end_line: 5,
901 end_column: 5,
902 rule_name: Some("MD032".to_string()),
903 message: "Second MD032 error".to_string(),
904 severity: Severity::Error,
905 fix: None,
906 },
907 LintWarning {
908 line: 10,
909 column: 1,
910 end_line: 10,
911 end_column: 5,
912 rule_name: Some("MD032".to_string()),
913 message: "Third MD032 error".to_string(),
914 severity: Severity::Error,
915 fix: None,
916 },
917 ],
918 )];
919
920 let output = format_sarif_report(&warnings);
921 let sarif: Value = serde_json::from_str(&output).unwrap();
922
923 let results = sarif["runs"][0]["results"].as_array().unwrap();
925 assert_eq!(results.len(), 3);
926 assert!(results.iter().all(|r| r["level"] == "error"));
927 assert!(results.iter().all(|r| r["ruleId"] == "MD032"));
928
929 let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
931 assert_eq!(rules.len(), 1);
932 assert_eq!(rules[0]["id"], "MD032");
933 assert!(rules[0].get("defaultConfiguration").is_none());
935 }
936
937 #[test]
938 fn test_sarif_output_has_required_structure() {
939 let formatter = SarifFormatter::new();
941 let warnings = vec![LintWarning {
942 line: 1,
943 column: 1,
944 end_line: 1,
945 end_column: 5,
946 rule_name: Some("MD001".to_string()),
947 message: "Test".to_string(),
948 severity: Severity::Warning,
949 fix: None,
950 }];
951
952 let output = formatter.format_warnings(&warnings, "test.md");
953
954 let sarif: Value = serde_json::from_str(&output).expect("SARIF output must be valid JSON");
956
957 assert!(sarif.get("version").is_some(), "Must have version field");
959 assert!(sarif.get("$schema").is_some(), "Must have $schema field");
960 assert!(sarif.get("runs").is_some(), "Must have runs field");
961
962 let runs = sarif["runs"].as_array().expect("runs must be an array");
964 assert!(!runs.is_empty(), "Must have at least one run");
965
966 let run = &runs[0];
968 assert!(run.get("tool").is_some(), "Run must have tool field");
969 assert!(run.get("results").is_some(), "Run must have results field");
970
971 assert!(run["tool"].get("driver").is_some(), "Tool must have driver field");
973
974 assert!(run["results"].is_array(), "Results must be an array");
976
977 let results = run["results"].as_array().unwrap();
979 for result in results {
980 assert!(result.get("ruleId").is_some(), "Result must have ruleId");
981 assert!(result.get("level").is_some(), "Result must have level");
982 assert!(result.get("message").is_some(), "Result must have message");
983 assert!(result.get("locations").is_some(), "Result must have locations");
984
985 let level = result["level"].as_str().unwrap();
987 assert!(
988 matches!(level, "warning" | "error" | "note" | "none" | "open"),
989 "Level must be valid SARIF level, got: {level}"
990 );
991 }
992 }
993
994 #[test]
995 fn test_default_configuration_removed() {
996 let warnings = vec![(
999 "test.md".to_string(),
1000 vec![
1001 LintWarning {
1002 line: 1,
1003 column: 1,
1004 end_line: 1,
1005 end_column: 5,
1006 rule_name: Some("MD001".to_string()),
1007 message: "Warning".to_string(),
1008 severity: Severity::Warning,
1009 fix: None,
1010 },
1011 LintWarning {
1012 line: 2,
1013 column: 1,
1014 end_line: 2,
1015 end_column: 5,
1016 rule_name: Some("MD032".to_string()),
1017 message: "Error".to_string(),
1018 severity: Severity::Error,
1019 fix: None,
1020 },
1021 ],
1022 )];
1023
1024 let output = format_sarif_report(&warnings);
1025 let sarif: Value = serde_json::from_str(&output).unwrap();
1026
1027 let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
1028 assert_eq!(rules.len(), 2);
1029
1030 for rule in rules {
1032 assert!(
1033 rule.get("defaultConfiguration").is_none(),
1034 "Rule {} should not have defaultConfiguration (it's instance-specific, not rule-specific)",
1035 rule["id"]
1036 );
1037 }
1038 }
1039
1040 #[test]
1041 fn test_unknown_rule_with_error_severity() {
1042 let formatter = SarifFormatter::new();
1044 let warnings = vec![LintWarning {
1045 line: 1,
1046 column: 1,
1047 end_line: 1,
1048 end_column: 5,
1049 rule_name: None,
1050 message: "Unknown error".to_string(),
1051 severity: Severity::Error,
1052 fix: None,
1053 }];
1054
1055 let output = formatter.format_warnings(&warnings, "test.md");
1056 let sarif: Value = serde_json::from_str(&output).unwrap();
1057
1058 let results = sarif["runs"][0]["results"].as_array().unwrap();
1059 assert_eq!(results.len(), 1);
1060 assert_eq!(results[0]["ruleId"], "unknown");
1061 assert_eq!(results[0]["level"], "error"); }
1063
1064 #[test]
1065 fn test_exhaustive_severity_mapping() {
1066 let formatter = SarifFormatter::new();
1069
1070 let all_severities = vec![(Severity::Warning, "warning"), (Severity::Error, "error")];
1072
1073 for (severity, expected_level) in all_severities {
1074 let warnings = vec![LintWarning {
1075 line: 1,
1076 column: 1,
1077 end_line: 1,
1078 end_column: 5,
1079 rule_name: Some("TEST".to_string()),
1080 message: format!("Test {severity:?}"),
1081 severity,
1082 fix: None,
1083 }];
1084
1085 let output = formatter.format_warnings(&warnings, "test.md");
1086 let sarif: Value = serde_json::from_str(&output).unwrap();
1087
1088 let results = sarif["runs"][0]["results"].as_array().unwrap();
1089 assert_eq!(
1090 results[0]["level"], expected_level,
1091 "Severity::{severity:?} should map to SARIF level '{expected_level}'"
1092 );
1093 }
1094 }
1095}