Skip to main content

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