Skip to main content

mdlint/format/
gitlab.rs

1// GitLab Code Quality Report
2// https://docs.gitlab.com/ci/testing/code_quality/#code-quality-report-format
3
4use 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(&current_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        // Pretty print should have indentation
173        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}