Skip to main content

dockerfile_roast/
output.rs

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
169/// Emit a SARIF 2.1.0 document covering all linted files at once.
170///
171/// SARIF is a document format — all files and findings must be collected
172/// before emission. Call this once after linting every file, not per-file.
173///
174/// Compatible with GitHub Advanced Security (`upload-sarif`), VS Code SARIF
175/// Viewer, and any tool that consumes the OASIS SARIF 2.1.0 schema.
176pub 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    // Collect the ordered, deduplicated set of rule IDs that actually fired.
188    // Sorted for deterministic output and so ruleIndex values are stable.
189    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    // ruleId → index in the rules array (ruleIndex in results must match).
198    let rule_index: HashMap<&str, usize> = rule_ids
199        .iter()
200        .enumerate()
201        .map(|(i, &id)| (id, i))
202        .collect();
203
204    // Highest severity seen per rule — used for defaultConfiguration.level.
205    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    // Build tool.driver.rules
216    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    // Build results array
232    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            // region is optional in SARIF; only add when we have a real line number.
252            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    // Artifacts — the list of scanned files (optional but useful for tooling).
261    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
293/// Map droast severity to the SARIF level string.
294/// SARIF uses "note" for informational findings, not "info".
295fn sarif_level(sev: &Severity) -> &'static str {
296    match sev {
297        Severity::Error => "error",
298        Severity::Warning => "warning",
299        Severity::Info => "note",
300    }
301}
302
303/// Convert a file path to a forward-slash URI relative to the repo root.
304/// Absolute paths are made relative by stripping the current working directory.
305fn 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}