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 => {
55 unreachable!("SARIF output is handled via print_sarif, not print_findings")
56 }
57 }
58}
59
60fn severity_color(s: &Severity) -> ColoredString {
61 match s {
62 Severity::Error => "ERROR".red().bold(),
63 Severity::Warning => "WARN ".yellow().bold(),
64 Severity::Info => "INFO ".cyan(),
65 }
66}
67
68fn print_terminal(file: &str, findings: &[Finding], no_roast: bool) {
69 if findings.is_empty() {
70 println!(
71 "\n {} {}\n",
72 "✓".green().bold(),
73 format!("{} passed with no issues. Impressive restraint.", file).green()
74 );
75 return;
76 }
77
78 println!(
79 "\n {} {}\n",
80 "🔥".bold(),
81 format!("Roasting {}...", file).bold()
82 );
83
84 for f in findings {
85 let line_info = if f.line > 0 {
86 format!("{}:{}", file, f.line).dimmed().to_string()
87 } else {
88 file.dimmed().to_string()
89 };
90
91 println!(
92 " {} [{}] {}",
93 severity_color(&f.severity),
94 f.rule.dimmed(),
95 f.message.bold()
96 );
97 println!(" {} at {}", " ".repeat(5), line_info);
98 if !no_roast {
99 println!(
100 " {} {} {}\n",
101 " ".repeat(5),
102 "💬".dimmed(),
103 format!("\"{}\"", f.roast).italic().dimmed()
104 );
105 } else {
106 println!();
107 }
108 }
109
110 let errors = findings
111 .iter()
112 .filter(|f| f.severity == Severity::Error)
113 .count();
114 let warnings = findings
115 .iter()
116 .filter(|f| f.severity == Severity::Warning)
117 .count();
118 let infos = findings
119 .iter()
120 .filter(|f| f.severity == Severity::Info)
121 .count();
122
123 println!(
124 " {} {} error(s), {} warning(s), {} info(s)",
125 "Summary:".bold(),
126 errors.to_string().red().bold(),
127 warnings.to_string().yellow().bold(),
128 infos.to_string().cyan()
129 );
130
131 if errors > 0 {
132 println!(
133 "\n {} This Dockerfile is a liability. Fix the errors.",
134 "💀".bold()
135 );
136 } else if warnings > 0 {
137 println!(
138 "\n {} Could be worse. Could also be much better.",
139 "🤔".bold()
140 );
141 } else {
142 println!(
143 "\n {} Only informational findings. You're almost competent.",
144 "📝".bold()
145 );
146 }
147 println!();
148}
149
150fn print_json(file: &str, findings: &[Finding]) {
151 println!(
152 "{}",
153 serde_json::to_string_pretty(&json_output(file, findings)).unwrap()
154 );
155}
156
157fn json_output(file: &str, findings: &[Finding]) -> JsonOutput {
158 let errors = findings
159 .iter()
160 .filter(|f| f.severity == Severity::Error)
161 .count();
162 let warnings = findings
163 .iter()
164 .filter(|f| f.severity == Severity::Warning)
165 .count();
166 let infos = findings
167 .iter()
168 .filter(|f| f.severity == Severity::Info)
169 .count();
170 JsonOutput {
171 file: file.to_string(),
172 total: findings.len(),
173 errors,
174 warnings,
175 infos,
176 findings: findings
177 .iter()
178 .map(|f| JsonFinding {
179 rule: f.rule.to_string(),
180 severity: f.severity.to_string(),
181 line: f.line,
182 message: f.message.clone(),
183 roast: f.roast.clone(),
184 })
185 .collect(),
186 }
187}
188
189pub fn print_json_results(results: &[(&str, &[Finding])]) {
192 if let [(file, findings)] = results {
193 print_json(file, findings);
194 return;
195 }
196 let output = results
197 .iter()
198 .map(|(file, findings)| json_output(file, findings))
199 .collect::<Vec<_>>();
200 println!("{}", serde_json::to_string_pretty(&output).unwrap());
201}
202
203fn print_github(file: &str, findings: &[Finding]) {
204 for f in findings {
205 let level = match f.severity {
206 Severity::Error => "error",
207 Severity::Warning => "warning",
208 Severity::Info => "notice",
209 };
210 let line_part = if f.line > 0 {
211 format!(",line={}", f.line)
212 } else {
213 String::new()
214 };
215 println!(
216 "::{} file={}{},title=[{}] {}::{}",
217 level, file, line_part, f.rule, f.message, f.roast
218 );
219 }
220}
221
222fn print_compact(file: &str, findings: &[Finding]) {
223 for f in findings {
224 let line_info = if f.line > 0 {
225 format!(":{}", f.line)
226 } else {
227 String::new()
228 };
229 println!(
230 "{}{}:{} [{}] {}",
231 file, line_info, f.severity, f.rule, f.message
232 );
233 }
234}
235
236pub fn print_sarif(results: &[(&str, &[Finding])]) {
244 println!("{}", build_sarif(results));
245}
246
247fn build_sarif(results: &[(&str, &[Finding])]) -> String {
248 let all_rule_meta = crate::rules::all_rules();
249 let rule_desc: HashMap<&str, &str> = all_rule_meta
250 .iter()
251 .map(|r| (r.id, r.description))
252 .collect();
253 let rule_categories: HashMap<&str, &[&str]> = all_rule_meta
254 .iter()
255 .map(|rule| (rule.id, rule.categories()))
256 .collect();
257
258 let mut seen_ids = std::collections::BTreeSet::new();
261 for (_, findings) in results {
262 for f in *findings {
263 seen_ids.insert(f.rule);
264 }
265 }
266 let rule_ids: Vec<&str> = seen_ids.into_iter().collect();
267
268 let rule_index: HashMap<&str, usize> = rule_ids
270 .iter()
271 .enumerate()
272 .map(|(i, &id)| (id, i))
273 .collect();
274
275 let mut rule_max_sev: HashMap<&str, Severity> = HashMap::new();
277 for (_, findings) in results {
278 for f in *findings {
279 let entry = rule_max_sev.entry(f.rule).or_insert(Severity::Info);
280 if f.severity > *entry {
281 *entry = f.severity;
282 }
283 }
284 }
285
286 let sarif_rules: Vec<serde_json::Value> = rule_ids
288 .iter()
289 .map(|&id| {
290 let desc = rule_desc.get(id).copied().unwrap_or(id);
291 let level = sarif_level(rule_max_sev.get(id).unwrap_or(&Severity::Info));
292 let categories = rule_categories.get(id).copied().unwrap_or_default();
293 serde_json::json!({
294 "id": id,
295 "name": id,
296 "shortDescription": { "text": desc },
297 "helpUri": "https://github.com/immanuwell/dockerfile-roast",
298 "defaultConfiguration": { "level": level },
299 "properties": { "tags": categories }
300 })
301 })
302 .collect();
303
304 let mut sarif_results: Vec<serde_json::Value> = Vec::new();
306 for (file, findings) in results {
307 let uri = normalize_uri(file);
308 for f in *findings {
309 let idx = *rule_index.get(f.rule).unwrap_or(&0);
310 let mut result = serde_json::json!({
311 "ruleId": f.rule,
312 "ruleIndex": idx,
313 "level": sarif_level(&f.severity),
314 "message": { "text": f.message },
315 "locations": [{
316 "physicalLocation": {
317 "artifactLocation": {
318 "uri": uri,
319 "uriBaseId": "%SRCROOT%"
320 }
321 }
322 }]
323 });
324 if f.line > 0 {
326 result["locations"][0]["physicalLocation"]["region"] =
327 serde_json::json!({ "startLine": f.line });
328 }
329 sarif_results.push(result);
330 }
331 }
332
333 let artifacts: Vec<serde_json::Value> = results
335 .iter()
336 .map(|(file, _)| {
337 serde_json::json!({
338 "location": {
339 "uri": normalize_uri(file),
340 "uriBaseId": "%SRCROOT%"
341 }
342 })
343 })
344 .collect();
345
346 let doc = serde_json::json!({
347 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
348 "version": "2.1.0",
349 "runs": [{
350 "tool": {
351 "driver": {
352 "name": "droast",
353 "version": env!("CARGO_PKG_VERSION"),
354 "informationUri": "https://github.com/immanuwell/dockerfile-roast",
355 "rules": sarif_rules
356 }
357 },
358 "results": sarif_results,
359 "artifacts": artifacts
360 }]
361 });
362
363 serde_json::to_string_pretty(&doc).unwrap()
364}
365
366fn sarif_level(sev: &Severity) -> &'static str {
369 match sev {
370 Severity::Error => "error",
371 Severity::Warning => "warning",
372 Severity::Info => "note",
373 }
374}
375
376fn normalize_uri(path: &str) -> String {
379 let p = std::path::Path::new(path);
380 let relative = if p.is_absolute() {
381 std::env::current_dir()
382 .ok()
383 .and_then(|cwd| p.strip_prefix(&cwd).ok().map(|r| r.to_path_buf()))
384 .unwrap_or_else(|| p.to_path_buf())
385 } else {
386 p.to_path_buf()
387 };
388 relative.to_string_lossy().replace('\\', "/")
389}
390
391pub fn print_summary_header() {
392 println!(
393 "\n{}",
394 r#"
395 ██████╗ ██████╗ ██████╗ █████╗ ███████╗████████╗
396 ██╔══██╗██╔══██╗██╔═══██╗██╔══██╗██╔════╝╚══██╔══╝
397 ██║ ██║██████╔╝██║ ██║███████║███████╗ ██║
398 ██║ ██║██╔══██╗██║ ██║██╔══██║╚════██║ ██║
399 ██████╔╝██║ ██║╚██████╔╝██║ ██║███████║ ██║
400 ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝
401 Dockerfile linter with personality
402"#
403 .bold()
404 .red()
405 );
406}