1use crate::output::OutputFormatter;
4use crate::rule::LintWarning;
5use serde_json::json;
6
7pub 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 let results: Vec<_> = warnings
26 .iter()
27 .map(|warning| {
28 let rule_id = warning.rule_name.as_deref().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
69pub 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 for (file_path, warnings) in all_warnings {
76 for warning in warnings {
77 let rule_id = warning.rule_name.as_deref().unwrap_or("unknown");
78
79 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 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 }
150
151 #[test]
152 fn test_sarif_formatter_new() {
153 let _formatter = SarifFormatter::new();
154 }
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".to_string()),
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".to_string()),
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 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".to_string()),
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".to_string()),
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".to_string()),
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 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".to_string()),
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".to_string()),
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".to_string()),
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 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".to_string()),
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".to_string()),
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 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 let warnings = vec![
455 LintWarning {
456 line: 1,
457 column: 1,
458 end_line: 1,
459 end_column: 5,
460 rule_name: Some("MD001".to_string()),
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".to_string()),
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 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".to_string()),
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 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".to_string()),
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}