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