Skip to main content

ailint_core/reporter/
terminal.rs

1//! Human- and LLM-readable terminal output, grouped by rule and by file.
2//!
3//! Output shape:
4//!
5//! ```text
6//! ⨯ AIL040  broken-local-link                  warning · 79 findings
7//!    Markdown link points to a path that does not exist on disk.
8//!    Fix: Correct the path, or remove the link.
9//!
10//!    .claude/agents/belva-project.md · 9
11//!      L28   ../.github/skills/story-writing/SKILL.md
12//!      L29   ../.github/skills/bug-writing/SKILL.md
13//!    ...
14//! ```
15
16use 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/// Renders violations grouped by rule, then by file, in an aligned layout
29/// designed to be easy for both humans and LLMs to audit.
30#[derive(Debug, Clone, Default)]
31pub struct TerminalReporter {
32    color: ColorMode,
33}
34
35impl TerminalReporter {
36    /// Reporter with an explicit color mode instead of the default `Auto`.
37    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        // Group by rule, stable-sort inside each group by (file, line, column).
61        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            // Sub-group by file. Preserve first-seen order so violations in the
98            // same file stay contiguous and sorted by line.
99            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
211/// Render every violation in a single file group as one line's worth of
212/// references. Keeps output compact and avoids repeating rule-level text
213/// on every row.
214///
215/// Rules:
216/// * If nothing is known (no line, no detail), return "" — the file path
217///   alone communicates the hit.
218/// * If every violation has the same detail (or none), emit only line
219///   labels like `L28, L29, L30`.
220/// * Otherwise, pair each label with its detail: `L28 target1, L29 target2`.
221fn 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        // Show line labels only; the (uniform) detail, if any, goes at the end.
237        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
263/// Longest shared leading path (component-wise) across all files. Returns
264/// `None` when the set spans more than one root, or when every violation
265/// is on a file in the current directory (nothing to strip).
266fn 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}