Skip to main content

agentlint/
output.rs

1use std::collections::BTreeMap;
2use std::fmt::Write as _;
3
4use anyhow::Result;
5use console::Style;
6
7use crate::{Diagnostic, LintReport, Severity};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
10pub enum OutputFormat {
11    Stylish,
12    Compact,
13    Json,
14    Github,
15}
16
17/// Render a report without writing it, allowing callers to control stdout/stderr.
18///
19/// # Errors
20///
21/// Returns an error if machine-readable serialization fails.
22pub fn render(
23    report: &LintReport,
24    format: OutputFormat,
25    color: bool,
26    quiet: bool,
27) -> Result<String> {
28    match format {
29        OutputFormat::Stylish => Ok(stylish(report, color, quiet)),
30        OutputFormat::Compact => Ok(compact(report, quiet)),
31        OutputFormat::Json => Ok(serde_json::to_string_pretty(report)?),
32        OutputFormat::Github => Ok(github(report, quiet)),
33    }
34}
35
36fn stylish(report: &LintReport, color: bool, quiet: bool) -> String {
37    let mut output = String::new();
38    let heading = Style::new().bold().force_styling(color);
39    let error = Style::new().red().force_styling(color);
40    let warning = Style::new().yellow().force_styling(color);
41    let info = Style::new().cyan().force_styling(color);
42    let muted = Style::new().dim().force_styling(color);
43    let _ = writeln!(
44        output,
45        "{}",
46        heading.apply_to(format!("agentlint v{}", report.version))
47    );
48    if report.diagnostics.is_empty() {
49        let _ = writeln!(
50            output,
51            "\nNo issues found. Your agent configuration looks clean."
52        );
53    } else {
54        for (file, items) in grouped_diagnostics(report, quiet) {
55            let _ = writeln!(output, "\n{}", heading.apply_to(file));
56            for diagnostic in items {
57                let (symbol, styled_rule) = match diagnostic.severity {
58                    Severity::Error => ("✗", error.apply_to(diagnostic.rule.as_str())),
59                    Severity::Warning => ("⚠", warning.apply_to(diagnostic.rule.as_str())),
60                    Severity::Info => ("i", info.apply_to(diagnostic.rule.as_str())),
61                };
62                let location = location(diagnostic);
63                let _ = writeln!(
64                    output,
65                    "  {symbol} {styled_rule:<8} {location:<7} {}",
66                    diagnostic.message
67                );
68                if let Some(suggestion) = &diagnostic.suggestion {
69                    let _ = writeln!(
70                        output,
71                        "    {}",
72                        muted.apply_to(format!("help: {suggestion}"))
73                    );
74                }
75            }
76        }
77    }
78    let shown = report
79        .diagnostics
80        .iter()
81        .filter(|item| !(quiet && item.severity == Severity::Info))
82        .count();
83    let infos = report
84        .diagnostics
85        .iter()
86        .filter(|item| item.severity == Severity::Info)
87        .count();
88    let _ = writeln!(
89        output,
90        "\n{} files checked · {} diagnostics · {} errors · {} warnings · {} suggestions",
91        report.files_scanned,
92        shown,
93        report.error_count(),
94        report.warning_count(),
95        infos
96    );
97    let _ = writeln!(
98        output,
99        "Estimated context: {} tokens ({} duplicated)",
100        report.estimated_total_tokens, report.estimated_duplicated_tokens
101    );
102    if !report.fixed_files.is_empty() {
103        let _ = writeln!(output, "Fixed {} file(s).", report.fixed_files.len());
104    }
105    output
106}
107
108fn compact(report: &LintReport, quiet: bool) -> String {
109    let mut output = String::new();
110    for diagnostic in &report.diagnostics {
111        if quiet && diagnostic.severity == Severity::Info {
112            continue;
113        }
114        let _ = writeln!(
115            output,
116            "{}:{}:{}: {} {} {}",
117            diagnostic.file.display(),
118            diagnostic.line.unwrap_or(1),
119            diagnostic.column.unwrap_or(1),
120            diagnostic.severity,
121            diagnostic.rule,
122            diagnostic.message
123        );
124    }
125    output
126}
127
128fn github(report: &LintReport, quiet: bool) -> String {
129    let mut output = String::new();
130    for diagnostic in &report.diagnostics {
131        if quiet && diagnostic.severity == Severity::Info {
132            continue;
133        }
134        let level = match diagnostic.severity {
135            Severity::Error => "error",
136            Severity::Warning => "warning",
137            Severity::Info => "notice",
138        };
139        let file = escape_property(&diagnostic.file.to_string_lossy());
140        let message = escape_data(&format!("{} {}", diagnostic.rule, diagnostic.message));
141        let _ = writeln!(
142            output,
143            "::{level} file={file},line={},col={}::{message}",
144            diagnostic.line.unwrap_or(1),
145            diagnostic.column.unwrap_or(1)
146        );
147    }
148    output
149}
150
151fn grouped_diagnostics(report: &LintReport, quiet: bool) -> BTreeMap<String, Vec<&Diagnostic>> {
152    let mut groups: BTreeMap<String, Vec<&Diagnostic>> = BTreeMap::new();
153    for diagnostic in &report.diagnostics {
154        if quiet && diagnostic.severity == Severity::Info {
155            continue;
156        }
157        groups
158            .entry(diagnostic.file.to_string_lossy().into_owned())
159            .or_default()
160            .push(diagnostic);
161    }
162    groups
163}
164
165fn location(diagnostic: &Diagnostic) -> String {
166    match (diagnostic.line, diagnostic.column) {
167        (Some(line), Some(column)) => format!("{line}:{column}"),
168        (Some(line), None) => line.to_string(),
169        _ => String::new(),
170    }
171}
172
173fn escape_property(value: &str) -> String {
174    value
175        .replace('%', "%25")
176        .replace('\r', "%0D")
177        .replace('\n', "%0A")
178        .replace(':', "%3A")
179        .replace(',', "%2C")
180}
181
182fn escape_data(value: &str) -> String {
183    value
184        .replace('%', "%25")
185        .replace('\r', "%0D")
186        .replace('\n', "%0A")
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use std::path::PathBuf;
193
194    fn report() -> LintReport {
195        LintReport {
196            version: "0.1.0",
197            root: PathBuf::from("/tmp/project"),
198            files_scanned: 1,
199            estimated_total_tokens: 10,
200            estimated_duplicated_tokens: 0,
201            diagnostics: vec![
202                Diagnostic::new("AGL202", Severity::Warning, "AGENTS.md", "Missing path").at(2, 3),
203            ],
204            fixed_files: Vec::new(),
205        }
206    }
207
208    #[test]
209    fn github_annotation_is_valid() {
210        assert_eq!(
211            github(&report(), false),
212            "::warning file=AGENTS.md,line=2,col=3::AGL202 Missing path\n"
213        );
214    }
215
216    #[test]
217    fn json_is_machine_readable() {
218        let output = render(&report(), OutputFormat::Json, false, false).expect("render");
219        let value: serde_json::Value = serde_json::from_str(&output).expect("json");
220        assert_eq!(value["diagnostics"][0]["rule"], "AGL202");
221    }
222}