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
17pub 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}