use forge::signal::compactor;
use once_cell::sync::Lazy;
use regex::Regex;
static TEXT_FINDING_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^([^:]+:\d+) (DL\d+|SC\d+) (error|warning|info|style): (.+)$").unwrap()
});
pub fn compress_hadolint(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
if cleaned.trim_start().starts_with('[') && cleaned.contains("\"code\"") {
return compress_hadolint_json(&cleaned);
}
if cleaned.trim().is_empty() {
return "hadolint: no issues".to_string();
}
let mut errors: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let mut info_count = 0usize;
for line in cleaned.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
if let Some(caps) = TEXT_FINDING_RE.captures(t) {
let loc = &caps[1];
let code = &caps[2];
let level = &caps[3];
let msg = &caps[4];
let msg_clean = msg
.split("https://")
.next()
.unwrap_or(msg)
.trim_end_matches('.')
.trim();
let entry = format!("{loc} {code}: {msg_clean}");
match level {
"error" => errors.push(entry),
"warning" => warnings.push(entry),
_ => info_count += 1,
}
}
}
let total = errors.len() + warnings.len() + info_count;
if total == 0 {
return "hadolint: no issues".to_string();
}
let error_count = errors.len();
let warning_count = warnings.len();
let mut result: Vec<String> = errors;
result.extend(warnings);
if info_count > 0 {
result.push(format!("({info_count} info/style issues suppressed)"));
}
result.push(format!(
"hadolint: {warning_count} warnings, {error_count} errors",
));
result.join("\n")
}
fn compress_hadolint_json(raw: &str) -> String {
use once_cell::sync::Lazy;
use regex::Regex;
static CODE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#""code"\s*:\s*"([^"]+)""#).unwrap());
static LEVEL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#""level"\s*:\s*"([^"]+)""#).unwrap());
static MSG_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#""message"\s*:\s*"([^"]+)""#).unwrap());
static LINE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#""line"\s*:\s*(\d+)"#).unwrap());
let codes: Vec<&str> = CODE_RE
.captures_iter(raw)
.filter_map(|c| c.get(1).map(|m| m.as_str()))
.collect();
let levels: Vec<&str> = LEVEL_RE
.captures_iter(raw)
.filter_map(|c| c.get(1).map(|m| m.as_str()))
.collect();
let msgs: Vec<&str> = MSG_RE
.captures_iter(raw)
.filter_map(|c| c.get(1).map(|m| m.as_str()))
.collect();
let lines: Vec<&str> = LINE_RE
.captures_iter(raw)
.filter_map(|c| c.get(1).map(|m| m.as_str()))
.collect();
if codes.is_empty() {
return "hadolint [json]: no issues".to_string();
}
let mut result: Vec<String> = codes
.iter()
.zip(levels.iter().chain(std::iter::repeat(&"warn")))
.zip(lines.iter().chain(std::iter::repeat(&"?")))
.zip(msgs.iter().chain(std::iter::repeat(&"")))
.filter(|(((_, level), _), _)| **level != "info" && **level != "style")
.map(|(((code, level), line), msg)| format!(":{line} {code} [{level}]: {msg}"))
.collect();
let info_count = codes
.iter()
.zip(levels.iter())
.filter(|(_, l)| **l == "info" || **l == "style")
.count();
if info_count > 0 {
result.push(format!("({info_count} info/style suppressed)"));
}
result.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_mode_groups_by_severity() {
let raw = "Dockerfile:3 DL3008 warning: Pin versions in apt get install.\nDockerfile:10 DL3009 warning: Delete the apt-get lists after installing.\nDockerfile:15 DL3007 error: Always tag the version.\n";
let out = compress_hadolint(&raw);
assert!(out.contains("DL3007") || out.contains("error"), "{out}");
assert!(out.contains("DL3008") || out.contains("warning"), "{out}");
}
#[test]
fn no_issues_clean() {
let out = compress_hadolint("");
assert!(out.contains("no issues"), "{out}");
}
#[test]
fn json_mode_extracts_codes() {
let raw = r#"[{"code":"DL3008","column":1,"file":"Dockerfile","level":"warning","line":3,"message":"Pin versions in apt get install."},{"code":"DL3007","column":1,"file":"Dockerfile","level":"error","line":15,"message":"Always tag the version."}]"#;
let out = compress_hadolint(raw);
assert!(out.contains("DL3008") || out.contains("warning"), "{out}");
}
}