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