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 => {
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
189/// Emit one valid JSON document for a repository scan while preserving the
190/// historical object shape for a single Dockerfile.
191pub 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
236/// Emit a SARIF 2.1.0 document covering all linted files at once.
237///
238/// SARIF is a document format — all files and findings must be collected
239/// before emission. Call this once after linting every file, not per-file.
240///
241/// Compatible with GitHub Advanced Security (`upload-sarif`), VS Code SARIF
242/// Viewer, and any tool that consumes the OASIS SARIF 2.1.0 schema.
243pub 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    // Collect the ordered, deduplicated set of rule IDs that actually fired.
259    // Sorted for deterministic output and so ruleIndex values are stable.
260    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    // ruleId → index in the rules array (ruleIndex in results must match).
269    let rule_index: HashMap<&str, usize> = rule_ids
270        .iter()
271        .enumerate()
272        .map(|(i, &id)| (id, i))
273        .collect();
274
275    // Highest severity seen per rule — used for defaultConfiguration.level.
276    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    // Build tool.driver.rules
287    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    // Build results array
305    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            // region is optional in SARIF; only add when we have a real line number.
325            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    // Artifacts — the list of scanned files (optional but useful for tooling).
334    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
366/// Map droast severity to the SARIF level string.
367/// SARIF uses "note" for informational findings, not "info".
368fn sarif_level(sev: &Severity) -> &'static str {
369    match sev {
370        Severity::Error => "error",
371        Severity::Warning => "warning",
372        Severity::Info => "note",
373    }
374}
375
376/// Convert a file path to a forward-slash URI relative to the repo root.
377/// Absolute paths are made relative by stripping the current working directory.
378fn 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}