agentlint-cli 0.1.0

A fast, local-first linter for AI coding-agent configuration
Documentation
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,
}

/// Render a report without writing it, allowing callers to control stdout/stderr.
///
/// # Errors
///
/// Returns an error if machine-readable serialization fails.
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");
    }
}