1use 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
144const 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("<script>alert(1)</script>"),
250 "{html}"
251 );
252 assert!(html.contains("a&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}