Skip to main content

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::new(100..110, "## Heading".to_string())),
121        }];
122
123        let output = formatter.format_warnings(&warnings, "README.md");
124
125        // Parse the JSON to verify structure
126        let json: Value = serde_json::from_str(&output).unwrap();
127        assert_eq!(json["fixable"], true);
128    }
129
130    #[test]
131    fn test_format_multiple_warnings() {
132        let formatter = JsonLinesFormatter::new();
133        let warnings = vec![
134            LintWarning {
135                line: 5,
136                column: 1,
137                end_line: 5,
138                end_column: 10,
139                rule_name: Some("MD001".to_string()),
140                message: "First warning".to_string(),
141                severity: Severity::Warning,
142                fix: None,
143            },
144            LintWarning {
145                line: 10,
146                column: 3,
147                end_line: 10,
148                end_column: 20,
149                rule_name: Some("MD013".to_string()),
150                message: "Second warning".to_string(),
151                severity: Severity::Error,
152                fix: Some(Fix::new(50..60, "fixed".to_string())),
153            },
154        ];
155
156        let output = formatter.format_warnings(&warnings, "test.md");
157        let lines: Vec<&str> = output.lines().collect();
158
159        // Should have 2 lines of JSON
160        assert_eq!(lines.len(), 2);
161
162        // Parse each line as JSON
163        let json1: Value = serde_json::from_str(lines[0]).unwrap();
164        assert_eq!(json1["line"], 5);
165        assert_eq!(json1["rule"], "MD001");
166        assert_eq!(json1["fixable"], false);
167
168        let json2: Value = serde_json::from_str(lines[1]).unwrap();
169        assert_eq!(json2["line"], 10);
170        assert_eq!(json2["rule"], "MD013");
171        assert_eq!(json2["fixable"], true);
172    }
173
174    #[test]
175    fn test_format_warning_unknown_rule() {
176        let formatter = JsonLinesFormatter::new();
177        let warnings = vec![LintWarning {
178            line: 1,
179            column: 1,
180            end_line: 1,
181            end_column: 5,
182            rule_name: None,
183            message: "Unknown rule warning".to_string(),
184            severity: Severity::Warning,
185            fix: None,
186        }];
187
188        let output = formatter.format_warnings(&warnings, "file.md");
189        let json: Value = serde_json::from_str(&output).unwrap();
190
191        assert_eq!(json["rule"], "unknown");
192    }
193
194    #[test]
195    fn test_edge_cases() {
196        let formatter = JsonLinesFormatter::new();
197
198        // Test large line/column numbers
199        let warnings = vec![LintWarning {
200            line: 99999,
201            column: 12345,
202            end_line: 100000,
203            end_column: 12350,
204            rule_name: Some("MD999".to_string()),
205            message: "Edge case warning".to_string(),
206            severity: Severity::Error,
207            fix: None,
208        }];
209
210        let output = formatter.format_warnings(&warnings, "large.md");
211        let json: Value = serde_json::from_str(&output).unwrap();
212
213        assert_eq!(json["line"], 99999);
214        assert_eq!(json["column"], 12345);
215    }
216
217    #[test]
218    fn test_special_characters_in_message() {
219        let formatter = JsonLinesFormatter::new();
220        let warnings = vec![LintWarning {
221            line: 1,
222            column: 1,
223            end_line: 1,
224            end_column: 5,
225            rule_name: Some("MD001".to_string()),
226            message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
227            severity: Severity::Warning,
228            fix: None,
229        }];
230
231        let output = formatter.format_warnings(&warnings, "test.md");
232        let json: Value = serde_json::from_str(&output).unwrap();
233
234        // JSON should properly escape special characters
235        assert_eq!(
236            json["message"],
237            "Warning with \"quotes\" and 'apostrophes' and \n newline"
238        );
239    }
240
241    #[test]
242    fn test_special_characters_in_file_path() {
243        let formatter = JsonLinesFormatter::new();
244        let warnings = vec![LintWarning {
245            line: 1,
246            column: 1,
247            end_line: 1,
248            end_column: 5,
249            rule_name: Some("MD001".to_string()),
250            message: "Test".to_string(),
251            severity: Severity::Warning,
252            fix: None,
253        }];
254
255        let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
256        let json: Value = serde_json::from_str(&output).unwrap();
257
258        assert_eq!(json["file"], "path/with spaces/and-dashes.md");
259    }
260
261    #[test]
262    fn test_json_lines_format() {
263        let formatter = JsonLinesFormatter::new();
264        let warnings = vec![
265            LintWarning {
266                line: 1,
267                column: 1,
268                end_line: 1,
269                end_column: 5,
270                rule_name: Some("MD001".to_string()),
271                message: "First".to_string(),
272                severity: Severity::Warning,
273                fix: None,
274            },
275            LintWarning {
276                line: 2,
277                column: 1,
278                end_line: 2,
279                end_column: 5,
280                rule_name: Some("MD002".to_string()),
281                message: "Second".to_string(),
282                severity: Severity::Warning,
283                fix: None,
284            },
285            LintWarning {
286                line: 3,
287                column: 1,
288                end_line: 3,
289                end_column: 5,
290                rule_name: Some("MD003".to_string()),
291                message: "Third".to_string(),
292                severity: Severity::Warning,
293                fix: None,
294            },
295        ];
296
297        let output = formatter.format_warnings(&warnings, "test.md");
298
299        // Each line should be valid JSON
300        for line in output.lines() {
301            assert!(serde_json::from_str::<Value>(line).is_ok());
302        }
303
304        // Should have 3 lines
305        assert_eq!(output.lines().count(), 3);
306    }
307
308    #[test]
309    fn test_severity_levels() {
310        let formatter = JsonLinesFormatter::new();
311
312        // Test that all severity levels are correctly output
313        let warnings = vec![
314            LintWarning {
315                line: 1,
316                column: 1,
317                end_line: 1,
318                end_column: 5,
319                rule_name: Some("MD001".to_string()),
320                message: "Warning severity".to_string(),
321                severity: Severity::Warning,
322                fix: None,
323            },
324            LintWarning {
325                line: 2,
326                column: 1,
327                end_line: 2,
328                end_column: 5,
329                rule_name: Some("MD002".to_string()),
330                message: "Error severity".to_string(),
331                severity: Severity::Error,
332                fix: None,
333            },
334            LintWarning {
335                line: 3,
336                column: 1,
337                end_line: 3,
338                end_column: 5,
339                rule_name: Some("MD003".to_string()),
340                message: "Info severity".to_string(),
341                severity: Severity::Info,
342                fix: None,
343            },
344        ];
345
346        let output = formatter.format_warnings(&warnings, "test.md");
347        let lines: Vec<&str> = output.lines().collect();
348
349        let json0: Value = serde_json::from_str(lines[0]).unwrap();
350        let json1: Value = serde_json::from_str(lines[1]).unwrap();
351        let json2: Value = serde_json::from_str(lines[2]).unwrap();
352
353        assert_eq!(json0["severity"], "warning");
354        assert_eq!(json1["severity"], "error");
355        assert_eq!(json2["severity"], "info");
356    }
357
358    #[test]
359    fn test_json_field_order() {
360        let formatter = JsonLinesFormatter::new();
361        let warnings = vec![LintWarning {
362            line: 1,
363            column: 1,
364            end_line: 1,
365            end_column: 5,
366            rule_name: Some("MD001".to_string()),
367            message: "Test".to_string(),
368            severity: Severity::Warning,
369            fix: None,
370        }];
371
372        let output = formatter.format_warnings(&warnings, "test.md");
373
374        // Verify that all expected fields are present
375        let json: Value = serde_json::from_str(&output).unwrap();
376        assert!(json.get("file").is_some());
377        assert!(json.get("line").is_some());
378        assert!(json.get("column").is_some());
379        assert!(json.get("rule").is_some());
380        assert!(json.get("message").is_some());
381        assert!(json.get("severity").is_some());
382        assert!(json.get("fixable").is_some());
383    }
384
385    #[test]
386    fn test_unicode_in_json() {
387        let formatter = JsonLinesFormatter::new();
388        let warnings = vec![LintWarning {
389            line: 1,
390            column: 1,
391            end_line: 1,
392            end_column: 5,
393            rule_name: Some("MD001".to_string()),
394            message: "Unicode: 你好 émoji 🎉".to_string(),
395            severity: Severity::Warning,
396            fix: None,
397        }];
398
399        let output = formatter.format_warnings(&warnings, "测试.md");
400        let json: Value = serde_json::from_str(&output).unwrap();
401
402        assert_eq!(json["message"], "Unicode: 你好 émoji 🎉");
403        assert_eq!(json["file"], "测试.md");
404    }
405}