Skip to main content

ailint_core/reporter/
markdown.rs

1//! Markdown report output (e.g. for pasting into a PR comment).
2
3use std::collections::BTreeMap;
4use std::io::Write;
5
6use anyhow::Result;
7
8use crate::reporter::Reporter;
9use crate::rules::registry::rule_meta;
10use crate::rules::{Severity, Violation};
11
12const RULE_DOC_BASE: &str = "https://github.com/jamesmhall/ailint";
13
14/// Renders violations as a Markdown table with rule-doc links.
15#[derive(Debug)]
16pub struct MarkdownReporter {
17    version: &'static str,
18}
19
20impl Default for MarkdownReporter {
21    fn default() -> Self {
22        Self {
23            version: crate::VERSION,
24        }
25    }
26}
27
28impl MarkdownReporter {
29    /// Reporter that stamps `version` in the header instead of the crate version.
30    pub fn with_version(version: &'static str) -> Self {
31        Self { version }
32    }
33}
34
35impl Reporter for MarkdownReporter {
36    fn report(&self, violations: &[Violation], out: &mut dyn Write) -> Result<()> {
37        writeln!(out, "# ailint report")?;
38        writeln!(out)?;
39
40        if violations.is_empty() {
41            writeln!(out, "_No violations._")?;
42            return Ok(());
43        }
44
45        let mut by_file: BTreeMap<&std::path::Path, Vec<&Violation>> = BTreeMap::new();
46        for v in violations {
47            by_file.entry(v.file.as_path()).or_default().push(v);
48        }
49
50        let (mut errors, mut warnings, mut infos) = (0usize, 0usize, 0usize);
51        for v in violations {
52            match v.severity {
53                Severity::Error => errors += 1,
54                Severity::Warning => warnings += 1,
55                Severity::Info => infos += 1,
56            }
57        }
58
59        writeln!(
60            out,
61            "**{} violations** across {} files.",
62            violations.len(),
63            by_file.len()
64        )?;
65        writeln!(out)?;
66
67        writeln!(out, "| Severity | Count |")?;
68        writeln!(out, "|----------|------:|")?;
69        writeln!(out, "| error    | {:>5} |", errors)?;
70        writeln!(out, "| warning  | {:>5} |", warnings)?;
71        writeln!(out, "| info     | {:>5} |", infos)?;
72        writeln!(out)?;
73
74        for (path, mut file_violations) in by_file {
75            file_violations.sort_by_key(|v| v.line.unwrap_or(0));
76            writeln!(out, "## `{}`", path.display())?;
77            writeln!(out)?;
78            writeln!(out, "| Rule | Severity | Line | Message | Detail | Fix |")?;
79            writeln!(out, "|------|----------|-----:|---------|--------|-----|")?;
80            for v in file_violations {
81                let sev = v.severity.as_str();
82                let badge_color = match v.severity {
83                    Severity::Error => "red",
84                    Severity::Warning => "yellow",
85                    Severity::Info => "blue",
86                };
87                let badge_url = format!("https://img.shields.io/badge/-{}-{}", sev, badge_color);
88                let doc_url = format!("{}#{}", RULE_DOC_BASE, v.rule_id.slug);
89                let line = v.line.map(|n| n.to_string()).unwrap_or_default();
90                let meta = rule_meta(v.rule_id);
91                let fix = meta.map(|m| m.fix_hint).unwrap_or("");
92                let detail = v.detail.as_deref().unwrap_or("");
93                writeln!(
94                    out,
95                    "| [![{sev}]({badge_url})]({doc_url}) `{code}` | {sev} | {line} | {msg} | {detail} | {fix} |",
96                    sev = sev,
97                    badge_url = badge_url,
98                    doc_url = doc_url,
99                    code = v.rule_id.code_str(),
100                    line = line,
101                    msg = escape_message(&v.message),
102                    detail = escape_message(detail),
103                    fix = escape_message(fix),
104                )?;
105            }
106            writeln!(out)?;
107        }
108
109        writeln!(out, "---")?;
110        writeln!(out, "_Generated by ailint {}._", self.version)?;
111        Ok(())
112    }
113}
114
115fn escape_message(msg: &str) -> String {
116    let mut out = String::with_capacity(msg.len());
117    for ch in msg.chars() {
118        match ch {
119            '|' => out.push_str("\\|"),
120            '\n' | '\r' => out.push(' '),
121            other => out.push(other),
122        }
123    }
124    out
125}