Skip to main content

cha_core/
html_reporter.rs

1use crate::health::HealthScore;
2use crate::{Finding, Severity};
3use std::collections::BTreeMap;
4use std::fmt::Write;
5
6/// Render a self-contained HTML report with findings, health scores, and source snippets.
7pub fn render_html(
8    findings: &[Finding],
9    scores: &[HealthScore],
10    file_contents: &[(String, String)],
11) -> String {
12    let mut html = String::with_capacity(32_000);
13    let _ = write!(
14        html,
15        "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">\
16        <title>Cha Report</title><style>{CSS}</style></head><body>"
17    );
18    render_summary(&mut html, findings, scores);
19    render_scores_table(&mut html, scores);
20    let contents: BTreeMap<&str, &str> = file_contents
21        .iter()
22        .map(|(p, c)| (p.as_str(), c.as_str()))
23        .collect();
24    render_findings_section(&mut html, findings, &contents);
25    let _ = write!(html, "</body></html>");
26    html
27}
28
29fn render_summary(html: &mut String, findings: &[Finding], scores: &[HealthScore]) {
30    let (errors, warnings, hints) = count_severities(findings);
31    let total_debt: u32 = scores.iter().map(|s| s.debt_minutes).sum();
32    let _ = write!(
33        html,
34        "<header><h1>察 Cha Report</h1>\
35         <div class=\"summary\">\
36         <span class=\"badge error\">{errors} error</span>\
37         <span class=\"badge warning\">{warnings} warning</span>\
38         <span class=\"badge hint\">{hints} hint</span>\
39         <span class=\"badge debt\">~{} debt</span>\
40         </div></header>",
41        format_duration(total_debt)
42    );
43}
44
45fn render_scores_table(html: &mut String, scores: &[HealthScore]) {
46    let _ = write!(
47        html,
48        "<section><h2>Health Scores</h2><table><tr>\
49        <th>Grade</th><th>File</th><th>Debt</th><th>Lines</th></tr>"
50    );
51    for s in scores {
52        let _ = write!(
53            html,
54            "<tr class=\"grade-{g}\"><td class=\"grade\">{g}</td>\
55             <td><a href=\"#f-{id}\">{path}</a></td>\
56             <td>~{d}min</td><td>{l}</td></tr>",
57            g = s.grade,
58            id = path_id(&s.path),
59            path = esc(&s.path),
60            d = s.debt_minutes,
61            l = s.lines,
62        );
63    }
64    let _ = write!(html, "</table></section>");
65}
66
67fn render_findings_section(
68    html: &mut String,
69    findings: &[Finding],
70    contents: &BTreeMap<&str, &str>,
71) {
72    let grouped = group_by_file(findings);
73    let _ = write!(html, "<section><h2>Findings</h2>");
74    for (path, file_findings) in &grouped {
75        let _ = write!(
76            html,
77            "<details id=\"f-{id}\"><summary><strong>{path}</strong> \
78             <span class=\"count\">({n})</span></summary>",
79            id = path_id(path),
80            path = esc(path),
81            n = file_findings.len(),
82        );
83        if let Some(src) = contents.get(path.as_str()) {
84            render_source_block(html, path, src, file_findings);
85        }
86        for f in file_findings {
87            let _ = write!(
88                html,
89                "<div class=\"finding {sev}\"><span class=\"sev\">{icon}</span> \
90                 <a href=\"#{id}-L{line}\">[{name}] L{start}-{end}</a> {msg}</div>",
91                sev = sev_class(f.severity),
92                icon = sev_icon(f.severity),
93                id = path_id(path),
94                line = f.location.start_line,
95                name = esc(&f.smell_name),
96                start = f.location.start_line,
97                end = f.location.end_line,
98                msg = esc(&f.message),
99            );
100        }
101        let _ = write!(html, "</details>");
102    }
103    let _ = write!(html, "</section>");
104}
105
106fn render_source_block(html: &mut String, path: &str, src: &str, file_findings: &[&Finding]) {
107    let context = 5;
108    let highlight_lines = finding_lines(file_findings);
109    let mut visible: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
110    for &ln in &highlight_lines {
111        let start = ln.saturating_sub(context).max(1);
112        for l in start..=ln + context {
113            visible.insert(l);
114        }
115    }
116    let lines: Vec<&str> = src.lines().collect();
117    let total = lines.len();
118    let _ = write!(html, "<div class=\"source\"><table class=\"code\">");
119    let mut prev = 0usize;
120    for &ln in &visible {
121        if ln > total {
122            break;
123        }
124        if ln > prev + 1 {
125            let _ = write!(
126                html,
127                "<tr class=\"sep\"><td class=\"ln\">⋮</td><td class=\"src\"></td></tr>"
128            );
129        }
130        let cls = if highlight_lines.contains(&ln) {
131            " class=\"hl\""
132        } else {
133            ""
134        };
135        let _ = write!(
136            html,
137            "<tr{cls} id=\"{id}-L{ln}\"><td class=\"ln\">{ln}</td>\
138             <td class=\"src\">{code}</td></tr>",
139            id = path_id(path),
140            code = esc(lines[ln - 1]),
141        );
142        prev = ln;
143    }
144    let _ = write!(html, "</table></div>");
145}
146
147fn count_severities(findings: &[Finding]) -> (usize, usize, usize) {
148    let mut e = 0;
149    let mut w = 0;
150    let mut h = 0;
151    for f in findings {
152        match f.severity {
153            Severity::Error => e += 1,
154            Severity::Warning => w += 1,
155            Severity::Hint => h += 1,
156        }
157    }
158    (e, w, h)
159}
160
161fn group_by_file(findings: &[Finding]) -> BTreeMap<String, Vec<&Finding>> {
162    let mut map: BTreeMap<String, Vec<&Finding>> = BTreeMap::new();
163    for f in findings {
164        map.entry(f.location.path.to_string_lossy().to_string())
165            .or_default()
166            .push(f);
167    }
168    map
169}
170
171fn finding_lines(findings: &[&Finding]) -> std::collections::HashSet<usize> {
172    let mut set = std::collections::HashSet::new();
173    for f in findings {
174        for l in f.location.start_line..=f.location.end_line {
175            set.insert(l);
176        }
177    }
178    set
179}
180
181fn path_id(path: &str) -> String {
182    path.replace(['/', '\\', '.'], "-")
183}
184
185fn esc(s: &str) -> String {
186    s.replace('&', "&amp;")
187        .replace('<', "&lt;")
188        .replace('>', "&gt;")
189        .replace('"', "&quot;")
190}
191
192fn sev_class(s: Severity) -> &'static str {
193    match s {
194        Severity::Error => "error",
195        Severity::Warning => "warning",
196        Severity::Hint => "hint",
197    }
198}
199
200fn sev_icon(s: Severity) -> &'static str {
201    match s {
202        Severity::Error => "🔴",
203        Severity::Warning => "⚠️",
204        Severity::Hint => "ℹ️",
205    }
206}
207
208fn format_duration(minutes: u32) -> String {
209    if minutes < 60 {
210        format!("{minutes}min")
211    } else {
212        let h = minutes / 60;
213        let m = minutes % 60;
214        if m == 0 {
215            format!("{h}h")
216        } else {
217            format!("{h}h {m}min")
218        }
219    }
220}
221
222const CSS: &str = r#"
223*{margin:0;padding:0;box-sizing:border-box}
224body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,monospace;background:#0d1117;color:#c9d1d9;padding:2rem;max-width:1200px;margin:0 auto}
225header{margin-bottom:2rem}
226h1{font-size:1.8rem;margin-bottom:.5rem;color:#58a6ff}
227h2{font-size:1.3rem;margin:1.5rem 0 .8rem;color:#8b949e;border-bottom:1px solid #21262d;padding-bottom:.3rem}
228.summary{display:flex;gap:.5rem;flex-wrap:wrap}
229.badge{padding:.2rem .6rem;border-radius:4px;font-size:.85rem;font-weight:600}
230.badge.error{background:#da3633;color:#fff}
231.badge.warning{background:#d29922;color:#fff}
232.badge.hint{background:#388bfd;color:#fff}
233.badge.debt{background:#21262d;color:#8b949e}
234table{width:100%;border-collapse:collapse;margin:.5rem 0}
235th,td{text-align:left;padding:.3rem .6rem;border-bottom:1px solid #21262d}
236th{color:#8b949e;font-size:.8rem;text-transform:uppercase}
237.grade{font-weight:700;font-size:1.1rem}
238.grade-A .grade{color:#3fb950}.grade-B .grade{color:#58a6ff}.grade-C .grade{color:#d29922}.grade-D .grade{color:#f85149}.grade-F .grade{color:#da3633}
239details{margin:.8rem 0;background:#161b22;border:1px solid #21262d;border-radius:6px;overflow:hidden}
240summary{padding:.6rem 1rem;cursor:pointer;background:#161b22}
241summary:hover{background:#1c2128}
242.count{color:#8b949e;font-weight:400}
243.source{overflow-x:auto;max-height:400px;overflow-y:auto}
244.code{font-size:.8rem;width:100%}
245.code td{border:none;padding:0 .5rem;white-space:pre}
246.code .ln{color:#484f58;text-align:right;user-select:none;width:3rem;min-width:3rem}
247.code .src{color:#c9d1d9}
248.code tr.hl{background:#2d1b00}
249.code tr.hl .src{color:#f0c674}
250.code tr.sep{color:#484f58}
251.code tr.sep td{border:none;padding:.1rem .5rem}
252.finding{padding:.4rem 1rem;font-size:.85rem;border-left:3px solid}
253.finding.error{border-color:#da3633;background:#1a0000}.finding.warning{border-color:#d29922;background:#1a1500}.finding.hint{border-color:#388bfd;background:#0a1929}
254.finding a{color:#58a6ff;text-decoration:none}
255.finding a:hover{text-decoration:underline}
256.sev{margin-right:.3rem}
257"#;