rumdl_lib/output/formatters/
sarif.rs

1//! SARIF 2.1.0 output format
2
3use crate::output::OutputFormatter;
4use crate::rule::LintWarning;
5use serde_json::json;
6
7/// SARIF (Static Analysis Results Interchange Format) formatter
8pub 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        // Format warnings for a single file as a minimal SARIF document
25        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
74/// Format all warnings as SARIF 2.1.0 report
75pub 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    // Collect all results and build rule index
80    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            // Add rule to index if not already present
85            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    // Build the complete SARIF document
127    let sarif_doc = json!({
128        "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
129        "version": "2.1.0",
130        "runs": [{
131            "tool": {
132                "driver": {
133                    "name": "rumdl",
134                    "version": env!("CARGO_PKG_VERSION"),
135                    "informationUri": "https://github.com/rvben/rumdl",
136                    "rules": rules.values().cloned().collect::<Vec<_>>()
137                }
138            },
139            "results": results
140        }]
141    });
142
143    serde_json::to_string_pretty(&sarif_doc).unwrap_or_else(|_| r#"{"version":"2.1.0","runs":[]}"#.to_string())
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::config::MarkdownFlavor;
150    use crate::lint_context::LintContext;
151    use crate::rule::{Fix, Rule, Severity};
152    use crate::rules::MD032BlanksAroundLists;
153    use serde_json::Value;
154    use std::path::PathBuf;
155
156    #[test]
157    fn test_sarif_formatter_default() {
158        let _formatter = SarifFormatter;
159        // No fields to test, just ensure it constructs
160    }
161
162    #[test]
163    fn test_sarif_formatter_new() {
164        let _formatter = SarifFormatter::new();
165        // No fields to test, just ensure it constructs
166    }
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        // SARIF format doesn't indicate fixable issues in the basic format
239        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        // Check that rule is defined in driver
358        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        // Check severity mapping
413        assert_eq!(results[0]["level"], "warning"); // MD001 - Warning
414        assert_eq!(results[1]["level"], "warning"); // MD013 - Warning
415        assert_eq!(results[2]["level"], "error"); // MD022 - Error
416
417        // Check that all rules are defined
418        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        // Should have 2 results but only 1 rule
459        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"); // Warning → "warning"
499        assert_eq!(results[1]["level"], "error"); // Error → "error"
500    }
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        // JSON should properly handle special characters
561        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    // ===== Comprehensive tests for full coverage =====
606
607    #[test]
608    fn test_md032_integration_produces_warning_level() {
609        // Test with actual MD032 rule that produces Warning severity warnings
610        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        // MD032 should produce at least one warning-level warning
616        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        // Verify at least one result has warning level (MD032 uses Severity::Warning)
624        assert!(
625            results.iter().any(|r| r["level"] == "warning"),
626            "MD032 violations should produce 'warning' level in SARIF output"
627        );
628        // Verify rule ID is MD032
629        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        // Edge case: File with only Warning severity (no Error severity)
638        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        // All should be "warning" level
678        assert!(results.iter().all(|r| r["level"] == "warning"));
679        // None should be "error" level
680        assert!(!results.iter().any(|r| r["level"] == "error"));
681    }
682
683    #[test]
684    fn test_all_errors_no_warnings() {
685        // Edge case: File with only Error severity (no Warning severity)
686        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        // All should be "error" level
716        assert!(results.iter().all(|r| r["level"] == "error"));
717        // None should be "warning" level
718        assert!(!results.iter().any(|r| r["level"] == "warning"));
719    }
720
721    #[test]
722    fn test_mixed_severities_same_file() {
723        // Edge case: Same file with both Warning and Error severities interleaved
724        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        // Verify exact mapping for each result
775        assert_eq!(results[0]["level"], "warning"); // Line 1
776        assert_eq!(results[1]["level"], "error"); // Line 2
777        assert_eq!(results[2]["level"], "warning"); // Line 3
778        assert_eq!(results[3]["level"], "error"); // Line 4
779
780        // Count severities
781        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        // Test that rule deduplication doesn't lose severity information
790        // Same rule (MD032) appears multiple times in same file with Error severity
791        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        // Should have 3 results, all with error level
831        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        // Should have only 1 rule definition (deduplicated)
837        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        // Verify defaultConfiguration was removed (should not be present)
841        assert!(rules[0].get("defaultConfiguration").is_none());
842    }
843
844    #[test]
845    fn test_sarif_output_valid_json_schema() {
846        // Verify SARIF output is valid JSON and has required top-level fields
847        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        // Must be valid JSON
862        let sarif: Value = serde_json::from_str(&output).expect("SARIF output must be valid JSON");
863
864        // SARIF 2.1.0 required fields at root level
865        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        // Runs must be an array with at least one run
870        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        // Each run must have tool and results
874        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        // Tool must have driver
879        assert!(run["tool"].get("driver").is_some(), "Tool must have driver field");
880
881        // Results must be an array
882        assert!(run["results"].is_array(), "Results must be an array");
883
884        // Each result must have required fields
885        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            // Level must be a valid SARIF level
893            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        // Verify that defaultConfiguration is no longer present in rule metadata
904        // (it was semantically incorrect since severity is instance-specific)
905        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        // Verify defaultConfiguration is not present in any rule
938        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        // Edge case: Unknown rule (None) with Error severity
950        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"); // Should still map Error → "error"
969    }
970
971    #[test]
972    fn test_exhaustive_severity_mapping() {
973        // Document all Severity enum variants and their SARIF mappings
974        // This test will break if new Severity variants are added without updating SARIF mapper
975        let formatter = SarifFormatter::new();
976
977        // Test all current Severity variants
978        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}