use std::collections::BTreeMap;
use std::fmt::Write as _;
use anyhow::Result;
use console::Style;
use crate::{Diagnostic, LintReport, Severity};
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum OutputFormat {
Stylish,
Compact,
Json,
Github,
}
pub fn render(
report: &LintReport,
format: OutputFormat,
color: bool,
quiet: bool,
) -> Result<String> {
match format {
OutputFormat::Stylish => Ok(stylish(report, color, quiet)),
OutputFormat::Compact => Ok(compact(report, quiet)),
OutputFormat::Json => Ok(serde_json::to_string_pretty(report)?),
OutputFormat::Github => Ok(github(report, quiet)),
}
}
fn stylish(report: &LintReport, color: bool, quiet: bool) -> String {
let mut output = String::new();
let heading = Style::new().bold().force_styling(color);
let error = Style::new().red().force_styling(color);
let warning = Style::new().yellow().force_styling(color);
let info = Style::new().cyan().force_styling(color);
let muted = Style::new().dim().force_styling(color);
let _ = writeln!(
output,
"{}",
heading.apply_to(format!("agentlint v{}", report.version))
);
if report.diagnostics.is_empty() {
let _ = writeln!(
output,
"\nNo issues found. Your agent configuration looks clean."
);
} else {
for (file, items) in grouped_diagnostics(report, quiet) {
let _ = writeln!(output, "\n{}", heading.apply_to(file));
for diagnostic in items {
let (symbol, styled_rule) = match diagnostic.severity {
Severity::Error => ("✗", error.apply_to(diagnostic.rule.as_str())),
Severity::Warning => ("⚠", warning.apply_to(diagnostic.rule.as_str())),
Severity::Info => ("i", info.apply_to(diagnostic.rule.as_str())),
};
let location = location(diagnostic);
let _ = writeln!(
output,
" {symbol} {styled_rule:<8} {location:<7} {}",
diagnostic.message
);
if let Some(suggestion) = &diagnostic.suggestion {
let _ = writeln!(
output,
" {}",
muted.apply_to(format!("help: {suggestion}"))
);
}
}
}
}
let shown = report
.diagnostics
.iter()
.filter(|item| !(quiet && item.severity == Severity::Info))
.count();
let infos = report
.diagnostics
.iter()
.filter(|item| item.severity == Severity::Info)
.count();
let _ = writeln!(
output,
"\n{} files checked · {} diagnostics · {} errors · {} warnings · {} suggestions",
report.files_scanned,
shown,
report.error_count(),
report.warning_count(),
infos
);
let _ = writeln!(
output,
"Estimated context: {} tokens ({} duplicated)",
report.estimated_total_tokens, report.estimated_duplicated_tokens
);
if !report.fixed_files.is_empty() {
let _ = writeln!(output, "Fixed {} file(s).", report.fixed_files.len());
}
output
}
fn compact(report: &LintReport, quiet: bool) -> String {
let mut output = String::new();
for diagnostic in &report.diagnostics {
if quiet && diagnostic.severity == Severity::Info {
continue;
}
let _ = writeln!(
output,
"{}:{}:{}: {} {} {}",
diagnostic.file.display(),
diagnostic.line.unwrap_or(1),
diagnostic.column.unwrap_or(1),
diagnostic.severity,
diagnostic.rule,
diagnostic.message
);
}
output
}
fn github(report: &LintReport, quiet: bool) -> String {
let mut output = String::new();
for diagnostic in &report.diagnostics {
if quiet && diagnostic.severity == Severity::Info {
continue;
}
let level = match diagnostic.severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "notice",
};
let file = escape_property(&diagnostic.file.to_string_lossy());
let message = escape_data(&format!("{} {}", diagnostic.rule, diagnostic.message));
let _ = writeln!(
output,
"::{level} file={file},line={},col={}::{message}",
diagnostic.line.unwrap_or(1),
diagnostic.column.unwrap_or(1)
);
}
output
}
fn grouped_diagnostics(report: &LintReport, quiet: bool) -> BTreeMap<String, Vec<&Diagnostic>> {
let mut groups: BTreeMap<String, Vec<&Diagnostic>> = BTreeMap::new();
for diagnostic in &report.diagnostics {
if quiet && diagnostic.severity == Severity::Info {
continue;
}
groups
.entry(diagnostic.file.to_string_lossy().into_owned())
.or_default()
.push(diagnostic);
}
groups
}
fn location(diagnostic: &Diagnostic) -> String {
match (diagnostic.line, diagnostic.column) {
(Some(line), Some(column)) => format!("{line}:{column}"),
(Some(line), None) => line.to_string(),
_ => String::new(),
}
}
fn escape_property(value: &str) -> String {
value
.replace('%', "%25")
.replace('\r', "%0D")
.replace('\n', "%0A")
.replace(':', "%3A")
.replace(',', "%2C")
}
fn escape_data(value: &str) -> String {
value
.replace('%', "%25")
.replace('\r', "%0D")
.replace('\n', "%0A")
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn report() -> LintReport {
LintReport {
version: "0.1.0",
root: PathBuf::from("/tmp/project"),
files_scanned: 1,
estimated_total_tokens: 10,
estimated_duplicated_tokens: 0,
diagnostics: vec![
Diagnostic::new("AGL202", Severity::Warning, "AGENTS.md", "Missing path").at(2, 3),
],
fixed_files: Vec::new(),
}
}
#[test]
fn github_annotation_is_valid() {
assert_eq!(
github(&report(), false),
"::warning file=AGENTS.md,line=2,col=3::AGL202 Missing path\n"
);
}
#[test]
fn json_is_machine_readable() {
let output = render(&report(), OutputFormat::Json, false, false).expect("render");
let value: serde_json::Value = serde_json::from_str(&output).expect("json");
assert_eq!(value["diagnostics"][0]["rule"], "AGL202");
}
}