1use crate::format::Formatter;
5use crate::lint::LintResult;
6use crate::types::FileResult;
7use serde::Serialize;
8use std::env;
9use std::hash::{DefaultHasher, Hash, Hasher};
10use std::path::PathBuf;
11
12pub struct GitlabFormatter {
13 pretty: bool,
14}
15
16impl GitlabFormatter {
17 #[must_use]
18 pub fn new(pretty: bool) -> Self {
19 Self { pretty }
20 }
21}
22
23fn file_violations(file_result: &FileResult, path_relative: &str) -> Vec<GitlabViolation> {
24 file_result
25 .violations
26 .iter()
27 .map(|violation| {
28 let key = format!("{}:{}", path_relative, violation.line);
29 GitlabViolation {
30 description: violation.message.clone(),
31 check_name: violation.rule.clone(),
32 fingerprint: create_fingerprint(&key),
33 location: GitlabLocation {
34 path: path_relative.to_owned(),
35 lines: GitlabLines {
36 begin: violation.line,
37 },
38 },
39 severity: if violation.fix.is_some() {
40 Severity::Minor
41 } else {
42 Severity::Major
43 },
44 }
45 })
46 .collect()
47}
48
49fn create_fingerprint(input: &str) -> String {
50 let mut hasher = DefaultHasher::new();
51 input.hash(&mut hasher);
52 let hash_value = hasher.finish();
53 format!("{hash_value:x}")
54}
55
56#[derive(Serialize)]
57#[serde(rename_all = "lowercase")]
58enum Severity {
59 Minor,
60 Major,
61}
62
63#[derive(Serialize)]
64struct GitlabViolation {
65 description: String,
66 check_name: String,
67 fingerprint: String,
68 location: GitlabLocation,
69 severity: Severity,
70}
71
72#[derive(Serialize)]
73struct GitlabLocation {
74 path: String,
75 lines: GitlabLines,
76}
77
78#[derive(Serialize)]
79struct GitlabLines {
80 begin: usize,
81}
82
83impl Formatter for GitlabFormatter {
84 fn format(&self, result: &LintResult) -> String {
85 let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from(""));
86 let violations: Vec<GitlabViolation> = result
87 .file_results
88 .iter()
89 .flat_map(|file_result| {
90 let path_relative = file_result
91 .path
92 .strip_prefix(¤t_dir)
93 .map_or_else(|_| file_result.path.clone(), std::path::Path::to_path_buf)
94 .display()
95 .to_string();
96
97 file_violations(file_result, &path_relative)
98 })
99 .collect();
100
101 if self.pretty {
102 serde_json::to_string_pretty(&violations)
103 .unwrap_or_else(|e| format!("{{\"error\": \"Failed to serialize JSON: {e}\"}}"))
104 } else {
105 serde_json::to_string(&violations)
106 .unwrap_or_else(|e| format!("{{\"error\": \"Failed to serialize JSON: {e}\"}}"))
107 }
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use crate::types::Violation;
115
116 #[test]
117 fn test_empty_result() {
118 let formatter = GitlabFormatter::new(false);
119 let result = LintResult::new();
120 let output = formatter.format(&result);
121
122 assert!(output.eq("[]"));
123 }
124
125 #[test]
126 fn test_single_violation() {
127 let formatter = GitlabFormatter::new(false);
128 let mut result = LintResult::new();
129
130 result.add_file_result(
131 PathBuf::from("test.md"),
132 vec![Violation {
133 line: 5,
134 column: Some(10),
135 rule: "MD001".to_owned(),
136 message: "Test message".to_owned(),
137 fix: None,
138 }],
139 vec![],
140 );
141
142 let output = formatter.format(&result);
143 let fingerprint = create_fingerprint("test.md:5");
144
145 assert!(output.contains("\"description\":\"Test message\""));
146 assert!(output.contains("\"check_name\":\"MD001\""));
147 assert!(output.contains(&format!("\"fingerprint\":\"{fingerprint}\"")));
148 assert!(output.contains("\"location\":{\"path\":\"test.md\","));
149 assert!(output.contains("\"lines\":{\"begin\":5"));
150 assert!(output.contains("\"severity\":\"major\""));
151 }
152
153 #[test]
154 fn test_pretty_print() {
155 let formatter = GitlabFormatter::new(true);
156 let mut result = LintResult::new();
157
158 result.add_file_result(
159 PathBuf::from("test.md"),
160 vec![Violation {
161 line: 1,
162 column: None,
163 rule: "MD001".to_owned(),
164 message: "Test".to_owned(),
165 fix: None,
166 }],
167 vec![],
168 );
169
170 let output = formatter.format(&result);
171
172 assert!(output.contains(" ") || output.contains('\n'));
174 }
175
176 #[test]
177 fn test_fixable_severity() {
178 let formatter = GitlabFormatter::new(false);
179 let mut result = LintResult::new();
180
181 result.add_file_result(
182 PathBuf::from("test.md"),
183 vec![Violation {
184 line: 1,
185 column: Some(1),
186 rule: "MD009".to_owned(),
187 message: "Trailing spaces".to_owned(),
188 fix: Some(crate::types::Fix {
189 line_start: 1,
190 line_end: 1,
191 column_start: None,
192 column_end: None,
193 replacement: "fixed".to_owned(),
194 description: "Remove trailing spaces".to_owned(),
195 }),
196 }],
197 vec![],
198 );
199
200 let output = formatter.format(&result);
201
202 assert!(output.contains("\"severity\":\"minor\""));
203 }
204}