Skip to main content

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::new(100..110, "## Heading".to_string())),
150        }];
151
152        let output = formatter.format_warnings(&warnings, "README.md");
153        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
154
155        // GitLab format doesn't indicate fixable issues
156        assert_eq!(issues.len(), 1);
157        assert_eq!(issues[0]["check_name"], "MD001");
158    }
159
160    #[test]
161    fn test_format_multiple_warnings() {
162        let formatter = GitLabFormatter::new();
163        let warnings = vec![
164            LintWarning {
165                line: 5,
166                column: 1,
167                end_line: 5,
168                end_column: 10,
169                rule_name: Some("MD001".to_string()),
170                message: "First warning".to_string(),
171                severity: Severity::Warning,
172                fix: None,
173            },
174            LintWarning {
175                line: 10,
176                column: 3,
177                end_line: 10,
178                end_column: 20,
179                rule_name: Some("MD013".to_string()),
180                message: "Second warning".to_string(),
181                severity: Severity::Error,
182                fix: None,
183            },
184        ];
185
186        let output = formatter.format_warnings(&warnings, "test.md");
187        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
188
189        assert_eq!(issues.len(), 2);
190        assert_eq!(issues[0]["check_name"], "MD001");
191        assert_eq!(issues[0]["location"]["lines"]["begin"], 5);
192        assert_eq!(issues[1]["check_name"], "MD013");
193        assert_eq!(issues[1]["location"]["lines"]["begin"], 10);
194    }
195
196    #[test]
197    fn test_format_warning_unknown_rule() {
198        let formatter = GitLabFormatter::new();
199        let warnings = vec![LintWarning {
200            line: 1,
201            column: 1,
202            end_line: 1,
203            end_column: 5,
204            rule_name: None,
205            message: "Unknown rule warning".to_string(),
206            severity: Severity::Warning,
207            fix: None,
208        }];
209
210        let output = formatter.format_warnings(&warnings, "file.md");
211        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
212
213        assert_eq!(issues[0]["check_name"], "unknown");
214        assert_eq!(issues[0]["fingerprint"], "file.md-1-1-unknown");
215    }
216
217    #[test]
218    fn test_gitlab_report_empty() {
219        let warnings = vec![];
220        let output = format_gitlab_report(&warnings);
221        assert_eq!(output, "[]");
222    }
223
224    #[test]
225    fn test_gitlab_report_single_file() {
226        let warnings = vec![(
227            "test.md".to_string(),
228            vec![LintWarning {
229                line: 10,
230                column: 5,
231                end_line: 10,
232                end_column: 15,
233                rule_name: Some("MD001".to_string()),
234                message: "Test warning".to_string(),
235                severity: Severity::Warning,
236                fix: None,
237            }],
238        )];
239
240        let output = format_gitlab_report(&warnings);
241        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
242
243        assert_eq!(issues.len(), 1);
244        assert_eq!(issues[0]["location"]["path"], "test.md");
245    }
246
247    #[test]
248    fn test_gitlab_report_multiple_files() {
249        let warnings = vec![
250            (
251                "file1.md".to_string(),
252                vec![LintWarning {
253                    line: 1,
254                    column: 1,
255                    end_line: 1,
256                    end_column: 5,
257                    rule_name: Some("MD001".to_string()),
258                    message: "Warning in file 1".to_string(),
259                    severity: Severity::Warning,
260                    fix: None,
261                }],
262            ),
263            (
264                "file2.md".to_string(),
265                vec![
266                    LintWarning {
267                        line: 5,
268                        column: 1,
269                        end_line: 5,
270                        end_column: 10,
271                        rule_name: Some("MD013".to_string()),
272                        message: "Warning 1 in file 2".to_string(),
273                        severity: Severity::Warning,
274                        fix: None,
275                    },
276                    LintWarning {
277                        line: 10,
278                        column: 1,
279                        end_line: 10,
280                        end_column: 10,
281                        rule_name: Some("MD022".to_string()),
282                        message: "Warning 2 in file 2".to_string(),
283                        severity: Severity::Error,
284                        fix: None,
285                    },
286                ],
287            ),
288        ];
289
290        let output = format_gitlab_report(&warnings);
291        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
292
293        assert_eq!(issues.len(), 3);
294        assert_eq!(issues[0]["location"]["path"], "file1.md");
295        assert_eq!(issues[1]["location"]["path"], "file2.md");
296        assert_eq!(issues[2]["location"]["path"], "file2.md");
297    }
298
299    #[test]
300    fn test_fingerprint_uniqueness() {
301        let formatter = GitLabFormatter::new();
302
303        // Same line/column but different rules should have different fingerprints
304        let warnings = vec![
305            LintWarning {
306                line: 10,
307                column: 5,
308                end_line: 10,
309                end_column: 15,
310                rule_name: Some("MD001".to_string()),
311                message: "First rule".to_string(),
312                severity: Severity::Warning,
313                fix: None,
314            },
315            LintWarning {
316                line: 10,
317                column: 5,
318                end_line: 10,
319                end_column: 15,
320                rule_name: Some("MD002".to_string()),
321                message: "Second rule".to_string(),
322                severity: Severity::Warning,
323                fix: None,
324            },
325        ];
326
327        let output = formatter.format_warnings(&warnings, "test.md");
328        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
329
330        assert_ne!(issues[0]["fingerprint"], issues[1]["fingerprint"]);
331        assert_eq!(issues[0]["fingerprint"], "test.md-10-5-MD001");
332        assert_eq!(issues[1]["fingerprint"], "test.md-10-5-MD002");
333    }
334
335    #[test]
336    fn test_severity_always_minor() {
337        let formatter = GitLabFormatter::new();
338
339        // Test that all severities are output as "minor" in GitLab format
340        let warnings = vec![
341            LintWarning {
342                line: 1,
343                column: 1,
344                end_line: 1,
345                end_column: 5,
346                rule_name: Some("MD001".to_string()),
347                message: "Warning severity".to_string(),
348                severity: Severity::Warning,
349                fix: None,
350            },
351            LintWarning {
352                line: 2,
353                column: 1,
354                end_line: 2,
355                end_column: 5,
356                rule_name: Some("MD002".to_string()),
357                message: "Error severity".to_string(),
358                severity: Severity::Error,
359                fix: None,
360            },
361        ];
362
363        let output = formatter.format_warnings(&warnings, "test.md");
364        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
365
366        // Both should use severity "minor" regardless of actual severity
367        assert_eq!(issues[0]["severity"], "minor");
368        assert_eq!(issues[1]["severity"], "minor");
369    }
370
371    #[test]
372    fn test_special_characters_in_message() {
373        let formatter = GitLabFormatter::new();
374        let warnings = vec![LintWarning {
375            line: 1,
376            column: 1,
377            end_line: 1,
378            end_column: 5,
379            rule_name: Some("MD001".to_string()),
380            message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
381            severity: Severity::Warning,
382            fix: None,
383        }];
384
385        let output = formatter.format_warnings(&warnings, "test.md");
386        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
387
388        // JSON should properly handle special characters
389        assert_eq!(
390            issues[0]["description"],
391            "Warning with \"quotes\" and 'apostrophes' and \n newline"
392        );
393    }
394
395    #[test]
396    fn test_special_characters_in_file_path() {
397        let formatter = GitLabFormatter::new();
398        let warnings = vec![LintWarning {
399            line: 1,
400            column: 1,
401            end_line: 1,
402            end_column: 5,
403            rule_name: Some("MD001".to_string()),
404            message: "Test".to_string(),
405            severity: Severity::Warning,
406            fix: None,
407        }];
408
409        let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
410        let issues: Vec<Value> = serde_json::from_str(&output).unwrap();
411
412        assert_eq!(issues[0]["location"]["path"], "path/with spaces/and-dashes.md");
413        assert_eq!(issues[0]["fingerprint"], "path/with spaces/and-dashes.md-1-1-MD001");
414    }
415
416    #[test]
417    fn test_json_pretty_formatting() {
418        let formatter = GitLabFormatter::new();
419        let warnings = vec![LintWarning {
420            line: 1,
421            column: 1,
422            end_line: 1,
423            end_column: 5,
424            rule_name: Some("MD001".to_string()),
425            message: "Test".to_string(),
426            severity: Severity::Warning,
427            fix: None,
428        }];
429
430        let output = formatter.format_warnings(&warnings, "test.md");
431
432        // Check that output is pretty-printed (contains newlines and indentation)
433        assert!(output.contains('\n'));
434        assert!(output.contains("  "));
435    }
436}