use super::structured::escape_xml;
use super::{DeadCodeContext, DeadCodeReporter, group_by_category, location, reasons_clause};
use crate::reporter::{ReporterError, ReporterOptions};
use crate::shared::{Style, write_report_file};
use cpd_core::deadcode::{ConfidenceLevel, Finding};
use std::fmt::Write;
use std::path::Path;
pub struct HtmlReporter {
style: Style,
tool_version: String,
}
impl HtmlReporter {
pub fn new(options: &ReporterOptions) -> Self {
Self {
style: Style::new(options.no_colors),
tool_version: options.tool_version.clone(),
}
}
}
impl DeadCodeReporter for HtmlReporter {
fn name(&self) -> &str {
"html"
}
fn report(
&self,
findings: &[Finding],
ctx: &DeadCodeContext<'_>,
output_dir: &Path,
) -> Result<(), ReporterError> {
let stats = ctx.stats;
let mut body = String::new();
let _ = write!(
body,
r#"<header><h1>Dead code</h1><p class="sub">{files} files · {symbols} declarations · {entries} entry points · {date}</p></header>
<section class="cards">
{cards}
</section>"#,
files = stats.files,
symbols = stats.symbols,
entries = stats.entry_points,
date = escape_xml(&stats.detection_date),
cards = cards(findings, stats),
);
if findings.is_empty() {
body.push_str("<section class=\"empty\"><p>No dead code found.</p></section>");
}
for (category, group) in group_by_category(findings) {
let _ = write!(
body,
"<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>",
escape_xml(category.title()),
group.len()
);
for finding in group {
let reasons = reasons_clause(finding);
let _ = write!(
body,
"<tr><td class=\"loc\">{}</td><td class=\"name\">{}</td><td class=\"num\">{}</td><td><span class=\"badge {}\">{}%</span></td><td class=\"why\">{}</td></tr>",
escape_xml(&location(finding)),
escape_xml(&display_name(finding)),
finding.lines,
level_class(finding.level()),
finding.confidence,
escape_xml(&reasons),
);
}
body.push_str("</tbody></table></section>");
}
let html = format!(
"<!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",
version = escape_xml(&self.tool_version),
);
write_report_file(
output_dir,
"basta-report.html",
html,
&self.style,
"Dead code HTML",
)?;
Ok(())
}
}
fn cards(findings: &[Finding], stats: &cpd_core::deadcode::Stats) -> String {
let mut out = String::new();
let card = |label: &str, value: String, tone: &str| {
format!(
"<div class=\"card {tone}\"><div class=\"value\">{value}</div><div class=\"label\">{label}</div></div>"
)
};
out.push_str(&card(
"findings",
findings.len().to_string(),
if findings.is_empty() { "good" } else { "warn" },
));
out.push_str(&card("dead lines", stats.dead_lines.to_string(), "plain"));
out.push_str(&card(
"of codebase",
format!("{:.1}%", stats.percentage),
"plain",
));
out.push_str(&card(
"unreached files",
stats
.files
.saturating_sub(stats.reachable_files)
.to_string(),
"plain",
));
out
}
fn display_name(finding: &Finding) -> String {
match (&finding.parent, &finding.exported_as) {
_ if finding.name.is_empty() => "—".to_string(),
(Some(parent), _) => format!("{parent}.{}", finding.name),
(None, Some(exported)) => format!("{} → {exported}", finding.name),
(None, None) => finding.name.clone(),
}
}
fn level_class(level: ConfidenceLevel) -> &'static str {
match level {
ConfidenceLevel::Certain => "certain",
ConfidenceLevel::High => "high",
ConfidenceLevel::Medium => "medium",
ConfidenceLevel::Low => "low",
}
}
const CSS: &str = "\
:root{color-scheme:light dark;--bg:#fbfbfa;--fg:#1c1b19;--muted:#6b6862;--line:#e5e2dc;--card:#fff;--accent:#b4532a}\
@media(prefers-color-scheme:dark){:root{--bg:#191817;--fg:#eceae6;--muted:#9a958c;--line:#33312e;--card:#211f1d;--accent:#e08a5f}}\
*{box-sizing:border-box}\
body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.6 ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif}\
main{max-width:1080px;margin:0 auto;padding:32px 20px 64px}\
h1{font-size:28px;margin:0 0 4px;letter-spacing:-.02em}\
h2{font-size:17px;margin:36px 0 12px;display:flex;align-items:baseline;gap:8px}\
.count{font-size:12px;font-weight:500;color:var(--muted);background:var(--card);border:1px solid var(--line);border-radius:999px;padding:1px 8px}\
.sub{color:var(--muted);margin:0}\
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:24px 0}\
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px}\
.card .value{font-size:24px;font-weight:600;letter-spacing:-.02em}\
.card .label{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.06em}\
.card.warn .value{color:var(--accent)}\
.card.good .value{color:#3f8f4f}\
table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}\
th{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)}\
td{padding:9px 12px;border-bottom:1px solid var(--line);vertical-align:top}\
tr:last-child td{border-bottom:0}\
.loc,.name{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px}\
.num{text-align:right;color:var(--muted);font-variant-numeric:tabular-nums}\
.why{color:var(--muted);font-size:13px}\
.badge{display:inline-block;font-size:11px;font-weight:600;padding:2px 7px;border-radius:999px;border:1px solid var(--line)}\
.badge.certain{background:#b4532a1a;color:var(--accent);border-color:#b4532a55}\
.badge.high{background:#b4532a12;color:var(--accent)}\
.badge.medium{color:var(--muted)}\
.badge.low{color:var(--muted);opacity:.7}\
.empty{padding:28px;text-align:center;color:var(--muted);background:var(--card);border:1px solid var(--line);border-radius:10px}\
footer{margin-top:40px;color:var(--muted);font-size:12px}\
";
#[cfg(test)]
mod tests {
use super::super::fixtures;
use super::*;
use cpd_core::deadcode::{Category, Stats};
use std::path::PathBuf;
use std::time::Duration;
fn render(findings: &[Finding], stats: &Stats) -> String {
let dir = fixtures::unique_dir("html");
let options = ReporterOptions::new(PathBuf::from("/tmp"));
let ctx = DeadCodeContext::new(stats, Duration::ZERO);
HtmlReporter::new(&options)
.report(findings, &ctx, &dir)
.unwrap();
let html = std::fs::read_to_string(dir.join("basta-report.html")).unwrap();
std::fs::remove_dir_all(&dir).ok();
html
}
#[test]
fn the_page_is_a_complete_self_contained_document() {
let stats = fixtures::stats();
let html = render(&fixtures::findings(), &stats);
assert!(html.starts_with("<!doctype html>"));
assert!(html.trim_end().ends_with("</html>"));
assert!(html.contains("<style>"), "styles are inlined, not linked");
assert!(
!html.contains("<link ") && !html.contains("<script"),
"the report must open from a CI artifact with no network"
);
assert_eq!(
html.matches("<table>").count(),
html.matches("</table>").count()
);
}
#[test]
fn every_category_present_gets_a_section() {
let stats = fixtures::stats();
let html = render(&fixtures::findings(), &stats);
for title in ["Unused files", "Unused exports", "Unused imports"] {
assert!(html.contains(title), "{title} missing");
}
assert!(
!html.contains("Unused members"),
"empty categories are omitted"
);
}
#[test]
fn a_clean_run_renders_the_empty_state() {
let stats = Stats::default();
let html = render(&[], &stats);
assert!(html.contains("No dead code found."));
assert!(html.contains("class=\"card good\""), "{html}");
}
#[test]
fn user_content_is_escaped_rather_than_injected() {
let mut finding = fixtures::finding(
Category::UnusedExport,
"src/<script>alert(1)</script>.ts",
"a&b",
85,
);
finding.message = "<img onerror=x>".into();
let stats = fixtures::stats();
let html = render(&[finding], &stats);
assert!(!html.contains("<script>alert"), "{html}");
assert!(
html.contains("<script>alert(1)</script>"),
"{html}"
);
assert!(html.contains("a&b"), "{html}");
}
#[test]
fn the_page_defines_both_colour_schemes() {
let stats = Stats::default();
let html = render(&[], &stats);
assert!(html.contains("color-scheme:light dark"));
assert!(html.contains("prefers-color-scheme:dark"));
}
#[test]
fn a_file_finding_shows_a_dash_rather_than_an_empty_name() {
let finding = fixtures::finding(Category::UnusedFile, "src/a.ts", "", 95);
assert_eq!(display_name(&finding), "—");
}
}