Skip to main content

cpd_reporter/deadcode/
html.rs

1//! A standalone HTML page for a dead-code run.
2//!
3//! Self-contained on purpose: one file with its styles inlined, so it can be
4//! opened from a CI artifact, attached to a review, or served from anywhere
5//! without a second request.
6
7use super::structured::escape_xml;
8use super::{DeadCodeContext, DeadCodeReporter, group_by_category, location, reasons_clause};
9use crate::reporter::{ReporterError, ReporterOptions};
10use crate::shared::{Style, write_report_file};
11use cpd_core::deadcode::{ConfidenceLevel, Finding};
12use std::fmt::Write;
13use std::path::Path;
14
15pub struct HtmlReporter {
16    style: Style,
17    tool_version: String,
18}
19
20impl HtmlReporter {
21    pub fn new(options: &ReporterOptions) -> Self {
22        Self {
23            style: Style::new(options.no_colors),
24            tool_version: options.tool_version.clone(),
25        }
26    }
27}
28
29impl DeadCodeReporter for HtmlReporter {
30    fn name(&self) -> &str {
31        "html"
32    }
33
34    fn report(
35        &self,
36        findings: &[Finding],
37        ctx: &DeadCodeContext<'_>,
38        output_dir: &Path,
39    ) -> Result<(), ReporterError> {
40        let stats = ctx.stats;
41        let mut body = String::new();
42
43        let _ = write!(
44            body,
45            r#"<header><h1>Dead code</h1><p class="sub">{files} files · {symbols} declarations · {entries} entry points · {date}</p></header>
46<section class="cards">
47  {cards}
48</section>"#,
49            files = stats.files,
50            symbols = stats.symbols,
51            entries = stats.entry_points,
52            date = escape_xml(&stats.detection_date),
53            cards = cards(findings, stats),
54        );
55
56        if findings.is_empty() {
57            body.push_str("<section class=\"empty\"><p>No dead code found.</p></section>");
58        }
59        for (category, group) in group_by_category(findings) {
60            let _ = write!(
61                body,
62                "<section><h2>{} <span class=\"count\">{}</span></h2><table><thead><tr><th>Location</th><th>Name</th><th>Lines</th><th>Confidence</th><th>Why not certain</th></tr></thead><tbody>",
63                escape_xml(category.title()),
64                group.len()
65            );
66            for finding in group {
67                let reasons = reasons_clause(finding);
68                let _ = write!(
69                    body,
70                    "<tr><td class=\"loc\">{}</td><td class=\"name\">{}</td><td class=\"num\">{}</td><td><span class=\"badge {}\">{}%</span></td><td class=\"why\">{}</td></tr>",
71                    escape_xml(&location(finding)),
72                    escape_xml(&display_name(finding)),
73                    finding.lines,
74                    level_class(finding.level()),
75                    finding.confidence,
76                    escape_xml(&reasons),
77                );
78            }
79            body.push_str("</tbody></table></section>");
80        }
81
82        let html = format!(
83            "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Dead code report</title><style>{CSS}</style></head><body><main>{body}<footer>Generated by basta {version}</footer></main></body></html>\n",
84            version = escape_xml(&self.tool_version),
85        );
86        write_report_file(
87            output_dir,
88            "basta-report.html",
89            html,
90            &self.style,
91            "Dead code HTML",
92        )?;
93        Ok(())
94    }
95}
96
97fn cards(findings: &[Finding], stats: &cpd_core::deadcode::Stats) -> String {
98    let mut out = String::new();
99    let card = |label: &str, value: String, tone: &str| {
100        format!(
101            "<div class=\"card {tone}\"><div class=\"value\">{value}</div><div class=\"label\">{label}</div></div>"
102        )
103    };
104    out.push_str(&card(
105        "findings",
106        findings.len().to_string(),
107        if findings.is_empty() { "good" } else { "warn" },
108    ));
109    out.push_str(&card("dead lines", stats.dead_lines.to_string(), "plain"));
110    out.push_str(&card(
111        "of codebase",
112        format!("{:.1}%", stats.percentage),
113        "plain",
114    ));
115    out.push_str(&card(
116        "unreached files",
117        stats
118            .files
119            .saturating_sub(stats.reachable_files)
120            .to_string(),
121        "plain",
122    ));
123    out
124}
125
126fn display_name(finding: &Finding) -> String {
127    match (&finding.parent, &finding.exported_as) {
128        _ if finding.name.is_empty() => "—".to_string(),
129        (Some(parent), _) => format!("{parent}.{}", finding.name),
130        (None, Some(exported)) => format!("{} → {exported}", finding.name),
131        (None, None) => finding.name.clone(),
132    }
133}
134
135fn level_class(level: ConfidenceLevel) -> &'static str {
136    match level {
137        ConfidenceLevel::Certain => "certain",
138        ConfidenceLevel::High => "high",
139        ConfidenceLevel::Medium => "medium",
140        ConfidenceLevel::Low => "low",
141    }
142}
143
144/// Inlined styles. Both colour schemes are defined so the report is readable
145/// whichever one the reader's machine is set to.
146const CSS: &str = "\
147:root{color-scheme:light dark;--bg:#fbfbfa;--fg:#1c1b19;--muted:#6b6862;--line:#e5e2dc;--card:#fff;--accent:#b4532a}\
148@media(prefers-color-scheme:dark){:root{--bg:#191817;--fg:#eceae6;--muted:#9a958c;--line:#33312e;--card:#211f1d;--accent:#e08a5f}}\
149*{box-sizing:border-box}\
150body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.6 ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif}\
151main{max-width:1080px;margin:0 auto;padding:32px 20px 64px}\
152h1{font-size:28px;margin:0 0 4px;letter-spacing:-.02em}\
153h2{font-size:17px;margin:36px 0 12px;display:flex;align-items:baseline;gap:8px}\
154.count{font-size:12px;font-weight:500;color:var(--muted);background:var(--card);border:1px solid var(--line);border-radius:999px;padding:1px 8px}\
155.sub{color:var(--muted);margin:0}\
156.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:24px 0}\
157.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px}\
158.card .value{font-size:24px;font-weight:600;letter-spacing:-.02em}\
159.card .label{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.06em}\
160.card.warn .value{color:var(--accent)}\
161.card.good .value{color:#3f8f4f}\
162table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}\
163th{text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);font-weight:600;padding:10px 12px;border-bottom:1px solid var(--line)}\
164td{padding:9px 12px;border-bottom:1px solid var(--line);vertical-align:top}\
165tr:last-child td{border-bottom:0}\
166.loc,.name{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px}\
167.num{text-align:right;color:var(--muted);font-variant-numeric:tabular-nums}\
168.why{color:var(--muted);font-size:13px}\
169.badge{display:inline-block;font-size:11px;font-weight:600;padding:2px 7px;border-radius:999px;border:1px solid var(--line)}\
170.badge.certain{background:#b4532a1a;color:var(--accent);border-color:#b4532a55}\
171.badge.high{background:#b4532a12;color:var(--accent)}\
172.badge.medium{color:var(--muted)}\
173.badge.low{color:var(--muted);opacity:.7}\
174.empty{padding:28px;text-align:center;color:var(--muted);background:var(--card);border:1px solid var(--line);border-radius:10px}\
175footer{margin-top:40px;color:var(--muted);font-size:12px}\
176";
177
178#[cfg(test)]
179mod tests {
180    use super::super::fixtures;
181    use super::*;
182    use cpd_core::deadcode::{Category, Stats};
183    use std::path::PathBuf;
184    use std::time::Duration;
185
186    fn render(findings: &[Finding], stats: &Stats) -> String {
187        let dir = fixtures::unique_dir("html");
188        let options = ReporterOptions::new(PathBuf::from("/tmp"));
189        let ctx = DeadCodeContext::new(stats, Duration::ZERO);
190        HtmlReporter::new(&options)
191            .report(findings, &ctx, &dir)
192            .unwrap();
193        let html = std::fs::read_to_string(dir.join("basta-report.html")).unwrap();
194        std::fs::remove_dir_all(&dir).ok();
195        html
196    }
197
198    #[test]
199    fn the_page_is_a_complete_self_contained_document() {
200        let stats = fixtures::stats();
201        let html = render(&fixtures::findings(), &stats);
202        assert!(html.starts_with("<!doctype html>"));
203        assert!(html.trim_end().ends_with("</html>"));
204        assert!(html.contains("<style>"), "styles are inlined, not linked");
205        assert!(
206            !html.contains("<link ") && !html.contains("<script"),
207            "the report must open from a CI artifact with no network"
208        );
209        assert_eq!(
210            html.matches("<table>").count(),
211            html.matches("</table>").count()
212        );
213    }
214
215    #[test]
216    fn every_category_present_gets_a_section() {
217        let stats = fixtures::stats();
218        let html = render(&fixtures::findings(), &stats);
219        for title in ["Unused files", "Unused exports", "Unused imports"] {
220            assert!(html.contains(title), "{title} missing");
221        }
222        assert!(
223            !html.contains("Unused members"),
224            "empty categories are omitted"
225        );
226    }
227
228    #[test]
229    fn a_clean_run_renders_the_empty_state() {
230        let stats = Stats::default();
231        let html = render(&[], &stats);
232        assert!(html.contains("No dead code found."));
233        assert!(html.contains("class=\"card good\""), "{html}");
234    }
235
236    #[test]
237    fn user_content_is_escaped_rather_than_injected() {
238        let mut finding = fixtures::finding(
239            Category::UnusedExport,
240            "src/<script>alert(1)</script>.ts",
241            "a&b",
242            85,
243        );
244        finding.message = "<img onerror=x>".into();
245        let stats = fixtures::stats();
246        let html = render(&[finding], &stats);
247        assert!(!html.contains("<script>alert"), "{html}");
248        assert!(
249            html.contains("&lt;script&gt;alert(1)&lt;/script&gt;"),
250            "{html}"
251        );
252        assert!(html.contains("a&amp;b"), "{html}");
253    }
254
255    #[test]
256    fn the_page_defines_both_colour_schemes() {
257        let stats = Stats::default();
258        let html = render(&[], &stats);
259        assert!(html.contains("color-scheme:light dark"));
260        assert!(html.contains("prefers-color-scheme:dark"));
261    }
262
263    #[test]
264    fn a_file_finding_shows_a_dash_rather_than_an_empty_name() {
265        let finding = fixtures::finding(Category::UnusedFile, "src/a.ts", "", 95);
266        assert_eq!(display_name(&finding), "—");
267    }
268}