use crate::baseline::diff::BaselineScanReport;
use crate::baseline::gate::CiGateResult;
use crate::scan::types::ScanSummary;
pub fn render(summary: &ScanSummary) -> String {
let cards = render_summary_cards(summary);
let languages_section = render_languages_section(summary);
let frameworks_section = render_frameworks_section(summary);
let findings_section = render_findings_section(summary);
render_document(
&summary.root_path.to_string_lossy(),
"",
&cards,
&languages_section,
&frameworks_section,
&findings_section,
)
}
pub fn render_with_baseline(report: &BaselineScanReport, ci_gate: Option<&CiGateResult>) -> String {
let cards = render_baseline_summary_cards(report);
let languages_section = render_languages_section(&report.summary);
let frameworks_section = render_frameworks_section(&report.summary);
let findings_section = render_baseline_findings_section(report);
let baseline_meta = render_baseline_meta(report, ci_gate);
render_document(
&report.summary.root_path.to_string_lossy(),
&baseline_meta,
&cards,
&languages_section,
&frameworks_section,
&findings_section,
)
}
fn render_document(
path: &str,
baseline_meta: &str,
cards: &str,
languages_section: &str,
frameworks_section: &str,
findings_section: &str,
) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RepoPilot Scan Report</title>
<style>
body {{ font-family: system-ui, sans-serif; margin: 0; padding: 2rem; color: #1a1a1a; background: #f8f8f8; }}
h1 {{ font-size: 1.6rem; margin-bottom: 0.25rem; }}
h2 {{ font-size: 1.1rem; margin-top: 2rem; border-bottom: 1px solid #ddd; padding-bottom: 0.3rem; }}
.meta {{ color: #666; font-size: 0.9rem; margin-bottom: 1.5rem; }}
.cards {{ display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }}
.card {{ background: #fff; border: 1px solid #e0e0e0; border-radius: 6px; padding: 1rem 1.5rem; min-width: 120px; }}
.card .num {{ font-size: 1.8rem; font-weight: 700; }}
.card .label {{ font-size: 0.75rem; color: #888; text-transform: uppercase; letter-spacing: .05em; }}
table {{ width: 100%; border-collapse: collapse; background: #fff; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px #0001; }}
th {{ text-align: left; padding: 0.6rem 1rem; background: #f0f0f0; font-size: 0.8rem; text-transform: uppercase; letter-spacing: .05em; }}
td {{ padding: 0.6rem 1rem; border-top: 1px solid #eee; vertical-align: top; font-size: 0.88rem; }}
.num {{ text-align: right; }}
.badge {{ display: inline-block; padding: 0.15rem 0.55rem; border-radius: 3px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; }}
.badge.info {{ background: #e8f4ff; color: #2563eb; }}
.badge.low {{ background: #f0fdf4; color: #16a34a; }}
.badge.medium {{ background: #fffbeb; color: #d97706; }}
.badge.high {{ background: #fff7ed; color: #ea580c; }}
.badge.critical {{ background: #fef2f2; color: #dc2626; }}
.status {{ font-weight: 600; text-transform: uppercase; font-size: 0.75rem; }}
.status.new {{ color: #b91c1c; }}
.status.existing {{ color: #166534; }}
pre.snippet {{ margin: 0.3rem 0 0; font-size: 0.8rem; background: #f5f5f5; padding: 0.4rem 0.6rem; border-radius: 4px; overflow: auto; white-space: pre-wrap; }}
.empty {{ color: #999; font-style: italic; }}
.filter-bar {{ display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.75rem; }}
.filter-btn {{ padding: 0.25rem 0.75rem; border: 1px solid #ccc; border-radius: 20px; cursor: pointer; font-size: 0.8rem; background: #fff; }}
.filter-btn.active {{ background: #1a1a1a; color: #fff; border-color: #1a1a1a; }}
</style>
</head>
<body>
<h1>RepoPilot Scan Report</h1>
<p class="meta">Path: <code>{path}</code></p>
{baseline_meta}
<div class="cards">
{cards}
</div>
<h2>Languages</h2>
{languages_section}
{frameworks_section}
<h2>Findings</h2>
<div class="filter-bar" id="filter-bar"></div>
{findings_section}
<script>
const rows = document.querySelectorAll('table#findings tbody tr');
const bar = document.getElementById('filter-bar');
const severities = [...new Set([...rows].map(r => r.querySelector('.badge').textContent.trim()))];
let active = new Set();
severities.forEach(sev => {{
const btn = document.createElement('button');
btn.className = 'filter-btn';
btn.textContent = sev;
btn.onclick = () => {{
if (active.has(sev)) {{ active.delete(sev); btn.classList.remove('active'); }}
else {{ active.add(sev); btn.classList.add('active'); }}
rows.forEach(r => {{
const rowSev = r.querySelector('.badge').textContent.trim();
r.style.display = active.size === 0 || active.has(rowSev) ? '' : 'none';
}});
}};
bar.appendChild(btn);
}});
</script>
</body>
</html>"#,
path = escape_html(path),
baseline_meta = baseline_meta,
cards = cards,
languages_section = languages_section,
frameworks_section = frameworks_section,
findings_section = findings_section,
)
}
fn render_summary_cards(summary: &ScanSummary) -> String {
let mut cards = vec![
summary_card(summary.files_count, "Files"),
summary_card(summary.directories_count, "Directories"),
summary_card(summary.lines_of_code, "Lines of Code"),
summary_card(summary.findings.len(), "Findings"),
];
if summary.skipped_files_count > 0 {
cards.push(summary_card(summary.skipped_files_count, "Skipped"));
}
cards.join("\n ")
}
fn render_baseline_summary_cards(report: &BaselineScanReport) -> String {
let mut cards = vec![
summary_card(report.summary.files_count, "Files"),
summary_card(report.summary.directories_count, "Directories"),
summary_card(report.summary.lines_of_code, "Lines of Code"),
summary_card(report.summary.findings.len(), "Findings"),
summary_card(report.new_count(), "New"),
summary_card(report.existing_count(), "Existing"),
];
if report.summary.skipped_files_count > 0 {
cards.push(summary_card(report.summary.skipped_files_count, "Skipped"));
}
cards.join("\n ")
}
fn render_baseline_meta(report: &BaselineScanReport, ci_gate: Option<&CiGateResult>) -> String {
let baseline = match &report.baseline_path {
Some(path) => format!(
"Baseline: <code>{}</code>",
escape_html(&path.to_string_lossy())
),
None => "Baseline: none (all findings treated as new)".to_string(),
};
let gate = ci_gate
.map(|ci_gate| {
let status = if ci_gate.passed() { "passed" } else { "failed" };
format!(" CI gate: {status} ({})", escape_html(&ci_gate.label()))
})
.unwrap_or_default();
format!(r#"<p class="meta">{baseline}.{gate}</p>"#)
}
fn summary_card(value: usize, label: &str) -> String {
format!(
r#"<div class="card"><div class="num">{value}</div><div class="label">{label}</div></div>"#
)
}
fn render_languages_section(summary: &ScanSummary) -> String {
if summary.languages.is_empty() {
return "<p class=\"empty\">No languages detected.</p>".to_string();
}
let rows = summary
.languages
.iter()
.map(|language| {
format!(
"<tr><td>{}</td><td class=\"num\">{}</td></tr>",
escape_html(&language.name),
language.files_count
)
})
.collect::<Vec<_>>()
.join("\n");
format!(
"<table><thead><tr><th>Language</th><th class=\"num\">Files</th></tr></thead><tbody>{rows}</tbody></table>"
)
}
fn render_frameworks_section(summary: &ScanSummary) -> String {
if summary.detected_frameworks.is_empty()
&& summary.framework_projects.is_empty()
&& summary.react_native.is_none()
{
return String::new();
}
let labels: Vec<String> = summary
.detected_frameworks
.iter()
.map(|f| {
format!(
"<span class=\"badge low\">{}</span>",
escape_html(&f.label())
)
})
.collect();
let mut output = String::from("<h2>Frameworks</h2>");
if !labels.is_empty() {
output.push_str(&format!("<p class=\"meta\">{}</p>\n", labels.join(" ")));
}
let nested_projects: Vec<_> = summary
.framework_projects
.iter()
.filter(|project| project.path.as_path() != std::path::Path::new("."))
.collect();
if !nested_projects.is_empty() {
output.push_str("<table><thead><tr><th>Path</th><th>Frameworks</th></tr></thead><tbody>");
for project in nested_projects {
let frameworks = project
.frameworks
.iter()
.map(|f| escape_html(&f.label()))
.collect::<Vec<_>>()
.join(", ");
output.push_str(&format!(
"<tr><td><code>{}</code></td><td>{}</td></tr>",
escape_html(&project.path.to_string_lossy()),
frameworks
));
}
output.push_str("</tbody></table>");
}
if let Some(rn) = &summary.react_native {
output.push_str(&format!(
"<p class=\"meta\">React Native: version {}, Android New Architecture {}, iOS New Architecture {}, Hermes {}, Codegen {}</p>\n",
escape_html(rn.react_native_version.as_deref().unwrap_or("unknown")),
escape_html(format_tristate(rn.android_new_arch_enabled)),
escape_html(format_tristate(rn.ios_new_arch_enabled)),
escape_html(format_tristate(rn.hermes_enabled)),
if rn.has_codegen_config { "found" } else { "missing" }
));
}
output
}
fn format_tristate(value: Option<bool>) -> &'static str {
match value {
Some(true) => "enabled",
Some(false) => "disabled",
None => "unknown",
}
}
fn render_findings_section(summary: &ScanSummary) -> String {
if summary.findings.is_empty() {
return "<p class=\"empty\">No findings found.</p>".to_string();
}
let rows = summary
.findings
.iter()
.map(render_finding_row)
.collect::<Vec<_>>()
.join("\n");
format!(
"<table id=\"findings\"><thead><tr><th>Severity</th><th>Rule</th><th>Title</th><th>Evidence</th></tr></thead><tbody>{rows}</tbody></table>"
)
}
fn render_baseline_findings_section(report: &BaselineScanReport) -> String {
if report.summary.findings.is_empty() {
return "<p class=\"empty\">No findings found.</p>".to_string();
}
let rows = report
.summary
.findings
.iter()
.enumerate()
.map(|(index, finding)| render_baseline_finding_row(finding, report, index))
.collect::<Vec<_>>()
.join("\n");
format!(
"<table id=\"findings\"><thead><tr><th>Severity</th><th>Baseline</th><th>Rule</th><th>Title</th><th>Evidence</th></tr></thead><tbody>{rows}</tbody></table>"
)
}
fn render_finding_row(finding: &crate::findings::types::Finding) -> String {
let evidence = finding.evidence.first();
let location = evidence
.map(|e| {
format!(
"<code>{}:{}</code>",
escape_html(&e.path.to_string_lossy()),
e.line_start
)
})
.unwrap_or_default();
let snippet = evidence
.map(|e| {
format!(
"<pre class=\"snippet\">{}</pre>",
escape_html(e.snippet.trim())
)
})
.unwrap_or_default();
let severity_class = finding.severity_label().to_lowercase();
format!(
"<tr>\
<td><span class=\"badge {severity_class}\">{}</span></td>\
<td><code>{}</code></td>\
<td>{}</td>\
<td>{location}{snippet}</td>\
</tr>",
escape_html(finding.severity_label()),
escape_html(&finding.rule_id),
escape_html(&finding.title),
)
}
fn render_baseline_finding_row(
finding: &crate::findings::types::Finding,
report: &BaselineScanReport,
index: usize,
) -> String {
let evidence = finding.evidence.first();
let location = evidence
.map(|e| {
format!(
"<code>{}:{}</code>",
escape_html(&e.path.to_string_lossy()),
e.line_start
)
})
.unwrap_or_default();
let snippet = evidence
.map(|e| {
format!(
"<pre class=\"snippet\">{}</pre>",
escape_html(e.snippet.trim())
)
})
.unwrap_or_default();
let severity_class = finding.severity_label().to_lowercase();
let status = report.finding_status(index).lowercase_label();
format!(
"<tr>\
<td><span class=\"badge {severity_class}\">{}</span></td>\
<td><span class=\"status {status}\">{}</span></td>\
<td><code>{}</code></td>\
<td>{}</td>\
<td>{location}{snippet}</td>\
</tr>",
escape_html(finding.severity_label()),
escape_html(status),
escape_html(&finding.rule_id),
escape_html(&finding.title),
)
}
fn escape_html(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}