1use std::collections::BTreeMap;
17use std::io::{IsTerminal, Write};
18use std::path::{Component, Path, PathBuf};
19
20use anyhow::Result;
21use colored::Colorize;
22
23use crate::config::ColorMode;
24use crate::reporter::Reporter;
25use crate::rules::registry::rule_meta;
26use crate::rules::{RuleId, Severity, Violation};
27
28#[derive(Debug, Clone, Default)]
31pub struct TerminalReporter {
32 color: ColorMode,
33}
34
35impl TerminalReporter {
36 pub fn new(color: ColorMode) -> Self {
38 Self { color }
39 }
40
41 fn color_on(&self) -> bool {
42 match self.color {
43 ColorMode::Always => true,
44 ColorMode::Never => false,
45 ColorMode::Auto => std::io::stdout().is_terminal(),
46 }
47 }
48}
49
50impl Reporter for TerminalReporter {
51 fn report(&self, violations: &[Violation], out: &mut dyn Write) -> Result<()> {
52 if violations.is_empty() {
53 writeln!(out, "ailint: no violations")?;
54 return Ok(());
55 }
56
57 let color_on = self.color_on();
58 let prefix = common_path_prefix(violations.iter().map(|v| v.file.as_path()));
59
60 let mut by_rule: BTreeMap<RuleId, Vec<&Violation>> = BTreeMap::new();
62 for v in violations {
63 by_rule.entry(v.rule_id).or_default().push(v);
64 }
65
66 let mut first_group = true;
67 for (rule_id, mut items) in by_rule {
68 items.sort_by(|a, b| {
69 a.file
70 .cmp(&b.file)
71 .then(a.line.unwrap_or(0).cmp(&b.line.unwrap_or(0)))
72 .then(a.column.unwrap_or(0).cmp(&b.column.unwrap_or(0)))
73 });
74
75 if !first_group {
76 writeln!(out)?;
77 }
78 first_group = false;
79
80 let sev = items[0].severity;
81 let meta = rule_meta(rule_id);
82 write_rule_header(out, rule_id, sev, items.len(), color_on)?;
83 if let Some(m) = meta {
84 if !m.description.is_empty() {
85 writeln!(out, " {}", dim(m.description, color_on))?;
86 }
87 if !m.fix_hint.is_empty() {
88 writeln!(
89 out,
90 " {} {}",
91 bold("Fix:", color_on),
92 dim(m.fix_hint, color_on)
93 )?;
94 }
95 }
96
97 let mut by_file: Vec<(PathBuf, Vec<&Violation>)> = Vec::new();
100 for v in items {
101 match by_file.last_mut() {
102 Some((p, group)) if *p == v.file => group.push(v),
103 _ => by_file.push((v.file.clone(), vec![v])),
104 }
105 }
106
107 for (file, group) in by_file {
108 let rel = strip_prefix(&file, prefix.as_deref());
109 let path_str = rel.display().to_string();
110 let refs = format_file_refs(&group);
111 if refs.is_empty() {
112 writeln!(out, " {}", dim(&path_str, color_on))?;
113 } else {
114 writeln!(out, " {} {}", dim(&path_str, color_on), refs,)?;
115 }
116 }
117 }
118
119 write_summary(out, violations, color_on)?;
120 Ok(())
121 }
122}
123
124fn write_rule_header(
125 out: &mut dyn Write,
126 id: RuleId,
127 sev: Severity,
128 count: usize,
129 color_on: bool,
130) -> Result<()> {
131 let glyph = if color_on {
132 match sev {
133 Severity::Error => "\u{2a2f}".red().bold().to_string(),
134 Severity::Warning => "\u{2a2f}".yellow().bold().to_string(),
135 Severity::Info => "\u{2139}".cyan().to_string(),
136 }
137 } else {
138 match sev {
139 Severity::Error | Severity::Warning => "x".to_string(),
140 Severity::Info => "i".to_string(),
141 }
142 };
143 let code = id.code_str();
144 let slug = id.slug;
145 let sev_str = sev.as_str();
146 let count_str = format!("{count} {}", pluralize(count, "finding", "findings"));
147 if color_on {
148 writeln!(
149 out,
150 "{} {} {} {} {} {}",
151 glyph,
152 code.bold(),
153 slug.bold(),
154 severity_colored(sev),
155 "\u{00b7}".dimmed(),
156 count_str.dimmed(),
157 )?;
158 } else {
159 writeln!(
160 out,
161 "{} {} {} {} \u{00b7} {}",
162 glyph, code, slug, sev_str, count_str,
163 )?;
164 }
165 Ok(())
166}
167
168fn severity_colored(sev: Severity) -> String {
169 match sev {
170 Severity::Error => sev.as_str().red().bold().to_string(),
171 Severity::Warning => sev.as_str().yellow().bold().to_string(),
172 Severity::Info => sev.as_str().cyan().to_string(),
173 }
174}
175
176fn write_summary(out: &mut dyn Write, violations: &[Violation], color_on: bool) -> Result<()> {
177 let (mut errors, mut warnings, mut info) = (0usize, 0usize, 0usize);
178 for v in violations {
179 match v.severity {
180 Severity::Error => errors += 1,
181 Severity::Warning => warnings += 1,
182 Severity::Info => info += 1,
183 }
184 }
185 let total = violations.len();
186 let glyph = if color_on { "\u{2a2f}" } else { "x" };
187 let summary = format!(
188 "{glyph} {total} {} ({errors} {}, {warnings} {}, {info} info)",
189 pluralize(total, "violation", "violations"),
190 pluralize(errors, "error", "errors"),
191 pluralize(warnings, "warning", "warnings"),
192 );
193
194 writeln!(out)?;
195 if color_on {
196 writeln!(out, "{}", summary.red().bold())?;
197 } else {
198 writeln!(out, "{summary}")?;
199 }
200 Ok(())
201}
202
203fn line_label(v: &Violation) -> Option<String> {
204 match (v.line, v.column) {
205 (Some(l), Some(c)) if c > 1 => Some(format!("L{l}:{c}")),
206 (Some(l), _) => Some(format!("L{l}")),
207 _ => None,
208 }
209}
210
211fn format_file_refs(group: &[&Violation]) -> String {
222 if group.is_empty() {
223 return String::new();
224 }
225 let all_no_line = group.iter().all(|v| v.line.is_none());
226 let all_no_detail = group.iter().all(|v| v.detail.is_none());
227 if all_no_line && all_no_detail {
228 return String::new();
229 }
230 let details_uniform = {
231 let first = group[0].detail.as_deref();
232 group.iter().all(|v| v.detail.as_deref() == first)
233 };
234 let mut parts: Vec<String> = Vec::with_capacity(group.len());
235 if details_uniform {
236 for v in group {
238 if let Some(lbl) = line_label(v) {
239 parts.push(lbl);
240 }
241 }
242 let joined = parts.join(", ");
243 match group[0].detail.as_deref() {
244 Some(d) if !d.is_empty() && !joined.is_empty() => format!("{joined} {d}"),
245 Some(d) if !d.is_empty() => d.to_string(),
246 _ => joined,
247 }
248 } else {
249 for v in group {
250 let label = line_label(v);
251 let detail = v.detail.as_deref();
252 match (label, detail) {
253 (Some(l), Some(d)) => parts.push(format!("{l} {d}")),
254 (Some(l), None) => parts.push(l),
255 (None, Some(d)) => parts.push(d.to_string()),
256 (None, None) => {}
257 }
258 }
259 parts.join(", ")
260 }
261}
262
263fn common_path_prefix<'a, I>(paths: I) -> Option<PathBuf>
267where
268 I: IntoIterator<Item = &'a Path>,
269{
270 let mut iter = paths.into_iter();
271 let first = iter.next()?;
272 let mut prefix: Vec<Component<'a>> = first.parent()?.components().collect();
273 for p in iter {
274 let parent = p.parent()?;
275 let mut new_len = 0;
276 for (a, b) in prefix.iter().zip(parent.components()) {
277 if a == &b {
278 new_len += 1;
279 } else {
280 break;
281 }
282 }
283 prefix.truncate(new_len);
284 if prefix.is_empty() {
285 return None;
286 }
287 }
288 if prefix.is_empty() {
289 None
290 } else {
291 Some(prefix.iter().collect())
292 }
293}
294
295fn strip_prefix(path: &Path, prefix: Option<&Path>) -> PathBuf {
296 match prefix {
297 Some(p) => path.strip_prefix(p).unwrap_or(path).to_path_buf(),
298 None => path.to_path_buf(),
299 }
300}
301
302fn pluralize(n: usize, singular: &'static str, plural: &'static str) -> &'static str {
303 if n == 1 {
304 singular
305 } else {
306 plural
307 }
308}
309
310fn dim(s: &str, color_on: bool) -> String {
311 if color_on {
312 s.dimmed().to_string()
313 } else {
314 s.to_string()
315 }
316}
317
318fn bold(s: &str, color_on: bool) -> String {
319 if color_on {
320 s.bold().to_string()
321 } else {
322 s.to_string()
323 }
324}