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 let mut results = Vec::new();
126 let mut rules = std::collections::HashMap::new();
127
128 for (file_path, warnings) in all_warnings {
129 for warning in warnings {
130 let rule_id = warning.rule_name.as_deref().unwrap_or("unknown");
131
132 rules.entry(rule_id).or_insert_with(|| {
133 json!({
134 "id": rule_id,
135 "name": rule_id,
136 "shortDescription": {
137 "text": format!("Markdown rule {}", rule_id)
138 },
139 "fullDescription": {
140 "text": format!("Markdown linting rule {}", rule_id)
141 }
142 })
143 });
144
145 let level = match warning.severity {
146 crate::rule::Severity::Error => "error",
147 crate::rule::Severity::Warning => "warning",
148 crate::rule::Severity::Info => "note",
149 };
150 let result = json!({
151 "ruleId": rule_id,
152 "level": level,
153 "message": {
154 "text": warning.message
155 },
156 "locations": [{
157 "physicalLocation": {
158 "artifactLocation": {
159 "uri": artifact_uri(file_path)
160 },
161 "region": {
162 "startLine": warning.line,
163 "startColumn": warning.column
164 }
165 }
166 }]
167 });
168
169 results.push(result);
170 }
171 }
172
173 let sarif_doc = json!({
174 "$schema": SARIF_SCHEMA_URI,
175 "version": "2.1.0",
176 "runs": [{
177 "tool": {
178 "driver": {
179 "name": "rumdl",
180 "version": env!("CARGO_PKG_VERSION"),
181 "informationUri": "https://github.com/rvben/rumdl",
182 "rules": rules.values().cloned().collect::<Vec<_>>()
183 }
184 },
185 "results": results
186 }]
187 });
188
189 serde_json::to_string_pretty(&sarif_doc).unwrap_or_else(|_| r#"{"version":"2.1.0","runs":[]}"#.to_string())
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use crate::config::MarkdownFlavor;
196 use crate::lint_context::LintContext;
197 use crate::rule::{Fix, Rule, Severity};
198 use crate::rules::MD032BlanksAroundLists;
199 use serde_json::Value;
200 use std::path::PathBuf;
201
202 #[test]
203 fn test_sarif_formatter_default() {
204 let _formatter = SarifFormatter;
205 }
207
208 #[test]
209 fn test_sarif_formatter_new() {
210 let _formatter = SarifFormatter::new();
211 }
213
214 #[test]
215 fn test_format_warnings_empty() {
216 let formatter = SarifFormatter::new();
217 let warnings = vec![];
218 let output = formatter.format_warnings(&warnings, "test.md");
219
220 let sarif: Value = serde_json::from_str(&output).unwrap();
221 assert_eq!(sarif["version"], "2.1.0");
222 assert_eq!(sarif["$schema"], SARIF_SCHEMA_URI);
223 assert_eq!(sarif["runs"][0]["results"].as_array().unwrap().len(), 0);
224 }
225
226 #[test]
227 fn test_format_single_warning() {
228 let formatter = SarifFormatter::new();
229 let warnings = vec![LintWarning {
230 line: 10,
231 column: 5,
232 end_line: 10,
233 end_column: 15,
234 rule_name: Some("MD001".to_string()),
235 message: "Heading levels should only increment by one level at a time".to_string(),
236 severity: Severity::Warning,
237 fix: None,
238 }];
239
240 let output = formatter.format_warnings(&warnings, "README.md");
241 let sarif: Value = serde_json::from_str(&output).unwrap();
242
243 let results = sarif["runs"][0]["results"].as_array().unwrap();
244 assert_eq!(results.len(), 1);
245
246 let result = &results[0];
247 assert_eq!(result["ruleId"], "MD001");
248 assert_eq!(result["level"], "warning");
249 assert_eq!(
250 result["message"]["text"],
251 "Heading levels should only increment by one level at a time"
252 );
253 assert_eq!(
254 result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
255 "README.md"
256 );
257 assert_eq!(result["locations"][0]["physicalLocation"]["region"]["startLine"], 10);
258 assert_eq!(result["locations"][0]["physicalLocation"]["region"]["startColumn"], 5);
259 }
260
261 #[test]
262 fn test_format_single_warning_with_fix() {
263 let formatter = SarifFormatter::new();
264 let warnings = vec![LintWarning {
265 line: 10,
266 column: 5,
267 end_line: 10,
268 end_column: 15,
269 rule_name: Some("MD001".to_string()),
270 message: "Heading levels should only increment by one level at a time".to_string(),
271 severity: Severity::Warning,
272 fix: Some(Fix::new(100..110, "## Heading".to_string())),
273 }];
274
275 let output = formatter.format_warnings(&warnings, "README.md");
276 let sarif: Value = serde_json::from_str(&output).unwrap();
277
278 let results = sarif["runs"][0]["results"].as_array().unwrap();
280 assert_eq!(results.len(), 1);
281 assert_eq!(results[0]["ruleId"], "MD001");
282 }
283
284 #[test]
285 fn test_format_multiple_warnings() {
286 let formatter = SarifFormatter::new();
287 let warnings = vec![
288 LintWarning {
289 line: 5,
290 column: 1,
291 end_line: 5,
292 end_column: 10,
293 rule_name: Some("MD001".to_string()),
294 message: "First warning".to_string(),
295 severity: Severity::Warning,
296 fix: None,
297 },
298 LintWarning {
299 line: 10,
300 column: 3,
301 end_line: 10,
302 end_column: 20,
303 rule_name: Some("MD013".to_string()),
304 message: "Second warning".to_string(),
305 severity: Severity::Error,
306 fix: None,
307 },
308 ];
309
310 let output = formatter.format_warnings(&warnings, "test.md");
311 let sarif: Value = serde_json::from_str(&output).unwrap();
312
313 let results = sarif["runs"][0]["results"].as_array().unwrap();
314 assert_eq!(results.len(), 2);
315 assert_eq!(results[0]["ruleId"], "MD001");
316 assert_eq!(results[0]["level"], "warning");
317 assert_eq!(results[0]["locations"][0]["physicalLocation"]["region"]["startLine"], 5);
318 assert_eq!(results[1]["ruleId"], "MD013");
319 assert_eq!(results[1]["level"], "error");
320 assert_eq!(
321 results[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
322 10
323 );
324 }
325
326 #[test]
327 fn test_format_warning_unknown_rule() {
328 let formatter = SarifFormatter::new();
329 let warnings = vec![LintWarning {
330 line: 1,
331 column: 1,
332 end_line: 1,
333 end_column: 5,
334 rule_name: None,
335 message: "Unknown rule warning".to_string(),
336 severity: Severity::Warning,
337 fix: None,
338 }];
339
340 let output = formatter.format_warnings(&warnings, "file.md");
341 let sarif: Value = serde_json::from_str(&output).unwrap();
342
343 let results = sarif["runs"][0]["results"].as_array().unwrap();
344 assert_eq!(results[0]["ruleId"], "unknown");
345 }
346
347 #[test]
348 fn test_tool_information() {
349 let formatter = SarifFormatter::new();
350 let warnings = vec![];
351 let output = formatter.format_warnings(&warnings, "test.md");
352
353 let sarif: Value = serde_json::from_str(&output).unwrap();
354 let driver = &sarif["runs"][0]["tool"]["driver"];
355
356 assert_eq!(driver["name"], "rumdl");
357 assert_eq!(driver["version"], env!("CARGO_PKG_VERSION"));
358 assert_eq!(driver["informationUri"], "https://github.com/rvben/rumdl");
359 }
360
361 #[test]
362 fn test_sarif_report_empty() {
363 let warnings = vec![];
364 let output = format_sarif_report(&warnings);
365
366 let sarif: Value = serde_json::from_str(&output).unwrap();
367 assert_eq!(sarif["version"], "2.1.0");
368 assert_eq!(sarif["runs"][0]["results"].as_array().unwrap().len(), 0);
369 }
370
371 #[test]
372 fn test_sarif_report_single_file() {
373 let warnings = vec![(
374 "test.md".to_string(),
375 vec![LintWarning {
376 line: 10,
377 column: 5,
378 end_line: 10,
379 end_column: 15,
380 rule_name: Some("MD001".to_string()),
381 message: "Test warning".to_string(),
382 severity: Severity::Warning,
383 fix: None,
384 }],
385 )];
386
387 let output = format_sarif_report(&warnings);
388 let sarif: Value = serde_json::from_str(&output).unwrap();
389
390 let results = sarif["runs"][0]["results"].as_array().unwrap();
391 assert_eq!(results.len(), 1);
392 assert_eq!(
393 results[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
394 "test.md"
395 );
396
397 let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
399 assert_eq!(rules.len(), 1);
400 assert_eq!(rules[0]["id"], "MD001");
401 }
402
403 #[test]
404 fn test_sarif_report_multiple_files() {
405 let warnings = vec![
406 (
407 "file1.md".to_string(),
408 vec![LintWarning {
409 line: 1,
410 column: 1,
411 end_line: 1,
412 end_column: 5,
413 rule_name: Some("MD001".to_string()),
414 message: "Warning in file 1".to_string(),
415 severity: Severity::Warning,
416 fix: None,
417 }],
418 ),
419 (
420 "file2.md".to_string(),
421 vec![
422 LintWarning {
423 line: 5,
424 column: 1,
425 end_line: 5,
426 end_column: 10,
427 rule_name: Some("MD013".to_string()),
428 message: "Warning 1 in file 2".to_string(),
429 severity: Severity::Warning,
430 fix: None,
431 },
432 LintWarning {
433 line: 10,
434 column: 1,
435 end_line: 10,
436 end_column: 10,
437 rule_name: Some("MD022".to_string()),
438 message: "Warning 2 in file 2".to_string(),
439 severity: Severity::Error,
440 fix: None,
441 },
442 ],
443 ),
444 ];
445
446 let output = format_sarif_report(&warnings);
447 let sarif: Value = serde_json::from_str(&output).unwrap();
448
449 let results = sarif["runs"][0]["results"].as_array().unwrap();
450 assert_eq!(results.len(), 3);
451
452 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();
459 assert_eq!(rules.len(), 3);
460
461 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
462 assert!(rule_ids.contains(&"MD001"));
463 assert!(rule_ids.contains(&"MD013"));
464 assert!(rule_ids.contains(&"MD022"));
465 }
466
467 #[test]
468 fn test_rule_deduplication() {
469 let warnings = vec![(
470 "test.md".to_string(),
471 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: "First MD001".to_string(),
479 severity: Severity::Warning,
480 fix: None,
481 },
482 LintWarning {
483 line: 10,
484 column: 1,
485 end_line: 10,
486 end_column: 5,
487 rule_name: Some("MD001".to_string()),
488 message: "Second MD001".to_string(),
489 severity: Severity::Warning,
490 fix: None,
491 },
492 ],
493 )];
494
495 let output = format_sarif_report(&warnings);
496 let sarif: Value = serde_json::from_str(&output).unwrap();
497
498 let results = sarif["runs"][0]["results"].as_array().unwrap();
500 assert_eq!(results.len(), 2);
501
502 let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
503 assert_eq!(rules.len(), 1);
504 assert_eq!(rules[0]["id"], "MD001");
505 }
506
507 #[test]
508 fn test_severity_mapping() {
509 let formatter = SarifFormatter::new();
510
511 let warnings = vec![
512 LintWarning {
513 line: 1,
514 column: 1,
515 end_line: 1,
516 end_column: 5,
517 rule_name: Some("MD001".to_string()),
518 message: "Warning severity".to_string(),
519 severity: Severity::Warning,
520 fix: None,
521 },
522 LintWarning {
523 line: 2,
524 column: 1,
525 end_line: 2,
526 end_column: 5,
527 rule_name: Some("MD032".to_string()),
528 message: "Error severity".to_string(),
529 severity: Severity::Error,
530 fix: None,
531 },
532 ];
533
534 let output = formatter.format_warnings(&warnings, "test.md");
535 let sarif: Value = serde_json::from_str(&output).unwrap();
536
537 let results = sarif["runs"][0]["results"].as_array().unwrap();
538 assert_eq!(results[0]["level"], "warning"); assert_eq!(results[1]["level"], "error"); }
541
542 #[test]
543 fn test_sarif_report_severity_mapping() {
544 let warnings = vec![
545 (
546 "file1.md".to_string(),
547 vec![LintWarning {
548 line: 1,
549 column: 1,
550 end_line: 1,
551 end_column: 5,
552 rule_name: Some("MD001".to_string()),
553 message: "Warning".to_string(),
554 severity: Severity::Warning,
555 fix: None,
556 }],
557 ),
558 (
559 "file2.md".to_string(),
560 vec![LintWarning {
561 line: 5,
562 column: 1,
563 end_line: 5,
564 end_column: 10,
565 rule_name: Some("MD032".to_string()),
566 message: "Error".to_string(),
567 severity: Severity::Error,
568 fix: None,
569 }],
570 ),
571 ];
572
573 let output = format_sarif_report(&warnings);
574 let sarif: Value = serde_json::from_str(&output).unwrap();
575
576 let results = sarif["runs"][0]["results"].as_array().unwrap();
577 assert_eq!(results.len(), 2);
578 assert_eq!(results[0]["level"], "warning");
579 assert_eq!(results[1]["level"], "error");
580 }
581
582 #[test]
583 fn test_special_characters_in_message() {
584 let formatter = SarifFormatter::new();
585 let warnings = vec![LintWarning {
586 line: 1,
587 column: 1,
588 end_line: 1,
589 end_column: 5,
590 rule_name: Some("MD001".to_string()),
591 message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
592 severity: Severity::Warning,
593 fix: None,
594 }];
595
596 let output = formatter.format_warnings(&warnings, "test.md");
597 let sarif: Value = serde_json::from_str(&output).unwrap();
598
599 let results = sarif["runs"][0]["results"].as_array().unwrap();
600 assert_eq!(
602 results[0]["message"]["text"],
603 "Warning with \"quotes\" and 'apostrophes' and \n newline"
604 );
605 }
606
607 #[test]
608 fn test_special_characters_in_file_path() {
609 let formatter = SarifFormatter::new();
610 let warnings = vec![LintWarning {
611 line: 1,
612 column: 1,
613 end_line: 1,
614 end_column: 5,
615 rule_name: Some("MD001".to_string()),
616 message: "Test".to_string(),
617 severity: Severity::Warning,
618 fix: None,
619 }];
620
621 let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
622 let sarif: Value = serde_json::from_str(&output).unwrap();
623
624 let results = sarif["runs"][0]["results"].as_array().unwrap();
625 assert_eq!(
626 results[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
627 "path/with%20spaces/and-dashes.md"
628 );
629 }
630
631 #[test]
632 fn test_windows_verbatim_paths_become_drive_and_share_uris() {
633 let formatter = SarifFormatter::new();
634 let warnings = vec![LintWarning {
635 line: 1,
636 column: 1,
637 end_line: 1,
638 end_column: 5,
639 rule_name: Some("MD001".to_string()),
640 message: "Test".to_string(),
641 severity: Severity::Warning,
642 fix: None,
643 }];
644
645 for (display_path, expected_uri) in [
648 ("//?/C:/Users/dev/docs/guide.md", "file:///C:/Users/dev/docs/guide.md"),
649 (
650 "//?/UNC/server/share/docs/guide.md",
651 "file://server/share/docs/guide.md",
652 ),
653 ("C:/Users/dev/docs/guide.md", "file:///C:/Users/dev/docs/guide.md"),
655 ("//server/share/docs/guide.md", "file://server/share/docs/guide.md"),
656 ] {
657 let output = formatter.format_warnings(&warnings, display_path);
658 let sarif: Value = serde_json::from_str(&output).unwrap();
659 let uri = sarif["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
660 .as_str()
661 .unwrap();
662 assert_eq!(uri, expected_uri, "URI for {display_path}");
663 let parsed = url::Url::parse(uri).unwrap_or_else(|error| panic!("{display_path}: {error}: {uri}"));
664 assert_eq!(parsed.scheme(), "file", "{display_path}");
665 }
666 }
667
668 #[test]
669 fn test_sarif_schema_version() {
670 let formatter = SarifFormatter::new();
671 let warnings = vec![];
672 let output = formatter.format_warnings(&warnings, "test.md");
673
674 let sarif: Value = serde_json::from_str(&output).unwrap();
675 assert_eq!(sarif["$schema"], SARIF_SCHEMA_URI);
676 assert_eq!(sarif["version"], "2.1.0");
677 }
678
679 #[test]
682 fn test_md032_integration_produces_warning_level() {
683 let content = "# Heading\n- List item without blank line before";
685 let rule = MD032BlanksAroundLists::default();
686 let ctx = LintContext::new(content, MarkdownFlavor::Standard, Some(PathBuf::from("test.md")));
687 let warnings = rule.check(&ctx).expect("MD032 check should succeed");
688
689 assert!(!warnings.is_empty(), "MD032 should flag list without blank line");
691
692 let formatter = SarifFormatter::new();
693 let output = formatter.format_warnings(&warnings, "test.md");
694 let sarif: Value = serde_json::from_str(&output).unwrap();
695
696 let results = sarif["runs"][0]["results"].as_array().unwrap();
697 assert!(
699 results.iter().any(|r| r["level"] == "warning"),
700 "MD032 violations should produce 'warning' level in SARIF output"
701 );
702 assert!(
704 results.iter().any(|r| r["ruleId"] == "MD032"),
705 "Results should include MD032 rule"
706 );
707 }
708
709 #[test]
710 fn test_all_warnings_no_errors() {
711 let formatter = SarifFormatter::new();
713 let warnings = vec![
714 LintWarning {
715 line: 1,
716 column: 1,
717 end_line: 1,
718 end_column: 5,
719 rule_name: Some("MD001".to_string()),
720 message: "First warning".to_string(),
721 severity: Severity::Warning,
722 fix: None,
723 },
724 LintWarning {
725 line: 2,
726 column: 1,
727 end_line: 2,
728 end_column: 5,
729 rule_name: Some("MD013".to_string()),
730 message: "Second warning".to_string(),
731 severity: Severity::Warning,
732 fix: None,
733 },
734 LintWarning {
735 line: 3,
736 column: 1,
737 end_line: 3,
738 end_column: 5,
739 rule_name: Some("MD041".to_string()),
740 message: "Third warning".to_string(),
741 severity: Severity::Warning,
742 fix: None,
743 },
744 ];
745
746 let output = formatter.format_warnings(&warnings, "test.md");
747 let sarif: Value = serde_json::from_str(&output).unwrap();
748
749 let results = sarif["runs"][0]["results"].as_array().unwrap();
750 assert_eq!(results.len(), 3);
751 assert!(results.iter().all(|r| r["level"] == "warning"));
753 assert!(!results.iter().any(|r| r["level"] == "error"));
755 }
756
757 #[test]
758 fn test_all_errors_no_warnings() {
759 let formatter = SarifFormatter::new();
761 let warnings = vec![
762 LintWarning {
763 line: 1,
764 column: 1,
765 end_line: 1,
766 end_column: 5,
767 rule_name: Some("MD032".to_string()),
768 message: "First error".to_string(),
769 severity: Severity::Error,
770 fix: None,
771 },
772 LintWarning {
773 line: 2,
774 column: 1,
775 end_line: 2,
776 end_column: 5,
777 rule_name: Some("MD032".to_string()),
778 message: "Second error".to_string(),
779 severity: Severity::Error,
780 fix: None,
781 },
782 ];
783
784 let output = formatter.format_warnings(&warnings, "test.md");
785 let sarif: Value = serde_json::from_str(&output).unwrap();
786
787 let results = sarif["runs"][0]["results"].as_array().unwrap();
788 assert_eq!(results.len(), 2);
789 assert!(results.iter().all(|r| r["level"] == "error"));
791 assert!(!results.iter().any(|r| r["level"] == "warning"));
793 }
794
795 #[test]
796 fn test_mixed_severities_same_file() {
797 let formatter = SarifFormatter::new();
799 let warnings = vec![
800 LintWarning {
801 line: 1,
802 column: 1,
803 end_line: 1,
804 end_column: 5,
805 rule_name: Some("MD001".to_string()),
806 message: "Warning".to_string(),
807 severity: Severity::Warning,
808 fix: None,
809 },
810 LintWarning {
811 line: 2,
812 column: 1,
813 end_line: 2,
814 end_column: 5,
815 rule_name: Some("MD032".to_string()),
816 message: "Error".to_string(),
817 severity: Severity::Error,
818 fix: None,
819 },
820 LintWarning {
821 line: 3,
822 column: 1,
823 end_line: 3,
824 end_column: 5,
825 rule_name: Some("MD013".to_string()),
826 message: "Warning".to_string(),
827 severity: Severity::Warning,
828 fix: None,
829 },
830 LintWarning {
831 line: 4,
832 column: 1,
833 end_line: 4,
834 end_column: 5,
835 rule_name: Some("MD032".to_string()),
836 message: "Error".to_string(),
837 severity: Severity::Error,
838 fix: None,
839 },
840 ];
841
842 let output = formatter.format_warnings(&warnings, "test.md");
843 let sarif: Value = serde_json::from_str(&output).unwrap();
844
845 let results = sarif["runs"][0]["results"].as_array().unwrap();
846 assert_eq!(results.len(), 4);
847
848 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();
856 let error_count = results.iter().filter(|r| r["level"] == "error").count();
857 assert_eq!(warning_count, 2);
858 assert_eq!(error_count, 2);
859 }
860
861 #[test]
862 fn test_rule_deduplication_preserves_severity() {
863 let warnings = vec![(
866 "test.md".to_string(),
867 vec![
868 LintWarning {
869 line: 1,
870 column: 1,
871 end_line: 1,
872 end_column: 5,
873 rule_name: Some("MD032".to_string()),
874 message: "First MD032 error".to_string(),
875 severity: Severity::Error,
876 fix: None,
877 },
878 LintWarning {
879 line: 5,
880 column: 1,
881 end_line: 5,
882 end_column: 5,
883 rule_name: Some("MD032".to_string()),
884 message: "Second MD032 error".to_string(),
885 severity: Severity::Error,
886 fix: None,
887 },
888 LintWarning {
889 line: 10,
890 column: 1,
891 end_line: 10,
892 end_column: 5,
893 rule_name: Some("MD032".to_string()),
894 message: "Third MD032 error".to_string(),
895 severity: Severity::Error,
896 fix: None,
897 },
898 ],
899 )];
900
901 let output = format_sarif_report(&warnings);
902 let sarif: Value = serde_json::from_str(&output).unwrap();
903
904 let results = sarif["runs"][0]["results"].as_array().unwrap();
906 assert_eq!(results.len(), 3);
907 assert!(results.iter().all(|r| r["level"] == "error"));
908 assert!(results.iter().all(|r| r["ruleId"] == "MD032"));
909
910 let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
912 assert_eq!(rules.len(), 1);
913 assert_eq!(rules[0]["id"], "MD032");
914 assert!(rules[0].get("defaultConfiguration").is_none());
916 }
917
918 #[test]
919 fn test_sarif_output_has_required_structure() {
920 let formatter = SarifFormatter::new();
922 let warnings = vec![LintWarning {
923 line: 1,
924 column: 1,
925 end_line: 1,
926 end_column: 5,
927 rule_name: Some("MD001".to_string()),
928 message: "Test".to_string(),
929 severity: Severity::Warning,
930 fix: None,
931 }];
932
933 let output = formatter.format_warnings(&warnings, "test.md");
934
935 let sarif: Value = serde_json::from_str(&output).expect("SARIF output must be valid JSON");
937
938 assert!(sarif.get("version").is_some(), "Must have version field");
940 assert!(sarif.get("$schema").is_some(), "Must have $schema field");
941 assert!(sarif.get("runs").is_some(), "Must have runs field");
942
943 let runs = sarif["runs"].as_array().expect("runs must be an array");
945 assert!(!runs.is_empty(), "Must have at least one run");
946
947 let run = &runs[0];
949 assert!(run.get("tool").is_some(), "Run must have tool field");
950 assert!(run.get("results").is_some(), "Run must have results field");
951
952 assert!(run["tool"].get("driver").is_some(), "Tool must have driver field");
954
955 assert!(run["results"].is_array(), "Results must be an array");
957
958 let results = run["results"].as_array().unwrap();
960 for result in results {
961 assert!(result.get("ruleId").is_some(), "Result must have ruleId");
962 assert!(result.get("level").is_some(), "Result must have level");
963 assert!(result.get("message").is_some(), "Result must have message");
964 assert!(result.get("locations").is_some(), "Result must have locations");
965
966 let level = result["level"].as_str().unwrap();
968 assert!(
969 matches!(level, "warning" | "error" | "note" | "none" | "open"),
970 "Level must be valid SARIF level, got: {level}"
971 );
972 }
973 }
974
975 #[test]
976 fn test_default_configuration_removed() {
977 let warnings = vec![(
980 "test.md".to_string(),
981 vec![
982 LintWarning {
983 line: 1,
984 column: 1,
985 end_line: 1,
986 end_column: 5,
987 rule_name: Some("MD001".to_string()),
988 message: "Warning".to_string(),
989 severity: Severity::Warning,
990 fix: None,
991 },
992 LintWarning {
993 line: 2,
994 column: 1,
995 end_line: 2,
996 end_column: 5,
997 rule_name: Some("MD032".to_string()),
998 message: "Error".to_string(),
999 severity: Severity::Error,
1000 fix: None,
1001 },
1002 ],
1003 )];
1004
1005 let output = format_sarif_report(&warnings);
1006 let sarif: Value = serde_json::from_str(&output).unwrap();
1007
1008 let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
1009 assert_eq!(rules.len(), 2);
1010
1011 for rule in rules {
1013 assert!(
1014 rule.get("defaultConfiguration").is_none(),
1015 "Rule {} should not have defaultConfiguration (it's instance-specific, not rule-specific)",
1016 rule["id"]
1017 );
1018 }
1019 }
1020
1021 #[test]
1022 fn test_unknown_rule_with_error_severity() {
1023 let formatter = SarifFormatter::new();
1025 let warnings = vec![LintWarning {
1026 line: 1,
1027 column: 1,
1028 end_line: 1,
1029 end_column: 5,
1030 rule_name: None,
1031 message: "Unknown error".to_string(),
1032 severity: Severity::Error,
1033 fix: None,
1034 }];
1035
1036 let output = formatter.format_warnings(&warnings, "test.md");
1037 let sarif: Value = serde_json::from_str(&output).unwrap();
1038
1039 let results = sarif["runs"][0]["results"].as_array().unwrap();
1040 assert_eq!(results.len(), 1);
1041 assert_eq!(results[0]["ruleId"], "unknown");
1042 assert_eq!(results[0]["level"], "error"); }
1044
1045 #[test]
1046 fn test_exhaustive_severity_mapping() {
1047 let formatter = SarifFormatter::new();
1050
1051 let all_severities = vec![(Severity::Warning, "warning"), (Severity::Error, "error")];
1053
1054 for (severity, expected_level) in all_severities {
1055 let warnings = vec![LintWarning {
1056 line: 1,
1057 column: 1,
1058 end_line: 1,
1059 end_column: 5,
1060 rule_name: Some("TEST".to_string()),
1061 message: format!("Test {severity:?}"),
1062 severity,
1063 fix: None,
1064 }];
1065
1066 let output = formatter.format_warnings(&warnings, "test.md");
1067 let sarif: Value = serde_json::from_str(&output).unwrap();
1068
1069 let results = sarif["runs"][0]["results"].as_array().unwrap();
1070 assert_eq!(
1071 results[0]["level"], expected_level,
1072 "Severity::{severity:?} should map to SARIF level '{expected_level}'"
1073 );
1074 }
1075 }
1076}