rumdl_lib/output/formatters/
json_lines.rs

1//! JSON Lines output formatter (one JSON object per line)
2
3use crate::output::OutputFormatter;
4use crate::rule::LintWarning;
5use serde_json::json;
6
7/// JSON Lines formatter - one JSON object per line
8pub struct JsonLinesFormatter;
9
10impl Default for JsonLinesFormatter {
11    fn default() -> Self {
12        Self
13    }
14}
15
16impl JsonLinesFormatter {
17    pub fn new() -> Self {
18        Self
19    }
20}
21
22impl OutputFormatter for JsonLinesFormatter {
23    fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String {
24        let mut output = String::new();
25
26        for warning in warnings {
27            let json_obj = json!({
28                "file": file_path,
29                "line": warning.line,
30                "column": warning.column,
31                "rule": warning.rule_name.as_deref().unwrap_or("unknown"),
32                "message": warning.message,
33                "severity": warning.severity,
34                "fixable": warning.fix.is_some()
35            });
36
37            // Compact JSON representation on a single line
38            if let Ok(json_str) = serde_json::to_string(&json_obj) {
39                output.push_str(&json_str);
40                output.push('\n');
41            }
42        }
43
44        // Remove trailing newline
45        if output.ends_with('\n') {
46            output.pop();
47        }
48
49        output
50    }
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56    use crate::rule::{Fix, Severity};
57    use serde_json::Value;
58
59    #[test]
60    fn test_json_lines_formatter_default() {
61        let _formatter = JsonLinesFormatter;
62        // No fields to test, just ensure it constructs
63    }
64
65    #[test]
66    fn test_json_lines_formatter_new() {
67        let _formatter = JsonLinesFormatter::new();
68        // No fields to test, just ensure it constructs
69    }
70
71    #[test]
72    fn test_format_warnings_empty() {
73        let formatter = JsonLinesFormatter::new();
74        let warnings = vec![];
75        let output = formatter.format_warnings(&warnings, "test.md");
76        assert_eq!(output, "");
77    }
78
79    #[test]
80    fn test_format_single_warning() {
81        let formatter = JsonLinesFormatter::new();
82        let warnings = vec![LintWarning {
83            line: 10,
84            column: 5,
85            end_line: 10,
86            end_column: 15,
87            rule_name: Some("MD001".to_string()),
88            message: "Heading levels should only increment by one level at a time".to_string(),
89            severity: Severity::Warning,
90            fix: None,
91        }];
92
93        let output = formatter.format_warnings(&warnings, "README.md");
94
95        // Parse the JSON to verify structure
96        let json: Value = serde_json::from_str(&output).unwrap();
97        assert_eq!(json["file"], "README.md");
98        assert_eq!(json["line"], 10);
99        assert_eq!(json["column"], 5);
100        assert_eq!(json["rule"], "MD001");
101        assert_eq!(
102            json["message"],
103            "Heading levels should only increment by one level at a time"
104        );
105        assert_eq!(json["severity"], "warning");
106        assert_eq!(json["fixable"], false);
107    }
108
109    #[test]
110    fn test_format_single_warning_with_fix() {
111        let formatter = JsonLinesFormatter::new();
112        let warnings = vec![LintWarning {
113            line: 10,
114            column: 5,
115            end_line: 10,
116            end_column: 15,
117            rule_name: Some("MD001".to_string()),
118            message: "Heading levels should only increment by one level at a time".to_string(),
119            severity: Severity::Warning,
120            fix: Some(Fix {
121                range: 100..110,
122                replacement: "## Heading".to_string(),
123            }),
124        }];
125
126        let output = formatter.format_warnings(&warnings, "README.md");
127
128        // Parse the JSON to verify structure
129        let json: Value = serde_json::from_str(&output).unwrap();
130        assert_eq!(json["fixable"], true);
131    }
132
133    #[test]
134    fn test_format_multiple_warnings() {
135        let formatter = JsonLinesFormatter::new();
136        let warnings = vec![
137            LintWarning {
138                line: 5,
139                column: 1,
140                end_line: 5,
141                end_column: 10,
142                rule_name: Some("MD001".to_string()),
143                message: "First warning".to_string(),
144                severity: Severity::Warning,
145                fix: None,
146            },
147            LintWarning {
148                line: 10,
149                column: 3,
150                end_line: 10,
151                end_column: 20,
152                rule_name: Some("MD013".to_string()),
153                message: "Second warning".to_string(),
154                severity: Severity::Error,
155                fix: Some(Fix {
156                    range: 50..60,
157                    replacement: "fixed".to_string(),
158                }),
159            },
160        ];
161
162        let output = formatter.format_warnings(&warnings, "test.md");
163        let lines: Vec<&str> = output.lines().collect();
164
165        // Should have 2 lines of JSON
166        assert_eq!(lines.len(), 2);
167
168        // Parse each line as JSON
169        let json1: Value = serde_json::from_str(lines[0]).unwrap();
170        assert_eq!(json1["line"], 5);
171        assert_eq!(json1["rule"], "MD001");
172        assert_eq!(json1["fixable"], false);
173
174        let json2: Value = serde_json::from_str(lines[1]).unwrap();
175        assert_eq!(json2["line"], 10);
176        assert_eq!(json2["rule"], "MD013");
177        assert_eq!(json2["fixable"], true);
178    }
179
180    #[test]
181    fn test_format_warning_unknown_rule() {
182        let formatter = JsonLinesFormatter::new();
183        let warnings = vec![LintWarning {
184            line: 1,
185            column: 1,
186            end_line: 1,
187            end_column: 5,
188            rule_name: None,
189            message: "Unknown rule warning".to_string(),
190            severity: Severity::Warning,
191            fix: None,
192        }];
193
194        let output = formatter.format_warnings(&warnings, "file.md");
195        let json: Value = serde_json::from_str(&output).unwrap();
196
197        assert_eq!(json["rule"], "unknown");
198    }
199
200    #[test]
201    fn test_edge_cases() {
202        let formatter = JsonLinesFormatter::new();
203
204        // Test large line/column numbers
205        let warnings = vec![LintWarning {
206            line: 99999,
207            column: 12345,
208            end_line: 100000,
209            end_column: 12350,
210            rule_name: Some("MD999".to_string()),
211            message: "Edge case warning".to_string(),
212            severity: Severity::Error,
213            fix: None,
214        }];
215
216        let output = formatter.format_warnings(&warnings, "large.md");
217        let json: Value = serde_json::from_str(&output).unwrap();
218
219        assert_eq!(json["line"], 99999);
220        assert_eq!(json["column"], 12345);
221    }
222
223    #[test]
224    fn test_special_characters_in_message() {
225        let formatter = JsonLinesFormatter::new();
226        let warnings = vec![LintWarning {
227            line: 1,
228            column: 1,
229            end_line: 1,
230            end_column: 5,
231            rule_name: Some("MD001".to_string()),
232            message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
233            severity: Severity::Warning,
234            fix: None,
235        }];
236
237        let output = formatter.format_warnings(&warnings, "test.md");
238        let json: Value = serde_json::from_str(&output).unwrap();
239
240        // JSON should properly escape special characters
241        assert_eq!(
242            json["message"],
243            "Warning with \"quotes\" and 'apostrophes' and \n newline"
244        );
245    }
246
247    #[test]
248    fn test_special_characters_in_file_path() {
249        let formatter = JsonLinesFormatter::new();
250        let warnings = vec![LintWarning {
251            line: 1,
252            column: 1,
253            end_line: 1,
254            end_column: 5,
255            rule_name: Some("MD001".to_string()),
256            message: "Test".to_string(),
257            severity: Severity::Warning,
258            fix: None,
259        }];
260
261        let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
262        let json: Value = serde_json::from_str(&output).unwrap();
263
264        assert_eq!(json["file"], "path/with spaces/and-dashes.md");
265    }
266
267    #[test]
268    fn test_json_lines_format() {
269        let formatter = JsonLinesFormatter::new();
270        let warnings = vec![
271            LintWarning {
272                line: 1,
273                column: 1,
274                end_line: 1,
275                end_column: 5,
276                rule_name: Some("MD001".to_string()),
277                message: "First".to_string(),
278                severity: Severity::Warning,
279                fix: None,
280            },
281            LintWarning {
282                line: 2,
283                column: 1,
284                end_line: 2,
285                end_column: 5,
286                rule_name: Some("MD002".to_string()),
287                message: "Second".to_string(),
288                severity: Severity::Warning,
289                fix: None,
290            },
291            LintWarning {
292                line: 3,
293                column: 1,
294                end_line: 3,
295                end_column: 5,
296                rule_name: Some("MD003".to_string()),
297                message: "Third".to_string(),
298                severity: Severity::Warning,
299                fix: None,
300            },
301        ];
302
303        let output = formatter.format_warnings(&warnings, "test.md");
304
305        // Each line should be valid JSON
306        for line in output.lines() {
307            assert!(serde_json::from_str::<Value>(line).is_ok());
308        }
309
310        // Should have 3 lines
311        assert_eq!(output.lines().count(), 3);
312    }
313
314    #[test]
315    fn test_severity_levels() {
316        let formatter = JsonLinesFormatter::new();
317
318        // Test that all severity levels are correctly output
319        let warnings = vec![
320            LintWarning {
321                line: 1,
322                column: 1,
323                end_line: 1,
324                end_column: 5,
325                rule_name: Some("MD001".to_string()),
326                message: "Warning severity".to_string(),
327                severity: Severity::Warning,
328                fix: None,
329            },
330            LintWarning {
331                line: 2,
332                column: 1,
333                end_line: 2,
334                end_column: 5,
335                rule_name: Some("MD002".to_string()),
336                message: "Error severity".to_string(),
337                severity: Severity::Error,
338                fix: None,
339            },
340            LintWarning {
341                line: 3,
342                column: 1,
343                end_line: 3,
344                end_column: 5,
345                rule_name: Some("MD003".to_string()),
346                message: "Info severity".to_string(),
347                severity: Severity::Info,
348                fix: None,
349            },
350        ];
351
352        let output = formatter.format_warnings(&warnings, "test.md");
353        let lines: Vec<&str> = output.lines().collect();
354
355        let json0: Value = serde_json::from_str(lines[0]).unwrap();
356        let json1: Value = serde_json::from_str(lines[1]).unwrap();
357        let json2: Value = serde_json::from_str(lines[2]).unwrap();
358
359        assert_eq!(json0["severity"], "warning");
360        assert_eq!(json1["severity"], "error");
361        assert_eq!(json2["severity"], "info");
362    }
363
364    #[test]
365    fn test_json_field_order() {
366        let formatter = JsonLinesFormatter::new();
367        let warnings = vec![LintWarning {
368            line: 1,
369            column: 1,
370            end_line: 1,
371            end_column: 5,
372            rule_name: Some("MD001".to_string()),
373            message: "Test".to_string(),
374            severity: Severity::Warning,
375            fix: None,
376        }];
377
378        let output = formatter.format_warnings(&warnings, "test.md");
379
380        // Verify that all expected fields are present
381        let json: Value = serde_json::from_str(&output).unwrap();
382        assert!(json.get("file").is_some());
383        assert!(json.get("line").is_some());
384        assert!(json.get("column").is_some());
385        assert!(json.get("rule").is_some());
386        assert!(json.get("message").is_some());
387        assert!(json.get("severity").is_some());
388        assert!(json.get("fixable").is_some());
389    }
390
391    #[test]
392    fn test_unicode_in_json() {
393        let formatter = JsonLinesFormatter::new();
394        let warnings = vec![LintWarning {
395            line: 1,
396            column: 1,
397            end_line: 1,
398            end_column: 5,
399            rule_name: Some("MD001".to_string()),
400            message: "Unicode: 你好 émoji 🎉".to_string(),
401            severity: Severity::Warning,
402            fix: None,
403        }];
404
405        let output = formatter.format_warnings(&warnings, "测试.md");
406        let json: Value = serde_json::from_str(&output).unwrap();
407
408        assert_eq!(json["message"], "Unicode: 你好 émoji 🎉");
409        assert_eq!(json["file"], "测试.md");
410    }
411}