1use crate::output::OutputFormatter;
4use crate::rule::{LintWarning, Severity};
5use serde_json::{Value, json};
6
7pub 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
33pub 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
46fn 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 }
81
82 #[test]
83 fn test_gitlab_formatter_new() {
84 let _formatter = GitLabFormatter::new();
85 }
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 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 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 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 assert!(output.contains('\n'));
415 assert!(output.contains(" "));
416 }
417}