rumdl_lib/output/formatters/
json.rs

1//! JSON output formatter
2
3use crate::output::OutputFormatter;
4use crate::rule::LintWarning;
5use serde_json::{Value, json};
6
7/// JSON formatter for machine-readable output
8#[derive(Default)]
9pub struct JsonFormatter {
10    collect_all: bool,
11}
12
13impl JsonFormatter {
14    pub fn new() -> Self {
15        Self::default()
16    }
17
18    /// Create a formatter that collects all warnings into a single JSON array
19    pub fn new_collecting() -> Self {
20        Self { collect_all: true }
21    }
22}
23
24impl OutputFormatter for JsonFormatter {
25    fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String {
26        if self.collect_all {
27            // For batch collection mode, just return empty string
28            // The actual JSON will be built elsewhere with all files
29            return String::new();
30        }
31
32        let json_warnings: Vec<Value> = warnings
33            .iter()
34            .map(|warning| {
35                json!({
36                    "file": file_path,
37                    "line": warning.line,
38                    "column": warning.column,
39                    "rule": warning.rule_name.as_deref().unwrap_or("unknown"),
40                    "message": warning.message,
41                    "severity": warning.severity,
42                    "fixable": warning.fix.is_some(),
43                    "fix": warning.fix.as_ref().map(|f| {
44                        json!({
45                            "range": {
46                                "start": f.range.start,
47                                "end": f.range.end
48                            },
49                            "replacement": f.replacement
50                        })
51                    })
52                })
53            })
54            .collect();
55
56        serde_json::to_string_pretty(&json_warnings).unwrap_or_default()
57    }
58}
59
60/// Helper to format all warnings from multiple files as a single JSON document
61pub fn format_all_warnings_as_json(all_warnings: &[(String, Vec<LintWarning>)]) -> String {
62    let mut json_warnings = Vec::new();
63
64    for (file_path, warnings) in all_warnings {
65        for warning in warnings {
66            json_warnings.push(json!({
67                "file": file_path,
68                "line": warning.line,
69                "column": warning.column,
70                "rule": warning.rule_name.as_deref().unwrap_or("unknown"),
71                "message": warning.message,
72                "severity": warning.severity,
73                "fixable": warning.fix.is_some(),
74                "fix": warning.fix.as_ref().map(|f| {
75                    json!({
76                        "range": {
77                            "start": f.range.start,
78                            "end": f.range.end
79                        },
80                        "replacement": f.replacement
81                    })
82                })
83            }));
84        }
85    }
86
87    serde_json::to_string_pretty(&json_warnings).unwrap_or_default()
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::rule::{Fix, Severity};
94
95    #[test]
96    fn test_json_formatter_default() {
97        let formatter = JsonFormatter::default();
98        assert!(!formatter.collect_all);
99    }
100
101    #[test]
102    fn test_json_formatter_new() {
103        let formatter = JsonFormatter::new();
104        assert!(!formatter.collect_all);
105    }
106
107    #[test]
108    fn test_json_formatter_new_collecting() {
109        let formatter = JsonFormatter::new_collecting();
110        assert!(formatter.collect_all);
111    }
112
113    #[test]
114    fn test_format_warnings_empty() {
115        let formatter = JsonFormatter::new();
116        let warnings = vec![];
117        let output = formatter.format_warnings(&warnings, "test.md");
118        assert_eq!(output, "[]");
119    }
120
121    #[test]
122    fn test_format_warnings_collecting_mode() {
123        let formatter = JsonFormatter::new_collecting();
124        let warnings = vec![LintWarning {
125            line: 1,
126            column: 1,
127            end_line: 1,
128            end_column: 5,
129            rule_name: Some("MD001".to_string()),
130            message: "Test warning".to_string(),
131            severity: Severity::Warning,
132            fix: None,
133        }];
134
135        // In collecting mode, it returns empty string
136        let output = formatter.format_warnings(&warnings, "test.md");
137        assert_eq!(output, "");
138    }
139
140    #[test]
141    fn test_format_single_warning() {
142        let formatter = JsonFormatter::new();
143        let warnings = vec![LintWarning {
144            line: 10,
145            column: 5,
146            end_line: 10,
147            end_column: 15,
148            rule_name: Some("MD001".to_string()),
149            message: "Heading levels should only increment by one level at a time".to_string(),
150            severity: Severity::Warning,
151            fix: None,
152        }];
153
154        let output = formatter.format_warnings(&warnings, "README.md");
155        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
156
157        assert_eq!(parsed.len(), 1);
158        assert_eq!(parsed[0]["file"], "README.md");
159        assert_eq!(parsed[0]["line"], 10);
160        assert_eq!(parsed[0]["column"], 5);
161        assert_eq!(parsed[0]["rule"], "MD001");
162        assert_eq!(
163            parsed[0]["message"],
164            "Heading levels should only increment by one level at a time"
165        );
166        assert_eq!(parsed[0]["severity"], "warning");
167        assert_eq!(parsed[0]["fixable"], false);
168        assert!(parsed[0]["fix"].is_null());
169    }
170
171    #[test]
172    fn test_format_warning_with_fix() {
173        let formatter = JsonFormatter::new();
174        let warnings = vec![LintWarning {
175            line: 15,
176            column: 1,
177            end_line: 15,
178            end_column: 10,
179            rule_name: Some("MD022".to_string()),
180            message: "Headings should be surrounded by blank lines".to_string(),
181            severity: Severity::Error,
182            fix: Some(Fix {
183                range: 100..110,
184                replacement: "\n# Heading\n".to_string(),
185            }),
186        }];
187
188        let output = formatter.format_warnings(&warnings, "doc.md");
189        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
190
191        assert_eq!(parsed.len(), 1);
192        assert_eq!(parsed[0]["file"], "doc.md");
193        assert_eq!(parsed[0]["line"], 15);
194        assert_eq!(parsed[0]["column"], 1);
195        assert_eq!(parsed[0]["rule"], "MD022");
196        assert_eq!(parsed[0]["message"], "Headings should be surrounded by blank lines");
197        assert_eq!(parsed[0]["severity"], "error");
198        assert_eq!(parsed[0]["fixable"], true);
199        assert!(!parsed[0]["fix"].is_null());
200        assert_eq!(parsed[0]["fix"]["range"]["start"], 100);
201        assert_eq!(parsed[0]["fix"]["range"]["end"], 110);
202        assert_eq!(parsed[0]["fix"]["replacement"], "\n# Heading\n");
203    }
204
205    #[test]
206    fn test_format_multiple_warnings() {
207        let formatter = JsonFormatter::new();
208        let warnings = vec![
209            LintWarning {
210                line: 5,
211                column: 1,
212                end_line: 5,
213                end_column: 10,
214                rule_name: Some("MD001".to_string()),
215                message: "First warning".to_string(),
216                severity: Severity::Warning,
217                fix: None,
218            },
219            LintWarning {
220                line: 10,
221                column: 3,
222                end_line: 10,
223                end_column: 20,
224                rule_name: Some("MD013".to_string()),
225                message: "Second warning".to_string(),
226                severity: Severity::Error,
227                fix: Some(Fix {
228                    range: 50..60,
229                    replacement: "fixed".to_string(),
230                }),
231            },
232        ];
233
234        let output = formatter.format_warnings(&warnings, "test.md");
235        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
236
237        assert_eq!(parsed.len(), 2);
238        assert_eq!(parsed[0]["rule"], "MD001");
239        assert_eq!(parsed[0]["message"], "First warning");
240        assert_eq!(parsed[0]["fixable"], false);
241
242        assert_eq!(parsed[1]["rule"], "MD013");
243        assert_eq!(parsed[1]["message"], "Second warning");
244        assert_eq!(parsed[1]["fixable"], true);
245    }
246
247    #[test]
248    fn test_format_warning_unknown_rule() {
249        let formatter = JsonFormatter::new();
250        let warnings = vec![LintWarning {
251            line: 1,
252            column: 1,
253            end_line: 1,
254            end_column: 5,
255            rule_name: None,
256            message: "Unknown rule warning".to_string(),
257            severity: Severity::Warning,
258            fix: None,
259        }];
260
261        let output = formatter.format_warnings(&warnings, "file.md");
262        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
263
264        assert_eq!(parsed[0]["rule"], "unknown");
265    }
266
267    #[test]
268    fn test_format_all_warnings_as_json_empty() {
269        let all_warnings = vec![];
270        let output = format_all_warnings_as_json(&all_warnings);
271        assert_eq!(output, "[]");
272    }
273
274    #[test]
275    fn test_format_all_warnings_as_json_single_file() {
276        let warnings = vec![LintWarning {
277            line: 1,
278            column: 1,
279            end_line: 1,
280            end_column: 5,
281            rule_name: Some("MD001".to_string()),
282            message: "Test warning".to_string(),
283            severity: Severity::Warning,
284            fix: None,
285        }];
286
287        let all_warnings = vec![("test.md".to_string(), warnings)];
288        let output = format_all_warnings_as_json(&all_warnings);
289        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
290
291        assert_eq!(parsed.len(), 1);
292        assert_eq!(parsed[0]["file"], "test.md");
293        assert_eq!(parsed[0]["rule"], "MD001");
294    }
295
296    #[test]
297    fn test_format_all_warnings_as_json_multiple_files() {
298        let warnings1 = vec![
299            LintWarning {
300                line: 1,
301                column: 1,
302                end_line: 1,
303                end_column: 5,
304                rule_name: Some("MD001".to_string()),
305                message: "Warning 1".to_string(),
306                severity: Severity::Warning,
307                fix: None,
308            },
309            LintWarning {
310                line: 5,
311                column: 1,
312                end_line: 5,
313                end_column: 10,
314                rule_name: Some("MD002".to_string()),
315                message: "Warning 2".to_string(),
316                severity: Severity::Warning,
317                fix: None,
318            },
319        ];
320
321        let warnings2 = vec![LintWarning {
322            line: 10,
323            column: 1,
324            end_line: 10,
325            end_column: 20,
326            rule_name: Some("MD003".to_string()),
327            message: "Warning 3".to_string(),
328            severity: Severity::Warning,
329            fix: Some(Fix {
330                range: 100..120,
331                replacement: "fixed".to_string(),
332            }),
333        }];
334
335        let all_warnings = vec![("file1.md".to_string(), warnings1), ("file2.md".to_string(), warnings2)];
336
337        let output = format_all_warnings_as_json(&all_warnings);
338        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
339
340        assert_eq!(parsed.len(), 3);
341        assert_eq!(parsed[0]["file"], "file1.md");
342        assert_eq!(parsed[0]["rule"], "MD001");
343        assert_eq!(parsed[1]["file"], "file1.md");
344        assert_eq!(parsed[1]["rule"], "MD002");
345        assert_eq!(parsed[2]["file"], "file2.md");
346        assert_eq!(parsed[2]["rule"], "MD003");
347        assert_eq!(parsed[2]["fixable"], true);
348    }
349
350    #[test]
351    fn test_json_output_is_valid() {
352        let formatter = JsonFormatter::new();
353        let warnings = vec![LintWarning {
354            line: 1,
355            column: 1,
356            end_line: 1,
357            end_column: 5,
358            rule_name: Some("MD001".to_string()),
359            message: "Test with \"quotes\" and special chars".to_string(),
360            severity: Severity::Warning,
361            fix: None,
362        }];
363
364        let output = formatter.format_warnings(&warnings, "test.md");
365
366        // Verify it's valid JSON
367        let result: Result<Vec<Value>, _> = serde_json::from_str(&output);
368        assert!(result.is_ok());
369
370        // Verify pretty printing works
371        assert!(output.contains("\n"));
372        assert!(output.contains("  "));
373    }
374
375    #[test]
376    fn test_edge_cases() {
377        let formatter = JsonFormatter::new();
378
379        // Test with large values
380        let warnings = vec![LintWarning {
381            line: 99999,
382            column: 12345,
383            end_line: 100000,
384            end_column: 12350,
385            rule_name: Some("MD999".to_string()),
386            message: "Edge case with\nnewlines\tand tabs".to_string(),
387            severity: Severity::Error,
388            fix: Some(Fix {
389                range: 999999..1000000,
390                replacement: "Multi\nline\nreplacement".to_string(),
391            }),
392        }];
393
394        let output = formatter.format_warnings(&warnings, "large.md");
395        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
396
397        assert_eq!(parsed[0]["line"], 99999);
398        assert_eq!(parsed[0]["column"], 12345);
399        assert_eq!(parsed[0]["fix"]["range"]["start"], 999999);
400        assert_eq!(parsed[0]["fix"]["range"]["end"], 1000000);
401        assert!(parsed[0]["message"].as_str().unwrap().contains("newlines\tand tabs"));
402        assert!(
403            parsed[0]["fix"]["replacement"]
404                .as_str()
405                .unwrap()
406                .contains("Multi\nline\nreplacement")
407        );
408    }
409
410    #[test]
411    fn test_severity_levels_in_json() {
412        let formatter = JsonFormatter::new();
413        let warnings = 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: "Error severity".to_string(),
421                severity: Severity::Error,
422                fix: None,
423            },
424            LintWarning {
425                line: 2,
426                column: 1,
427                end_line: 2,
428                end_column: 5,
429                rule_name: Some("MD002".to_string()),
430                message: "Warning severity".to_string(),
431                severity: Severity::Warning,
432                fix: None,
433            },
434            LintWarning {
435                line: 3,
436                column: 1,
437                end_line: 3,
438                end_column: 5,
439                rule_name: Some("MD003".to_string()),
440                message: "Info severity".to_string(),
441                severity: Severity::Info,
442                fix: None,
443            },
444        ];
445
446        let output = formatter.format_warnings(&warnings, "test.md");
447        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
448
449        assert_eq!(parsed.len(), 3);
450        assert_eq!(parsed[0]["severity"], "error");
451        assert_eq!(parsed[1]["severity"], "warning");
452        assert_eq!(parsed[2]["severity"], "info");
453    }
454}