1use crate::rules::{Finding, Severity};
2use colored::*;
3use serde::Serialize;
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub enum OutputFormat {
8 Terminal,
9 Json,
10 Github,
11 Compact,
12 Sarif,
13}
14
15impl std::str::FromStr for OutputFormat {
16 type Err = String;
17 fn from_str(s: &str) -> Result<Self, Self::Err> {
18 match s.to_lowercase().as_str() {
19 "terminal" | "tty" => Ok(OutputFormat::Terminal),
20 "json" => Ok(OutputFormat::Json),
21 "github" | "gh" => Ok(OutputFormat::Github),
22 "compact" => Ok(OutputFormat::Compact),
23 "sarif" => Ok(OutputFormat::Sarif),
24 other => Err(format!("unknown format '{}'", other)),
25 }
26 }
27}
28
29#[derive(Serialize)]
30struct JsonFinding {
31 rule: String,
32 severity: String,
33 line: usize,
34 message: String,
35 roast: String,
36}
37
38#[derive(Serialize)]
39struct JsonOutput {
40 file: String,
41 total: usize,
42 errors: usize,
43 warnings: usize,
44 infos: usize,
45 findings: Vec<JsonFinding>,
46}
47
48pub fn print_findings(file: &str, findings: &[Finding], format: OutputFormat, no_roast: bool) {
49 match format {
50 OutputFormat::Terminal => print_terminal(file, findings, no_roast),
51 OutputFormat::Json => print_json(file, findings),
52 OutputFormat::Github => print_github(file, findings),
53 OutputFormat::Compact => print_compact(file, findings),
54 OutputFormat::Sarif => unreachable!("SARIF output is handled via print_sarif, not print_findings"),
55 }
56}
57
58fn severity_color(s: &Severity) -> ColoredString {
59 match s {
60 Severity::Error => "ERROR".red().bold(),
61 Severity::Warning => "WARN ".yellow().bold(),
62 Severity::Info => "INFO ".cyan(),
63 }
64}
65
66fn print_terminal(file: &str, findings: &[Finding], no_roast: bool) {
67 if findings.is_empty() {
68 println!(
69 "\n {} {}\n",
70 "✓".green().bold(),
71 format!("{} passed with no issues. Impressive restraint.", file).green()
72 );
73 return;
74 }
75
76 println!("\n {} {}\n", "🔥".bold(), format!("Roasting {}...", file).bold());
77
78 for f in findings {
79 let line_info = if f.line > 0 {
80 format!("{}:{}", file, f.line).dimmed().to_string()
81 } else {
82 file.dimmed().to_string()
83 };
84
85 println!(
86 " {} [{}] {}",
87 severity_color(&f.severity),
88 f.rule.dimmed(),
89 f.message.bold()
90 );
91 println!(" {} at {}", " ".repeat(5), line_info);
92 if !no_roast {
93 println!(
94 " {} {} {}\n",
95 " ".repeat(5),
96 "💬".dimmed(),
97 format!("\"{}\"", f.roast).italic().dimmed()
98 );
99 } else {
100 println!();
101 }
102 }
103
104 let errors = findings.iter().filter(|f| f.severity == Severity::Error).count();
105 let warnings = findings.iter().filter(|f| f.severity == Severity::Warning).count();
106 let infos = findings.iter().filter(|f| f.severity == Severity::Info).count();
107
108 println!(
109 " {} {} error(s), {} warning(s), {} info(s)",
110 "Summary:".bold(),
111 errors.to_string().red().bold(),
112 warnings.to_string().yellow().bold(),
113 infos.to_string().cyan()
114 );
115
116 if errors > 0 {
117 println!("\n {} This Dockerfile is a liability. Fix the errors.", "💀".bold());
118 } else if warnings > 0 {
119 println!("\n {} Could be worse. Could also be much better.", "🤔".bold());
120 } else {
121 println!("\n {} Only informational findings. You're almost competent.", "📝".bold());
122 }
123 println!();
124}
125
126fn print_json(file: &str, findings: &[Finding]) {
127 let errors = findings.iter().filter(|f| f.severity == Severity::Error).count();
128 let warnings = findings.iter().filter(|f| f.severity == Severity::Warning).count();
129 let infos = findings.iter().filter(|f| f.severity == Severity::Info).count();
130 let out = JsonOutput {
131 file: file.to_string(),
132 total: findings.len(),
133 errors,
134 warnings,
135 infos,
136 findings: findings.iter().map(|f| JsonFinding {
137 rule: f.rule.to_string(),
138 severity: f.severity.to_string(),
139 line: f.line,
140 message: f.message.clone(),
141 roast: f.roast.clone(),
142 }).collect(),
143 };
144 println!("{}", serde_json::to_string_pretty(&out).unwrap());
145}
146
147fn print_github(file: &str, findings: &[Finding]) {
148 for f in findings {
149 let level = match f.severity {
150 Severity::Error => "error",
151 Severity::Warning => "warning",
152 Severity::Info => "notice",
153 };
154 let line_part = if f.line > 0 { format!(",line={}", f.line) } else { String::new() };
155 println!(
156 "::{} file={}{},title=[{}] {}::{}",
157 level, file, line_part, f.rule, f.message, f.roast
158 );
159 }
160}
161
162fn print_compact(file: &str, findings: &[Finding]) {
163 for f in findings {
164 let line_info = if f.line > 0 { format!(":{}", f.line) } else { String::new() };
165 println!("{}{}:{} [{}] {}", file, line_info, f.severity, f.rule, f.message);
166 }
167}
168
169pub fn print_sarif(results: &[(&str, &[Finding])]) {
177 println!("{}", build_sarif(results));
178}
179
180fn build_sarif(results: &[(&str, &[Finding])]) -> String {
181 let all_rule_meta = crate::rules::all_rules();
182 let rule_desc: HashMap<&str, &str> = all_rule_meta
183 .iter()
184 .map(|r| (r.id, r.description))
185 .collect();
186
187 let mut seen_ids = std::collections::BTreeSet::new();
190 for (_, findings) in results {
191 for f in *findings {
192 seen_ids.insert(f.rule);
193 }
194 }
195 let rule_ids: Vec<&str> = seen_ids.into_iter().collect();
196
197 let rule_index: HashMap<&str, usize> = rule_ids
199 .iter()
200 .enumerate()
201 .map(|(i, &id)| (id, i))
202 .collect();
203
204 let mut rule_max_sev: HashMap<&str, Severity> = HashMap::new();
206 for (_, findings) in results {
207 for f in *findings {
208 let entry = rule_max_sev.entry(f.rule).or_insert(Severity::Info);
209 if f.severity > *entry {
210 *entry = f.severity.clone();
211 }
212 }
213 }
214
215 let sarif_rules: Vec<serde_json::Value> = rule_ids
217 .iter()
218 .map(|&id| {
219 let desc = rule_desc.get(id).copied().unwrap_or(id);
220 let level = sarif_level(rule_max_sev.get(id).unwrap_or(&Severity::Info));
221 serde_json::json!({
222 "id": id,
223 "name": id,
224 "shortDescription": { "text": desc },
225 "helpUri": "https://github.com/immanuwell/dockerfile-roast",
226 "defaultConfiguration": { "level": level }
227 })
228 })
229 .collect();
230
231 let mut sarif_results: Vec<serde_json::Value> = Vec::new();
233 for (file, findings) in results {
234 let uri = normalize_uri(file);
235 for f in *findings {
236 let idx = *rule_index.get(f.rule).unwrap_or(&0);
237 let mut result = serde_json::json!({
238 "ruleId": f.rule,
239 "ruleIndex": idx,
240 "level": sarif_level(&f.severity),
241 "message": { "text": f.message },
242 "locations": [{
243 "physicalLocation": {
244 "artifactLocation": {
245 "uri": uri,
246 "uriBaseId": "%SRCROOT%"
247 }
248 }
249 }]
250 });
251 if f.line > 0 {
253 result["locations"][0]["physicalLocation"]["region"] =
254 serde_json::json!({ "startLine": f.line });
255 }
256 sarif_results.push(result);
257 }
258 }
259
260 let artifacts: Vec<serde_json::Value> = results
262 .iter()
263 .map(|(file, _)| {
264 serde_json::json!({
265 "location": {
266 "uri": normalize_uri(file),
267 "uriBaseId": "%SRCROOT%"
268 }
269 })
270 })
271 .collect();
272
273 let doc = serde_json::json!({
274 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
275 "version": "2.1.0",
276 "runs": [{
277 "tool": {
278 "driver": {
279 "name": "droast",
280 "version": env!("CARGO_PKG_VERSION"),
281 "informationUri": "https://github.com/immanuwell/dockerfile-roast",
282 "rules": sarif_rules
283 }
284 },
285 "results": sarif_results,
286 "artifacts": artifacts
287 }]
288 });
289
290 serde_json::to_string_pretty(&doc).unwrap()
291}
292
293fn sarif_level(sev: &Severity) -> &'static str {
296 match sev {
297 Severity::Error => "error",
298 Severity::Warning => "warning",
299 Severity::Info => "note",
300 }
301}
302
303fn normalize_uri(path: &str) -> String {
306 let p = std::path::Path::new(path);
307 let relative = if p.is_absolute() {
308 std::env::current_dir()
309 .ok()
310 .and_then(|cwd| p.strip_prefix(&cwd).ok().map(|r| r.to_path_buf()))
311 .unwrap_or_else(|| p.to_path_buf())
312 } else {
313 p.to_path_buf()
314 };
315 relative.to_string_lossy().replace('\\', "/")
316}
317
318pub fn print_summary_header() {
319 println!(
320 "\n{}",
321 r#"
322 ██████╗ ██████╗ ██████╗ █████╗ ███████╗████████╗
323 ██╔══██╗██╔══██╗██╔═══██╗██╔══██╗██╔════╝╚══██╔══╝
324 ██║ ██║██████╔╝██║ ██║███████║███████╗ ██║
325 ██║ ██║██╔══██╗██║ ██║██╔══██║╚════██║ ██║
326 ██████╔╝██║ ██║╚██████╔╝██║ ██║███████║ ██║
327 ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝
328 Dockerfile linter with personality
329"#
330 .bold()
331 .red()
332 );
333}