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