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    pub fn new(pretty: bool) -> Self {
18        Self { pretty }
19    }
20}
21
22fn file_violations(file_result: &FileResult, path_relative: &str) -> Vec<GitlabViolation> {
23    file_result
24        .violations
25        .iter()
26        .map(|violation| {
27            let key = format!("{}:{}", path_relative, violation.line);
28            GitlabViolation {
29                description: violation.message.clone(),
30                check_name: violation.rule.clone(),
31                fingerprint: create_fingerprint(&key),
32                location: GitlabLocation {
33                    path: path_relative.to_string(),
34                    lines: GitlabLines {
35                        begin: violation.line,
36                    },
37                },
38                severity: if violation.fix.is_some() {
39                    Severity::Minor
40                } else {
41                    Severity::Major
42                },
43            }
44        })
45        .collect()
46}
47
48fn create_fingerprint(input: &str) -> String {
49    let mut hasher = DefaultHasher::new();
50    input.hash(&mut hasher);
51    let hash_value = hasher.finish();
52    format!("{:x}", hash_value)
53}
54
55#[derive(Serialize)]
56#[serde(rename_all = "lowercase")]
57enum Severity {
58    Minor,
59    Major,
60}
61
62#[derive(Serialize)]
63struct GitlabViolation {
64    description: String,
65    check_name: String,
66    fingerprint: String,
67    location: GitlabLocation,
68    severity: Severity,
69}
70
71#[derive(Serialize)]
72struct GitlabLocation {
73    path: String,
74    lines: GitlabLines,
75}
76
77#[derive(Serialize)]
78struct GitlabLines {
79    begin: usize,
80}
81
82impl Formatter for GitlabFormatter {
83    fn format(&self, result: &LintResult) -> String {
84        let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from(""));
85        let violations: Vec<GitlabViolation> = result
86            .file_results
87            .iter()
88            .flat_map(|file_result| {
89                let path_relative = file_result
90                    .path
91                    .strip_prefix(&current_dir)
92                    .map(|rel_path| rel_path.to_path_buf())
93                    .unwrap_or_else(|_| file_result.path.clone())
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_string(),
136                message: "Test message".to_string(),
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_string(),
164                message: "Test".to_string(),
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_string(),
187                message: "Trailing spaces".to_string(),
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_string(),
194                    description: "Remove trailing spaces".to_string(),
195                }),
196            }],
197            vec![],
198        );
199
200        let output = formatter.format(&result);
201
202        assert!(output.contains("\"severity\":\"minor\""));
203    }
204}