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 { "\x1b[0m" } else { "" }
58    }
59
60    fn dim(&self) -> &'static str {
61        if self.colors { "\x1b[2m" } else { "" }
62    }
63
64    fn bold(&self) -> &'static str {
65        if self.colors { "\x1b[1m" } else { "" }
66    }
67}
68
69impl Formatter for TtyFormatter {
70    fn format<W: Write>(
71        &self,
72        result: &LintResult,
73        filename: &str,
74        writer: &mut W,
75    ) -> std::io::Result<()> {
76        if result.failures.is_empty() {
77            return Ok(());
78        }
79
80        for failure in &result.failures {
81            let color = self.severity_color(failure.severity);
82            let reset = self.reset();
83            let dim = self.dim();
84            let bold = self.bold();
85
86            // Format: filename:line severity: [code] message
87            if self.show_filename {
88                write!(writer, "{}{}{}{}:{}", bold, filename, reset, dim, reset)?;
89            }
90
91            write!(writer, "{}{}{} ", dim, failure.line, reset)?;
92
93            // Severity badge
94            let severity_str = match failure.severity {
95                Severity::Error => "error",
96                Severity::Warning => "warning",
97                Severity::Info => "info",
98                Severity::Style => "style",
99                Severity::Ignore => "ignore",
100            };
101
102            write!(writer, "{}{}{}", color, severity_str, reset)?;
103
104            // Rule code
105            write!(writer, " {}{}{}: ", dim, failure.code, reset)?;
106
107            // Message
108            writeln!(writer, "{}", failure.message)?;
109        }
110
111        // Summary line
112        let error_count = result
113            .failures
114            .iter()
115            .filter(|f| f.severity == Severity::Error)
116            .count();
117        let warning_count = result
118            .failures
119            .iter()
120            .filter(|f| f.severity == Severity::Warning)
121            .count();
122        let info_count = result
123            .failures
124            .iter()
125            .filter(|f| f.severity == Severity::Info)
126            .count();
127        let style_count = result
128            .failures
129            .iter()
130            .filter(|f| f.severity == Severity::Style)
131            .count();
132
133        writeln!(writer)?;
134
135        let mut parts = Vec::new();
136        if error_count > 0 {
137            parts.push(format!(
138                "{}{} error{}{}",
139                self.severity_color(Severity::Error),
140                error_count,
141                if error_count == 1 { "" } else { "s" },
142                self.reset()
143            ));
144        }
145        if warning_count > 0 {
146            parts.push(format!(
147                "{}{} warning{}{}",
148                self.severity_color(Severity::Warning),
149                warning_count,
150                if warning_count == 1 { "" } else { "s" },
151                self.reset()
152            ));
153        }
154        if info_count > 0 {
155            parts.push(format!(
156                "{}{} info{}",
157                self.severity_color(Severity::Info),
158                info_count,
159                self.reset()
160            ));
161        }
162        if style_count > 0 {
163            parts.push(format!(
164                "{}{} style{}",
165                self.severity_color(Severity::Style),
166                style_count,
167                self.reset()
168            ));
169        }
170
171        if !parts.is_empty() {
172            writeln!(writer, "{}", parts.join(", "))?;
173        }
174
175        Ok(())
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::analyzer::hadolint::types::CheckFailure;
183
184    #[test]
185    fn test_tty_output_no_color() {
186        let mut result = LintResult::new();
187        result.failures.push(CheckFailure::new(
188            "DL3008",
189            Severity::Warning,
190            "Pin versions in apt get install",
191            5,
192        ));
193
194        let formatter = TtyFormatter::no_color();
195        let output = formatter.format_to_string(&result, "Dockerfile");
196
197        assert!(output.contains("Dockerfile"));
198        assert!(output.contains("5"));
199        assert!(output.contains("warning"));
200        assert!(output.contains("DL3008"));
201        assert!(output.contains("Pin versions"));
202    }
203}