ailint-core 1.0.1

Core library for ailint: discovery, parsing, rule engine, and reporters for AI agent guidance files.
Documentation
//! Markdown report output (e.g. for pasting into a PR comment).

use std::collections::BTreeMap;
use std::io::Write;

use anyhow::Result;

use crate::reporter::Reporter;
use crate::rules::{Severity, Violation};

const RULE_DOC_BASE: &str = "https://github.com/jamesmhall/ailint";

/// Renders violations as a Markdown table with rule-doc links.
#[derive(Debug)]
pub struct MarkdownReporter {
    version: &'static str,
}

impl Default for MarkdownReporter {
    fn default() -> Self {
        Self {
            version: crate::VERSION,
        }
    }
}

impl MarkdownReporter {
    /// Reporter that stamps `version` in the header instead of the crate version.
    pub fn with_version(version: &'static str) -> Self {
        Self { version }
    }
}

impl Reporter for MarkdownReporter {
    fn report(&self, violations: &[Violation], out: &mut dyn Write) -> Result<()> {
        writeln!(out, "# ailint report")?;
        writeln!(out)?;

        if violations.is_empty() {
            writeln!(out, "_No violations._")?;
            return Ok(());
        }

        let mut by_file: BTreeMap<&std::path::Path, Vec<&Violation>> = BTreeMap::new();
        for v in violations {
            by_file.entry(v.file.as_path()).or_default().push(v);
        }

        let (mut errors, mut warnings, mut infos) = (0usize, 0usize, 0usize);
        for v in violations {
            match v.severity {
                Severity::Error => errors += 1,
                Severity::Warning => warnings += 1,
                Severity::Info => infos += 1,
            }
        }

        writeln!(
            out,
            "**{} violations** across {} files.",
            violations.len(),
            by_file.len()
        )?;
        writeln!(out)?;

        writeln!(out, "| Severity | Count |")?;
        writeln!(out, "|----------|------:|")?;
        writeln!(out, "| error    | {:>5} |", errors)?;
        writeln!(out, "| warning  | {:>5} |", warnings)?;
        writeln!(out, "| info     | {:>5} |", infos)?;
        writeln!(out)?;

        for (path, mut file_violations) in by_file {
            file_violations.sort_by_key(|v| v.line.unwrap_or(0));
            writeln!(out, "## `{}`", path.display())?;
            writeln!(out)?;
            writeln!(out, "| Rule | Severity | Line | Message |")?;
            writeln!(out, "|------|----------|-----:|---------|")?;
            for v in file_violations {
                let sev = v.severity.as_str();
                let badge_color = match v.severity {
                    Severity::Error => "red",
                    Severity::Warning => "yellow",
                    Severity::Info => "blue",
                };
                let badge_url = format!("https://img.shields.io/badge/-{}-{}", sev, badge_color);
                let doc_url = format!("{}#{}", RULE_DOC_BASE, v.rule_id.slug);
                let line = v.line.map(|n| n.to_string()).unwrap_or_default();
                writeln!(
                    out,
                    "| [![{sev}]({badge_url})]({doc_url}) `{code}` | {sev} | {line} | {msg} |",
                    sev = sev,
                    badge_url = badge_url,
                    doc_url = doc_url,
                    code = v.rule_id.code_str(),
                    line = line,
                    msg = escape_message(&v.message),
                )?;
            }
            writeln!(out)?;
        }

        writeln!(out, "---")?;
        writeln!(out, "_Generated by ailint {}._", self.version)?;
        Ok(())
    }
}

fn escape_message(msg: &str) -> String {
    let mut out = String::with_capacity(msg.len());
    for ch in msg.chars() {
        match ch {
            '|' => out.push_str("\\|"),
            '\n' | '\r' => out.push(' '),
            other => out.push(other),
        }
    }
    out
}