rumdl_lib/output/formatters/
gitlab.rs

1//! GitLab Code Quality report format
2
3use crate::output::OutputFormatter;
4use crate::rule::LintWarning;
5use serde_json::json;
6
7/// GitLab Code Quality formatter
8/// Outputs in GitLab's code quality JSON format
9pub struct GitLabFormatter;
10
11impl Default for GitLabFormatter {
12    fn default() -> Self {
13        Self
14    }
15}
16
17impl GitLabFormatter {
18    pub fn new() -> Self {
19        Self
20    }
21}
22
23impl OutputFormatter for GitLabFormatter {
24    fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String {
25        // Format warnings for a single file as GitLab Code Quality issues
26        let issues: Vec<_> = warnings
27            .iter()
28            .map(|warning| {
29                let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
30                let fingerprint = format!("{}-{}-{}-{}", file_path, warning.line, warning.column, rule_name);
31
32                json!({
33                    "description": warning.message,
34                    "check_name": rule_name,
35                    "fingerprint": fingerprint,
36                    "severity": "minor",
37                    "location": {
38                        "path": file_path,
39                        "lines": {
40                            "begin": warning.line
41                        }
42                    }
43                })
44            })
45            .collect();
46
47        serde_json::to_string_pretty(&issues).unwrap_or_else(|_| "[]".to_string())
48    }
49}
50
51/// Format all warnings as GitLab Code Quality report
52pub fn format_gitlab_report(all_warnings: &[(String, Vec<LintWarning>)]) -> String {
53    let mut issues = Vec::new();
54
55    for (file_path, warnings) in all_warnings {
56        for warning in warnings {
57            let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
58
59            // Create a fingerprint for deduplication
60            let fingerprint = format!("{}-{}-{}-{}", file_path, warning.line, warning.column, rule_name);
61
62            let issue = json!({
63                "description": warning.message,
64                "check_name": rule_name,
65                "fingerprint": fingerprint,
66                "severity": "minor",
67                "location": {
68                    "path": file_path,
69                    "lines": {
70                        "begin": warning.line
71                    }
72                }
73            });
74
75            issues.push(issue);
76        }
77    }
78
79    serde_json::to_string_pretty(&issues).unwrap_or_else(|_| "[]".to_string())
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::rule::{Fix, Severity};
86    use serde_json::Value;
87
88    #[test]
89    fn test_gitlab_formatter_default() {
90        let _formatter = GitLabFormatter;
91        // No fields to test, just ensure it constructs
92    }
93
94    #[test]
95    fn test_gitlab_formatter_new() {
96        let _formatter = GitLabFormatter::new();
97        // No fields to test, just ensure it constructs
98    }
99
100    #[test]
101    fn test_format_warnings_empty() {
102        let formatter = GitLabFormatter::new();
103        let warnings = vec![];
104        let output = formatter.format_warnings(&warnings, "test.md");
105        assert_eq!(output, "[]");
106    }
107
108    #[test]
109    fn test_format_single_warning() {
110        let formatter = GitLabFormatter::new();
111        let warnings = vec![LintWarning {
112            line: 10,
113            column: 5,
114            end_line: 10,
115            end_column: 15,
116            rule_name: Some("MD001".to_string()),
117            message: "Heading levels should only increment by one level at a time".to_string(),
118            severity: Severity::Warning,
119            fix: None,
120        }];
121
122        let output = formatter.format_warnings(&warnings, "README.md");
123        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
124
125        assert_eq!(issues.len(), 1);
126        let issue = &issues[0];
127        assert_eq!(
128            issue["description"],
129            "Heading levels should only increment by one level at a time"
130        );
131        assert_eq!(issue["check_name"], "MD001");
132        assert_eq!(issue["fingerprint"], "README.md-10-5-MD001");
133        assert_eq!(issue["severity"], "minor");
134        assert_eq!(issue["location"]["path"], "README.md");
135        assert_eq!(issue["location"]["lines"]["begin"], 10);
136    }
137
138    #[test]
139    fn test_format_single_warning_with_fix() {
140        let formatter = GitLabFormatter::new();
141        let warnings = vec![LintWarning {
142            line: 10,
143            column: 5,
144            end_line: 10,
145            end_column: 15,
146            rule_name: Some("MD001".to_string()),
147            message: "Heading levels should only increment by one level at a time".to_string(),
148            severity: Severity::Warning,
149            fix: Some(Fix {
150                range: 100..110,
151                replacement: "## Heading".to_string(),
152            }),
153        }];
154
155        let output = formatter.format_warnings(&warnings, "README.md");
156        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
157
158        // GitLab format doesn't indicate fixable issues
159        assert_eq!(issues.len(), 1);
160        assert_eq!(issues[0]["check_name"], "MD001");
161    }
162
163    #[test]
164    fn test_format_multiple_warnings() {
165        let formatter = GitLabFormatter::new();
166        let warnings = vec![
167            LintWarning {
168                line: 5,
169                column: 1,
170                end_line: 5,
171                end_column: 10,
172                rule_name: Some("MD001".to_string()),
173                message: "First warning".to_string(),
174                severity: Severity::Warning,
175                fix: None,
176            },
177            LintWarning {
178                line: 10,
179                column: 3,
180                end_line: 10,
181                end_column: 20,
182                rule_name: Some("MD013".to_string()),
183                message: "Second warning".to_string(),
184                severity: Severity::Error,
185                fix: None,
186            },
187        ];
188
189        let output = formatter.format_warnings(&warnings, "test.md");
190        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
191
192        assert_eq!(issues.len(), 2);
193        assert_eq!(issues[0]["check_name"], "MD001");
194        assert_eq!(issues[0]["location"]["lines"]["begin"], 5);
195        assert_eq!(issues[1]["check_name"], "MD013");
196        assert_eq!(issues[1]["location"]["lines"]["begin"], 10);
197    }
198
199    #[test]
200    fn test_format_warning_unknown_rule() {
201        let formatter = GitLabFormatter::new();
202        let warnings = vec![LintWarning {
203            line: 1,
204            column: 1,
205            end_line: 1,
206            end_column: 5,
207            rule_name: None,
208            message: "Unknown rule warning".to_string(),
209            severity: Severity::Warning,
210            fix: None,
211        }];
212
213        let output = formatter.format_warnings(&warnings, "file.md");
214        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
215
216        assert_eq!(issues[0]["check_name"], "unknown");
217        assert_eq!(issues[0]["fingerprint"], "file.md-1-1-unknown");
218    }
219
220    #[test]
221    fn test_gitlab_report_empty() {
222        let warnings = vec![];
223        let output = format_gitlab_report(&warnings);
224        assert_eq!(output, "[]");
225    }
226
227    #[test]
228    fn test_gitlab_report_single_file() {
229        let warnings = vec![(
230            "test.md".to_string(),
231            vec![LintWarning {
232                line: 10,
233                column: 5,
234                end_line: 10,
235                end_column: 15,
236                rule_name: Some("MD001".to_string()),
237                message: "Test warning".to_string(),
238                severity: Severity::Warning,
239                fix: None,
240            }],
241        )];
242
243        let output = format_gitlab_report(&warnings);
244        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
245
246        assert_eq!(issues.len(), 1);
247        assert_eq!(issues[0]["location"]["path"], "test.md");
248    }
249
250    #[test]
251    fn test_gitlab_report_multiple_files() {
252        let warnings = vec![
253            (
254                "file1.md".to_string(),
255                vec![LintWarning {
256                    line: 1,
257                    column: 1,
258                    end_line: 1,
259                    end_column: 5,
260                    rule_name: Some("MD001".to_string()),
261                    message: "Warning in file 1".to_string(),
262                    severity: Severity::Warning,
263                    fix: None,
264                }],
265            ),
266            (
267                "file2.md".to_string(),
268                vec![
269                    LintWarning {
270                        line: 5,
271                        column: 1,
272                        end_line: 5,
273                        end_column: 10,
274                        rule_name: Some("MD013".to_string()),
275                        message: "Warning 1 in file 2".to_string(),
276                        severity: Severity::Warning,
277                        fix: None,
278                    },
279                    LintWarning {
280                        line: 10,
281                        column: 1,
282                        end_line: 10,
283                        end_column: 10,
284                        rule_name: Some("MD022".to_string()),
285                        message: "Warning 2 in file 2".to_string(),
286                        severity: Severity::Error,
287                        fix: None,
288                    },
289                ],
290            ),
291        ];
292
293        let output = format_gitlab_report(&warnings);
294        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
295
296        assert_eq!(issues.len(), 3);
297        assert_eq!(issues[0]["location"]["path"], "file1.md");
298        assert_eq!(issues[1]["location"]["path"], "file2.md");
299        assert_eq!(issues[2]["location"]["path"], "file2.md");
300    }
301
302    #[test]
303    fn test_fingerprint_uniqueness() {
304        let formatter = GitLabFormatter::new();
305
306        // Same line/column but different rules should have different fingerprints
307        let warnings = vec![
308            LintWarning {
309                line: 10,
310                column: 5,
311                end_line: 10,
312                end_column: 15,
313                rule_name: Some("MD001".to_string()),
314                message: "First rule".to_string(),
315                severity: Severity::Warning,
316                fix: None,
317            },
318            LintWarning {
319                line: 10,
320                column: 5,
321                end_line: 10,
322                end_column: 15,
323                rule_name: Some("MD002".to_string()),
324                message: "Second rule".to_string(),
325                severity: Severity::Warning,
326                fix: None,
327            },
328        ];
329
330        let output = formatter.format_warnings(&warnings, "test.md");
331        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
332
333        assert_ne!(issues[0]["fingerprint"], issues[1]["fingerprint"]);
334        assert_eq!(issues[0]["fingerprint"], "test.md-10-5-MD001");
335        assert_eq!(issues[1]["fingerprint"], "test.md-10-5-MD002");
336    }
337
338    #[test]
339    fn test_severity_always_minor() {
340        let formatter = GitLabFormatter::new();
341
342        // Test that all severities are output as "minor" in GitLab format
343        let warnings = vec![
344            LintWarning {
345                line: 1,
346                column: 1,
347                end_line: 1,
348                end_column: 5,
349                rule_name: Some("MD001".to_string()),
350                message: "Warning severity".to_string(),
351                severity: Severity::Warning,
352                fix: None,
353            },
354            LintWarning {
355                line: 2,
356                column: 1,
357                end_line: 2,
358                end_column: 5,
359                rule_name: Some("MD002".to_string()),
360                message: "Error severity".to_string(),
361                severity: Severity::Error,
362                fix: None,
363            },
364        ];
365
366        let output = formatter.format_warnings(&warnings, "test.md");
367        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
368
369        // Both should use severity "minor" regardless of actual severity
370        assert_eq!(issues[0]["severity"], "minor");
371        assert_eq!(issues[1]["severity"], "minor");
372    }
373
374    #[test]
375    fn test_special_characters_in_message() {
376        let formatter = GitLabFormatter::new();
377        let warnings = vec![LintWarning {
378            line: 1,
379            column: 1,
380            end_line: 1,
381            end_column: 5,
382            rule_name: Some("MD001".to_string()),
383            message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
384            severity: Severity::Warning,
385            fix: None,
386        }];
387
388        let output = formatter.format_warnings(&warnings, "test.md");
389        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
390
391        // JSON should properly handle special characters
392        assert_eq!(
393            issues[0]["description"],
394            "Warning with \"quotes\" and 'apostrophes' and \n newline"
395        );
396    }
397
398    #[test]
399    fn test_special_characters_in_file_path() {
400        let formatter = GitLabFormatter::new();
401        let warnings = vec![LintWarning {
402            line: 1,
403            column: 1,
404            end_line: 1,
405            end_column: 5,
406            rule_name: Some("MD001".to_string()),
407            message: "Test".to_string(),
408            severity: Severity::Warning,
409            fix: None,
410        }];
411
412        let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
413        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
414
415        assert_eq!(issues[0]["location"]["path"], "path/with spaces/and-dashes.md");
416        assert_eq!(issues[0]["fingerprint"], "path/with spaces/and-dashes.md-1-1-MD001");
417    }
418
419    #[test]
420    fn test_json_pretty_formatting() {
421        let formatter = GitLabFormatter::new();
422        let warnings = vec![LintWarning {
423            line: 1,
424            column: 1,
425            end_line: 1,
426            end_column: 5,
427            rule_name: Some("MD001".to_string()),
428            message: "Test".to_string(),
429            severity: Severity::Warning,
430            fix: None,
431        }];
432
433        let output = formatter.format_warnings(&warnings, "test.md");
434
435        // Check that output is pretty-printed (contains newlines and indentation)
436        assert!(output.contains('\n'));
437        assert!(output.contains("  "));
438    }
439}