syncable_cli/analyzer/hadolint/formatter/
tty.rs

1//! TTY formatter for hadolint-rs.
2//!
3//! Outputs lint results with colored terminal output for human readability.
4//! Uses ANSI escape codes for colors.
5
6use crate::analyzer::hadolint::formatter::Formatter;
7use crate::analyzer::hadolint::lint::LintResult;
8use crate::analyzer::hadolint::types::Severity;
9use std::io::Write;
10
11/// TTY (terminal) output formatter with colors.
12#[derive(Debug, Clone)]
13pub struct TtyFormatter {
14    /// Use colors in output.
15    pub colors: bool,
16    /// Show the filename in each line.
17    pub show_filename: bool,
18}
19
20impl Default for TtyFormatter {
21    fn default() -> Self {
22        Self {
23            colors: true,
24            show_filename: true,
25        }
26    }
27}
28
29impl TtyFormatter {
30    /// Create a new TTY formatter with colors enabled.
31    pub fn new() -> Self {
32        Self::default()
33    }
34
35    /// Create a TTY formatter without colors.
36    pub fn no_color() -> Self {
37        Self {
38            colors: false,
39            show_filename: true,
40        }
41    }
42
43    fn severity_color(&self, severity: Severity) -> &'static str {
44        if !self.colors {
45            return "";
46        }
47        match severity {
48            Severity::Error => "\x1b[1;31m",   // Bold red
49            Severity::Warning => "\x1b[1;33m", // Bold yellow
50            Severity::Info => "\x1b[1;36m",    // Bold cyan
51            Severity::Style => "\x1b[1;35m",   // Bold magenta
52            Severity::Ignore => "\x1b[2m",     // Dim
53        }
54    }
55
56    fn reset(&self) -> &'static str {
57        if self.colors {
58            "\x1b[0m"
59        } else {
60            ""
61        }
62    }
63
64    fn dim(&self) -> &'static str {
65        if self.colors {
66            "\x1b[2m"
67        } else {
68            ""
69        }
70    }
71
72    fn bold(&self) -> &'static str {
73        if self.colors {
74            "\x1b[1m"
75        } else {
76            ""
77        }
78    }
79}
80
81impl Formatter for TtyFormatter {
82    fn format<W: Write>(&self, result: &LintResult, filename: &str, writer: &mut W) -> std::io::Result<()> {
83        if result.failures.is_empty() {
84            return Ok(());
85        }
86
87        for failure in &result.failures {
88            let color = self.severity_color(failure.severity);
89            let reset = self.reset();
90            let dim = self.dim();
91            let bold = self.bold();
92
93            // Format: filename:line severity: [code] message
94            if self.show_filename {
95                write!(
96                    writer,
97                    "{}{}{}{}:{}",
98                    bold, filename, reset, dim, reset
99                )?;
100            }
101
102            write!(
103                writer,
104                "{}{}{} ",
105                dim,
106                failure.line,
107                reset
108            )?;
109
110            // Severity badge
111            let severity_str = match failure.severity {
112                Severity::Error => "error",
113                Severity::Warning => "warning",
114                Severity::Info => "info",
115                Severity::Style => "style",
116                Severity::Ignore => "ignore",
117            };
118
119            write!(
120                writer,
121                "{}{}{}",
122                color, severity_str, reset
123            )?;
124
125            // Rule code
126            write!(
127                writer,
128                " {}{}{}: ",
129                dim, failure.code, reset
130            )?;
131
132            // Message
133            writeln!(writer, "{}", failure.message)?;
134        }
135
136        // Summary line
137        let error_count = result.failures.iter().filter(|f| f.severity == Severity::Error).count();
138        let warning_count = result.failures.iter().filter(|f| f.severity == Severity::Warning).count();
139        let info_count = result.failures.iter().filter(|f| f.severity == Severity::Info).count();
140        let style_count = result.failures.iter().filter(|f| f.severity == Severity::Style).count();
141
142        writeln!(writer)?;
143
144        let mut parts = Vec::new();
145        if error_count > 0 {
146            parts.push(format!(
147                "{}{} error{}{}",
148                self.severity_color(Severity::Error),
149                error_count,
150                if error_count == 1 { "" } else { "s" },
151                self.reset()
152            ));
153        }
154        if warning_count > 0 {
155            parts.push(format!(
156                "{}{} warning{}{}",
157                self.severity_color(Severity::Warning),
158                warning_count,
159                if warning_count == 1 { "" } else { "s" },
160                self.reset()
161            ));
162        }
163        if info_count > 0 {
164            parts.push(format!(
165                "{}{} info{}",
166                self.severity_color(Severity::Info),
167                info_count,
168                self.reset()
169            ));
170        }
171        if style_count > 0 {
172            parts.push(format!(
173                "{}{} style{}",
174                self.severity_color(Severity::Style),
175                style_count,
176                self.reset()
177            ));
178        }
179
180        if !parts.is_empty() {
181            writeln!(writer, "{}", parts.join(", "))?;
182        }
183
184        Ok(())
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::analyzer::hadolint::types::CheckFailure;
192
193    #[test]
194    fn test_tty_output_no_color() {
195        let mut result = LintResult::new();
196        result.failures.push(CheckFailure::new(
197            "DL3008",
198            Severity::Warning,
199            "Pin versions in apt get install",
200            5,
201        ));
202
203        let formatter = TtyFormatter::no_color();
204        let output = formatter.format_to_string(&result, "Dockerfile");
205
206        assert!(output.contains("Dockerfile"));
207        assert!(output.contains("5"));
208        assert!(output.contains("warning"));
209        assert!(output.contains("DL3008"));
210        assert!(output.contains("Pin versions"));
211    }
212}