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