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.unwrap_or("unknown");
29                json!({
30                    "ruleId": rule_id,
31                    "level": "warning",
32                    "message": {
33                        "text": warning.message
34                    },
35                    "locations": [{
36                        "physicalLocation": {
37                            "artifactLocation": {
38                                "uri": file_path
39                            },
40                            "region": {
41                                "startLine": warning.line,
42                                "startColumn": warning.column
43                            }
44                        }
45                    }]
46                })
47            })
48            .collect();
49
50        let sarif_doc = json!({
51            "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
52            "version": "2.1.0",
53            "runs": [{
54                "tool": {
55                    "driver": {
56                        "name": "rumdl",
57                        "version": env!("CARGO_PKG_VERSION"),
58                        "informationUri": "https://github.com/rvben/rumdl"
59                    }
60                },
61                "results": results
62            }]
63        });
64
65        serde_json::to_string_pretty(&sarif_doc).unwrap_or_else(|_| r#"{"version":"2.1.0","runs":[]}"#.to_string())
66    }
67}
68
69/// Format all warnings as SARIF 2.1.0 report
70pub fn format_sarif_report(all_warnings: &[(String, Vec<LintWarning>)]) -> String {
71    let mut results = Vec::new();
72    let mut rules = std::collections::HashMap::new();
73
74    // Collect all results and build rule index
75    for (file_path, warnings) in all_warnings {
76        for warning in warnings {
77            let rule_id = warning.rule_name.unwrap_or("unknown");
78
79            // Add rule to index if not already present
80            rules.entry(rule_id).or_insert_with(|| {
81                json!({
82                    "id": rule_id,
83                    "name": rule_id,
84                    "shortDescription": {
85                        "text": format!("Markdown rule {}", rule_id)
86                    },
87                    "fullDescription": {
88                        "text": format!("Markdown linting rule {}", rule_id)
89                    },
90                    "defaultConfiguration": {
91                        "level": "warning"
92                    }
93                })
94            });
95
96            let result = json!({
97                "ruleId": rule_id,
98                "level": "warning",
99                "message": {
100                    "text": warning.message
101                },
102                "locations": [{
103                    "physicalLocation": {
104                        "artifactLocation": {
105                            "uri": file_path
106                        },
107                        "region": {
108                            "startLine": warning.line,
109                            "startColumn": warning.column
110                        }
111                    }
112                }]
113            });
114
115            results.push(result);
116        }
117    }
118
119    // Build the complete SARIF document
120    let sarif_doc = json!({
121        "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
122        "version": "2.1.0",
123        "runs": [{
124            "tool": {
125                "driver": {
126                    "name": "rumdl",
127                    "version": env!("CARGO_PKG_VERSION"),
128                    "informationUri": "https://github.com/rvben/rumdl",
129                    "rules": rules.values().cloned().collect::<Vec<_>>()
130                }
131            },
132            "results": results
133        }]
134    });
135
136    serde_json::to_string_pretty(&sarif_doc).unwrap_or_else(|_| r#"{"version":"2.1.0","runs":[]}"#.to_string())
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::rule::{Fix, Severity};
143    use serde_json::Value;
144
145    #[test]
146    fn test_sarif_formatter_default() {
147        let _formatter = SarifFormatter;
148        // No fields to test, just ensure it constructs
149    }
150
151    #[test]
152    fn test_sarif_formatter_new() {
153        let _formatter = SarifFormatter::new();
154        // No fields to test, just ensure it constructs
155    }
156
157    #[test]
158    fn test_format_warnings_empty() {
159        let formatter = SarifFormatter::new();
160        let warnings = vec![];
161        let output = formatter.format_warnings(&warnings, "test.md");
162
163        let sarif: Value = serde_json::from_str(&output).unwrap();
164        assert_eq!(sarif["version"], "2.1.0");
165        assert_eq!(
166            sarif["$schema"],
167            "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"
168        );
169        assert_eq!(sarif["runs"][0]["results"].as_array().unwrap().len(), 0);
170    }
171
172    #[test]
173    fn test_format_single_warning() {
174        let formatter = SarifFormatter::new();
175        let warnings = vec![LintWarning {
176            line: 10,
177            column: 5,
178            end_line: 10,
179            end_column: 15,
180            rule_name: Some("MD001"),
181            message: "Heading levels should only increment by one level at a time".to_string(),
182            severity: Severity::Warning,
183            fix: None,
184        }];
185
186        let output = formatter.format_warnings(&warnings, "README.md");
187        let sarif: Value = serde_json::from_str(&output).unwrap();
188
189        let results = sarif["runs"][0]["results"].as_array().unwrap();
190        assert_eq!(results.len(), 1);
191
192        let result = &results[0];
193        assert_eq!(result["ruleId"], "MD001");
194        assert_eq!(result["level"], "warning");
195        assert_eq!(
196            result["message"]["text"],
197            "Heading levels should only increment by one level at a time"
198        );
199        assert_eq!(
200            result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
201            "README.md"
202        );
203        assert_eq!(result["locations"][0]["physicalLocation"]["region"]["startLine"], 10);
204        assert_eq!(result["locations"][0]["physicalLocation"]["region"]["startColumn"], 5);
205    }
206
207    #[test]
208    fn test_format_single_warning_with_fix() {
209        let formatter = SarifFormatter::new();
210        let warnings = vec![LintWarning {
211            line: 10,
212            column: 5,
213            end_line: 10,
214            end_column: 15,
215            rule_name: Some("MD001"),
216            message: "Heading levels should only increment by one level at a time".to_string(),
217            severity: Severity::Warning,
218            fix: Some(Fix {
219                range: 100..110,
220                replacement: "## Heading".to_string(),
221            }),
222        }];
223
224        let output = formatter.format_warnings(&warnings, "README.md");
225        let sarif: Value = serde_json::from_str(&output).unwrap();
226
227        // SARIF format doesn't indicate fixable issues in the basic format
228        let results = sarif["runs"][0]["results"].as_array().unwrap();
229        assert_eq!(results.len(), 1);
230        assert_eq!(results[0]["ruleId"], "MD001");
231    }
232
233    #[test]
234    fn test_format_multiple_warnings() {
235        let formatter = SarifFormatter::new();
236        let warnings = vec![
237            LintWarning {
238                line: 5,
239                column: 1,
240                end_line: 5,
241                end_column: 10,
242                rule_name: Some("MD001"),
243                message: "First warning".to_string(),
244                severity: Severity::Warning,
245                fix: None,
246            },
247            LintWarning {
248                line: 10,
249                column: 3,
250                end_line: 10,
251                end_column: 20,
252                rule_name: Some("MD013"),
253                message: "Second warning".to_string(),
254                severity: Severity::Error,
255                fix: None,
256            },
257        ];
258
259        let output = formatter.format_warnings(&warnings, "test.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(), 2);
264        assert_eq!(results[0]["ruleId"], "MD001");
265        assert_eq!(results[0]["locations"][0]["physicalLocation"]["region"]["startLine"], 5);
266        assert_eq!(results[1]["ruleId"], "MD013");
267        assert_eq!(
268            results[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
269            10
270        );
271    }
272
273    #[test]
274    fn test_format_warning_unknown_rule() {
275        let formatter = SarifFormatter::new();
276        let warnings = vec![LintWarning {
277            line: 1,
278            column: 1,
279            end_line: 1,
280            end_column: 5,
281            rule_name: None,
282            message: "Unknown rule warning".to_string(),
283            severity: Severity::Warning,
284            fix: None,
285        }];
286
287        let output = formatter.format_warnings(&warnings, "file.md");
288        let sarif: Value = serde_json::from_str(&output).unwrap();
289
290        let results = sarif["runs"][0]["results"].as_array().unwrap();
291        assert_eq!(results[0]["ruleId"], "unknown");
292    }
293
294    #[test]
295    fn test_tool_information() {
296        let formatter = SarifFormatter::new();
297        let warnings = vec![];
298        let output = formatter.format_warnings(&warnings, "test.md");
299
300        let sarif: Value = serde_json::from_str(&output).unwrap();
301        let driver = &sarif["runs"][0]["tool"]["driver"];
302
303        assert_eq!(driver["name"], "rumdl");
304        assert_eq!(driver["version"], env!("CARGO_PKG_VERSION"));
305        assert_eq!(driver["informationUri"], "https://github.com/rvben/rumdl");
306    }
307
308    #[test]
309    fn test_sarif_report_empty() {
310        let warnings = vec![];
311        let output = format_sarif_report(&warnings);
312
313        let sarif: Value = serde_json::from_str(&output).unwrap();
314        assert_eq!(sarif["version"], "2.1.0");
315        assert_eq!(sarif["runs"][0]["results"].as_array().unwrap().len(), 0);
316    }
317
318    #[test]
319    fn test_sarif_report_single_file() {
320        let warnings = vec![(
321            "test.md".to_string(),
322            vec![LintWarning {
323                line: 10,
324                column: 5,
325                end_line: 10,
326                end_column: 15,
327                rule_name: Some("MD001"),
328                message: "Test warning".to_string(),
329                severity: Severity::Warning,
330                fix: None,
331            }],
332        )];
333
334        let output = format_sarif_report(&warnings);
335        let sarif: Value = serde_json::from_str(&output).unwrap();
336
337        let results = sarif["runs"][0]["results"].as_array().unwrap();
338        assert_eq!(results.len(), 1);
339        assert_eq!(
340            results[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
341            "test.md"
342        );
343
344        // Check that rule is defined in driver
345        let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
346        assert_eq!(rules.len(), 1);
347        assert_eq!(rules[0]["id"], "MD001");
348    }
349
350    #[test]
351    fn test_sarif_report_multiple_files() {
352        let warnings = vec![
353            (
354                "file1.md".to_string(),
355                vec![LintWarning {
356                    line: 1,
357                    column: 1,
358                    end_line: 1,
359                    end_column: 5,
360                    rule_name: Some("MD001"),
361                    message: "Warning in file 1".to_string(),
362                    severity: Severity::Warning,
363                    fix: None,
364                }],
365            ),
366            (
367                "file2.md".to_string(),
368                vec![
369                    LintWarning {
370                        line: 5,
371                        column: 1,
372                        end_line: 5,
373                        end_column: 10,
374                        rule_name: Some("MD013"),
375                        message: "Warning 1 in file 2".to_string(),
376                        severity: Severity::Warning,
377                        fix: None,
378                    },
379                    LintWarning {
380                        line: 10,
381                        column: 1,
382                        end_line: 10,
383                        end_column: 10,
384                        rule_name: Some("MD022"),
385                        message: "Warning 2 in file 2".to_string(),
386                        severity: Severity::Error,
387                        fix: None,
388                    },
389                ],
390            ),
391        ];
392
393        let output = format_sarif_report(&warnings);
394        let sarif: Value = serde_json::from_str(&output).unwrap();
395
396        let results = sarif["runs"][0]["results"].as_array().unwrap();
397        assert_eq!(results.len(), 3);
398
399        // Check that all rules are defined
400        let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
401        assert_eq!(rules.len(), 3);
402
403        let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
404        assert!(rule_ids.contains(&"MD001"));
405        assert!(rule_ids.contains(&"MD013"));
406        assert!(rule_ids.contains(&"MD022"));
407    }
408
409    #[test]
410    fn test_rule_deduplication() {
411        let warnings = vec![(
412            "test.md".to_string(),
413            vec![
414                LintWarning {
415                    line: 1,
416                    column: 1,
417                    end_line: 1,
418                    end_column: 5,
419                    rule_name: Some("MD001"),
420                    message: "First MD001".to_string(),
421                    severity: Severity::Warning,
422                    fix: None,
423                },
424                LintWarning {
425                    line: 10,
426                    column: 1,
427                    end_line: 10,
428                    end_column: 5,
429                    rule_name: Some("MD001"),
430                    message: "Second MD001".to_string(),
431                    severity: Severity::Warning,
432                    fix: None,
433                },
434            ],
435        )];
436
437        let output = format_sarif_report(&warnings);
438        let sarif: Value = serde_json::from_str(&output).unwrap();
439
440        // Should have 2 results but only 1 rule
441        let results = sarif["runs"][0]["results"].as_array().unwrap();
442        assert_eq!(results.len(), 2);
443
444        let rules = sarif["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
445        assert_eq!(rules.len(), 1);
446        assert_eq!(rules[0]["id"], "MD001");
447    }
448
449    #[test]
450    fn test_severity_always_warning() {
451        let formatter = SarifFormatter::new();
452
453        // Test that all severities are output as "warning" in SARIF format
454        let warnings = vec![
455            LintWarning {
456                line: 1,
457                column: 1,
458                end_line: 1,
459                end_column: 5,
460                rule_name: Some("MD001"),
461                message: "Warning severity".to_string(),
462                severity: Severity::Warning,
463                fix: None,
464            },
465            LintWarning {
466                line: 2,
467                column: 1,
468                end_line: 2,
469                end_column: 5,
470                rule_name: Some("MD002"),
471                message: "Error severity".to_string(),
472                severity: Severity::Error,
473                fix: None,
474            },
475        ];
476
477        let output = formatter.format_warnings(&warnings, "test.md");
478        let sarif: Value = serde_json::from_str(&output).unwrap();
479
480        let results = sarif["runs"][0]["results"].as_array().unwrap();
481        // Both should use level "warning" regardless of severity
482        assert_eq!(results[0]["level"], "warning");
483        assert_eq!(results[1]["level"], "warning");
484    }
485
486    #[test]
487    fn test_special_characters_in_message() {
488        let formatter = SarifFormatter::new();
489        let warnings = vec![LintWarning {
490            line: 1,
491            column: 1,
492            end_line: 1,
493            end_column: 5,
494            rule_name: Some("MD001"),
495            message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
496            severity: Severity::Warning,
497            fix: None,
498        }];
499
500        let output = formatter.format_warnings(&warnings, "test.md");
501        let sarif: Value = serde_json::from_str(&output).unwrap();
502
503        let results = sarif["runs"][0]["results"].as_array().unwrap();
504        // JSON should properly handle special characters
505        assert_eq!(
506            results[0]["message"]["text"],
507            "Warning with \"quotes\" and 'apostrophes' and \n newline"
508        );
509    }
510
511    #[test]
512    fn test_special_characters_in_file_path() {
513        let formatter = SarifFormatter::new();
514        let warnings = vec![LintWarning {
515            line: 1,
516            column: 1,
517            end_line: 1,
518            end_column: 5,
519            rule_name: Some("MD001"),
520            message: "Test".to_string(),
521            severity: Severity::Warning,
522            fix: None,
523        }];
524
525        let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
526        let sarif: Value = serde_json::from_str(&output).unwrap();
527
528        let results = sarif["runs"][0]["results"].as_array().unwrap();
529        assert_eq!(
530            results[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
531            "path/with spaces/and-dashes.md"
532        );
533    }
534
535    #[test]
536    fn test_sarif_schema_version() {
537        let formatter = SarifFormatter::new();
538        let warnings = vec![];
539        let output = formatter.format_warnings(&warnings, "test.md");
540
541        let sarif: Value = serde_json::from_str(&output).unwrap();
542        assert_eq!(
543            sarif["$schema"],
544            "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"
545        );
546        assert_eq!(sarif["version"], "2.1.0");
547    }
548}