cpd-reporter 0.1.21

Output format reporters for cpd
Documentation
//! A standalone HTML page for a dead-code run.
//!
//! Self-contained on purpose: one file with its styles inlined, so it can be
//! opened from a CI artifact, attached to a review, or served from anywhere
//! without a second request.

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",
    }
}

/// Inlined styles. Both colour schemes are defined so the report is readable
/// whichever one the reader's machine is set to.
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("&lt;script&gt;alert(1)&lt;/script&gt;"),
            "{html}"
        );
        assert!(html.contains("a&amp;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), "—");
    }
}