syncable_cli/analyzer/helmlint/formatter/
stylish.rs

1//! Stylish formatter for helmlint results.
2//!
3//! Produces human-readable colored output similar to ESLint's stylish formatter.
4
5use crate::analyzer::helmlint::lint::LintResult;
6use crate::analyzer::helmlint::types::Severity;
7use std::collections::BTreeMap;
8
9/// ANSI color codes.
10mod colors {
11    pub const RESET: &str = "\x1b[0m";
12    pub const RED: &str = "\x1b[31m";
13    pub const YELLOW: &str = "\x1b[33m";
14    pub const BLUE: &str = "\x1b[34m";
15    pub const CYAN: &str = "\x1b[36m";
16    pub const DIM: &str = "\x1b[2m";
17    pub const BOLD: &str = "\x1b[1m";
18    pub const UNDERLINE: &str = "\x1b[4m";
19}
20
21/// Format a lint result in stylish format.
22pub fn format(result: &LintResult) -> String {
23    let mut output = String::new();
24
25    // Group failures by file
26    let mut by_file: BTreeMap<String, Vec<_>> = BTreeMap::new();
27    for failure in &result.failures {
28        let file = failure.file.display().to_string();
29        by_file.entry(file).or_default().push(failure);
30    }
31
32    // Handle parse errors
33    if !result.parse_errors.is_empty() {
34        output.push_str(&format!(
35            "\n{}{}Parse Errors:{}\n",
36            colors::BOLD,
37            colors::RED,
38            colors::RESET
39        ));
40        for error in &result.parse_errors {
41            output.push_str(&format!(
42                "  {}{}{}  {}\n",
43                colors::RED,
44                "error",
45                colors::RESET,
46                error
47            ));
48        }
49        output.push('\n');
50    }
51
52    if by_file.is_empty() && result.parse_errors.is_empty() {
53        output.push_str(&format!(
54            "{}{}{}  No issues found\n",
55            colors::BOLD,
56            result.chart_path,
57            colors::RESET
58        ));
59        return output;
60    }
61
62    // Output failures grouped by file
63    for (file, failures) in by_file {
64        output.push_str(&format!(
65            "\n{}{}{}{}",
66            colors::UNDERLINE,
67            colors::BOLD,
68            file,
69            colors::RESET
70        ));
71        output.push('\n');
72
73        for failure in failures {
74            let severity_color = match failure.severity {
75                Severity::Error => colors::RED,
76                Severity::Warning => colors::YELLOW,
77                Severity::Info => colors::BLUE,
78                Severity::Style => colors::CYAN,
79                Severity::Ignore => colors::DIM,
80            };
81
82            let severity_text = match failure.severity {
83                Severity::Error => "error",
84                Severity::Warning => "warning",
85                Severity::Info => "info",
86                Severity::Style => "style",
87                Severity::Ignore => "ignore",
88            };
89
90            let location = match failure.column {
91                Some(col) => format!("{}:{}", failure.line, col),
92                None => format!("{}", failure.line),
93            };
94
95            output.push_str(&format!(
96                "  {}{}:{:>8}{}  {}  {}  {}",
97                colors::DIM,
98                location,
99                severity_color,
100                severity_text,
101                colors::RESET,
102                failure.message,
103                failure.code,
104            ));
105            output.push_str(colors::RESET);
106            output.push('\n');
107        }
108    }
109
110    // Summary
111    output.push('\n');
112    let total = result.failures.len();
113    let errors = result.error_count;
114    let warnings = result.warning_count;
115    let infos = total - errors - warnings;
116
117    if total > 0 {
118        let status_color = if errors > 0 {
119            colors::RED
120        } else {
121            colors::YELLOW
122        };
123        output.push_str(&format!(
124            "{}{}✖ {} {} ({} {}, {} {}, {} info)\n{}",
125            colors::BOLD,
126            status_color,
127            total,
128            if total == 1 { "problem" } else { "problems" },
129            errors,
130            if errors == 1 { "error" } else { "errors" },
131            warnings,
132            if warnings == 1 { "warning" } else { "warnings" },
133            infos,
134            colors::RESET,
135        ));
136    }
137
138    output
139}
140
141/// Format without colors (for non-TTY output).
142pub fn format_no_color(result: &LintResult) -> String {
143    let mut output = String::new();
144
145    // Group failures by file
146    let mut by_file: BTreeMap<String, Vec<_>> = BTreeMap::new();
147    for failure in &result.failures {
148        let file = failure.file.display().to_string();
149        by_file.entry(file).or_default().push(failure);
150    }
151
152    if !result.parse_errors.is_empty() {
153        output.push_str("\nParse Errors:\n");
154        for error in &result.parse_errors {
155            output.push_str(&format!("  error  {}\n", error));
156        }
157        output.push('\n');
158    }
159
160    if by_file.is_empty() && result.parse_errors.is_empty() {
161        output.push_str(&format!("{}  No issues found\n", result.chart_path));
162        return output;
163    }
164
165    for (file, failures) in by_file {
166        output.push_str(&format!("\n{}\n", file));
167
168        for failure in failures {
169            let severity_text = match failure.severity {
170                Severity::Error => "error",
171                Severity::Warning => "warning",
172                Severity::Info => "info",
173                Severity::Style => "style",
174                Severity::Ignore => "ignore",
175            };
176
177            let location = match failure.column {
178                Some(col) => format!("{}:{}", failure.line, col),
179                None => format!("{}", failure.line),
180            };
181
182            output.push_str(&format!(
183                "  {}:  {}  {}  {}\n",
184                location, severity_text, failure.message, failure.code
185            ));
186        }
187    }
188
189    // Summary
190    output.push('\n');
191    let total = result.failures.len();
192    let errors = result.error_count;
193    let warnings = result.warning_count;
194    let infos = total - errors - warnings;
195
196    if total > 0 {
197        output.push_str(&format!(
198            "✖ {} {} ({} {}, {} {}, {} info)\n",
199            total,
200            if total == 1 { "problem" } else { "problems" },
201            errors,
202            if errors == 1 { "error" } else { "errors" },
203            warnings,
204            if warnings == 1 { "warning" } else { "warnings" },
205            infos
206        ));
207    }
208
209    output
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::analyzer::helmlint::types::{CheckFailure, RuleCategory, Severity};
216
217    #[test]
218    fn test_stylish_format_empty() {
219        let result = LintResult::new("test-chart");
220        let output = format(&result);
221        assert!(output.contains("No issues found"));
222    }
223
224    #[test]
225    fn test_stylish_format_with_failures() {
226        let mut result = LintResult::new("test-chart");
227        result.failures.push(CheckFailure::new(
228            "HL1001",
229            Severity::Error,
230            "Missing Chart.yaml",
231            "Chart.yaml",
232            1,
233            RuleCategory::Structure,
234        ));
235        result.error_count = 1;
236
237        let output = format_no_color(&result);
238        assert!(output.contains("Chart.yaml"));
239        assert!(output.contains("error"));
240        assert!(output.contains("HL1001"));
241    }
242}