syncable_cli/analyzer/hadolint/formatter/
codeclimate.rs

1//! CodeClimate formatter for hadolint-rs.
2//!
3//! Outputs lint results in CodeClimate JSON format for GitLab CI integration.
4//!
5//! CodeClimate Specification: https://github.com/codeclimate/platform/blob/master/spec/analyzers/SPEC.md
6
7use crate::analyzer::hadolint::formatter::Formatter;
8use crate::analyzer::hadolint::lint::LintResult;
9use crate::analyzer::hadolint::types::Severity;
10use serde::Serialize;
11use std::io::Write;
12
13/// CodeClimate JSON output formatter for GitLab CI.
14#[derive(Debug, Clone, Default)]
15pub struct CodeClimateFormatter;
16
17impl CodeClimateFormatter {
18    /// Create a new CodeClimate formatter.
19    pub fn new() -> Self {
20        Self
21    }
22}
23
24/// CodeClimate issue structure.
25#[derive(Debug, Serialize)]
26struct CodeClimateIssue {
27    #[serde(rename = "type")]
28    issue_type: &'static str,
29    check_name: String,
30    description: String,
31    content: CodeClimateContent,
32    categories: Vec<&'static str>,
33    location: CodeClimateLocation,
34    severity: &'static str,
35    fingerprint: String,
36}
37
38#[derive(Debug, Serialize)]
39struct CodeClimateContent {
40    body: String,
41}
42
43#[derive(Debug, Serialize)]
44struct CodeClimateLocation {
45    path: String,
46    lines: CodeClimateLines,
47}
48
49#[derive(Debug, Serialize)]
50struct CodeClimateLines {
51    begin: u32,
52    end: u32,
53}
54
55fn severity_to_codeclimate(severity: Severity) -> &'static str {
56    match severity {
57        Severity::Error => "critical",
58        Severity::Warning => "major",
59        Severity::Info => "minor",
60        Severity::Style => "info",
61        Severity::Ignore => "info",
62    }
63}
64
65fn get_categories(code: &str) -> Vec<&'static str> {
66    // Categorize based on rule code prefix
67    if let Some(suffix) = code.strip_prefix("DL") {
68        // Dockerfile linting rules
69        let rule_num: u32 = suffix.parse().unwrap_or(0);
70        match rule_num {
71            // Security-related rules
72            3000..=3010 => vec!["Security", "Bug Risk"],
73            // Best practices
74            3011..=3030 => vec!["Style", "Clarity"],
75            // Performance
76            3031..=3050 => vec!["Performance"],
77            // Deprecated instructions
78            4000..=4999 => vec!["Compatibility", "Bug Risk"],
79            _ => vec!["Style"],
80        }
81    } else if code.starts_with("SC") {
82        // ShellCheck rules
83        vec!["Bug Risk", "Security"]
84    } else {
85        vec!["Style"]
86    }
87}
88
89fn generate_fingerprint(filename: &str, code: &str, line: u32) -> String {
90    use std::collections::hash_map::DefaultHasher;
91    use std::hash::{Hash, Hasher};
92
93    let mut hasher = DefaultHasher::new();
94    filename.hash(&mut hasher);
95    code.hash(&mut hasher);
96    line.hash(&mut hasher);
97    format!("{:016x}", hasher.finish())
98}
99
100fn get_help_body(code: &str) -> String {
101    if code.starts_with("DL") {
102        format!(
103            "See the hadolint wiki for more information: https://github.com/hadolint/hadolint/wiki/{}",
104            code
105        )
106    } else if code.starts_with("SC") {
107        format!(
108            "See the ShellCheck wiki for more information: https://www.shellcheck.net/wiki/{}",
109            code
110        )
111    } else {
112        "See hadolint documentation for more information.".to_string()
113    }
114}
115
116impl Formatter for CodeClimateFormatter {
117    fn format<W: Write>(
118        &self,
119        result: &LintResult,
120        filename: &str,
121        writer: &mut W,
122    ) -> std::io::Result<()> {
123        let issues: Vec<CodeClimateIssue> = result
124            .failures
125            .iter()
126            .map(|f| {
127                let code = f.code.to_string();
128                CodeClimateIssue {
129                    issue_type: "issue",
130                    check_name: code.clone(),
131                    description: f.message.clone(),
132                    content: CodeClimateContent {
133                        body: get_help_body(&code),
134                    },
135                    categories: get_categories(&code),
136                    location: CodeClimateLocation {
137                        path: filename.to_string(),
138                        lines: CodeClimateLines {
139                            begin: f.line,
140                            end: f.line,
141                        },
142                    },
143                    severity: severity_to_codeclimate(f.severity),
144                    fingerprint: generate_fingerprint(filename, &code, f.line),
145                }
146            })
147            .collect();
148
149        // CodeClimate expects newline-delimited JSON (NDJSON)
150        for issue in &issues {
151            let json = serde_json::to_string(issue).map_err(std::io::Error::other)?;
152            writeln!(writer, "{}", json)?;
153        }
154
155        Ok(())
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::analyzer::hadolint::types::CheckFailure;
163
164    #[test]
165    fn test_codeclimate_output() {
166        let mut result = LintResult::new();
167        result.failures.push(CheckFailure::new(
168            "DL3008",
169            Severity::Warning,
170            "Pin versions in apt get install",
171            5,
172        ));
173
174        let formatter = CodeClimateFormatter::new();
175        let output = formatter.format_to_string(&result, "Dockerfile");
176
177        assert!(output.contains("\"type\":\"issue\""));
178        assert!(output.contains("\"check_name\":\"DL3008\""));
179        assert!(output.contains("\"severity\":\"major\""));
180        assert!(output.contains("\"path\":\"Dockerfile\""));
181        assert!(output.contains("\"fingerprint\""));
182    }
183
184    #[test]
185    fn test_fingerprint_consistency() {
186        let fp1 = generate_fingerprint("Dockerfile", "DL3008", 5);
187        let fp2 = generate_fingerprint("Dockerfile", "DL3008", 5);
188        let fp3 = generate_fingerprint("Dockerfile", "DL3008", 6);
189
190        assert_eq!(fp1, fp2);
191        assert_ne!(fp1, fp3);
192    }
193
194    #[test]
195    fn test_categories() {
196        assert!(get_categories("DL3000").contains(&"Security"));
197        assert!(get_categories("SC2086").contains(&"Bug Risk"));
198        assert!(get_categories("DL4000").contains(&"Compatibility"));
199    }
200}