use crate::{
BuildSystemKind, CiProvider, HealthSignal, HealthSignalKind, HealthSignalStatus, LicenseKind,
MultiProjectScan, ProjectHealth, ProjectScan, RiskCode, RiskFinding, RiskSeverity, VcsKind,
relative_display,
};
const STYLES: &str = r#"
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu,
"Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--fg);
line-height: 1.5;
}
:root {
--bg: #fafbfc;
--fg: #1a1a1a;
--muted: #6b7280;
--card-bg: #ffffff;
--border: #e5e7eb;
--table-stripe: #f9fafb;
--code-bg: #f3f4f6;
--ok: #16a34a;
--warn: #d97706;
--info: #2563eb;
--risk-high: #dc2626;
--risk-medium: #d97706;
--risk-low: #2563eb;
--risk-info: #6b7280;
--grade-healthy: #16a34a;
--grade-needs: #d97706;
--grade-risky: #dc2626;
--grade-unknown: #6b7280;
--accent: #2563eb;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a;
--fg: #e5e7eb;
--muted: #94a3b8;
--card-bg: #1e293b;
--border: #334155;
--table-stripe: #1a2336;
--code-bg: #1a2336;
--accent: #60a5fa;
}
}
main { max-width: 1200px; margin: 0 auto; padding: 32px 24px 16px; }
header h1 { margin: 0 0 4px 0; font-size: 28px; font-weight: 600; }
header .meta { color: var(--muted); font-size: 14px; }
section { margin-top: 32px; }
section h2 {
font-size: 20px;
font-weight: 600;
margin: 0 0 12px 0;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
.cards-row { display: flex; flex-wrap: wrap; gap: 16px; }
.card {
flex: 1 1 200px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
min-width: 160px;
}
.card h3 {
margin: 0 0 8px 0;
font-size: 13px;
font-weight: 500;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.card .big-number { font-size: 28px; font-weight: 600; margin: 0; }
.card .sub { font-size: 13px; color: var(--muted); margin: 4px 0 0 0; }
.health-ring { width: 88px; height: 88px; display: block; margin: 0 auto; color: var(--fg); }
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
background: var(--card-bg);
border: 1px solid var(--border);
color: var(--fg);
}
.badge-ok { background: var(--ok); color: white; border-color: var(--ok); }
.badge-warn { background: var(--warn); color: white; border-color: var(--warn); }
.badge-info { background: var(--info); color: white; border-color: var(--info); }
.badge-risk-high { background: var(--risk-high); color: white; border-color: var(--risk-high); }
.badge-risk-medium { background: var(--risk-medium); color: white; border-color: var(--risk-medium); }
.badge-risk-low { background: var(--risk-low); color: white; border-color: var(--risk-low); }
.badge-risk-info { background: var(--risk-info); color: white; border-color: var(--risk-info); }
.badge-grade-healthy { background: var(--grade-healthy); color: white; border-color: var(--grade-healthy); }
.badge-grade-needs { background: var(--grade-needs); color: white; border-color: var(--grade-needs); }
.badge-grade-risky { background: var(--grade-risky); color: white; border-color: var(--grade-risky); }
.badge-grade-unknown { background: var(--grade-unknown); color: white; border-color: var(--grade-unknown); }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
th { font-weight: 600; background: var(--table-stripe); }
tr:nth-child(even) td { background: var(--table-stripe); }
code, .code-inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: var(--code-bg); padding: 1px 6px; border-radius: 4px; font-size: 13px; }
.muted { color: var(--muted); }
.bar { width: 100%; height: 8px; border-radius: 4px; overflow: hidden; background: var(--border); }
.bar-row { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; font-size: 13px; }
.bar-row .label { flex: 0 0 140px; }
.bar-row .bar-wrap { flex: 1; }
.bar-row .value { flex: 0 0 80px; text-align: right; color: var(--muted); }
details { margin: 12px 0; border: 1px solid var(--border); border-radius: 8px; background: var(--card-bg); }
details > summary { cursor: pointer; padding: 12px 16px; font-weight: 500; user-select: none; }
details[open] > summary { border-bottom: 1px solid var(--border); }
details > .details-body { padding: 12px 16px 16px 16px; }
footer { color: var(--muted); font-size: 13px; padding: 24px; text-align: center; }
@media (max-width: 700px) {
.cards-row { flex-direction: column; }
.bar-row .label { flex-basis: 90px; }
}
"#;
pub(crate) fn html_doc(title: &str, body: &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>{title}</title>
<style>{STYLES}</style>
</head>
<body>
{body}
</body>
</html>
"#,
title = escape_html(title),
)
}
pub(crate) fn escape_html(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
pub(crate) fn grade_color(grade: ProjectHealth) -> &'static str {
match grade {
ProjectHealth::Healthy => "var(--grade-healthy)",
ProjectHealth::NeedsAttention => "var(--grade-needs)",
ProjectHealth::Risky => "var(--grade-risky)",
ProjectHealth::Unknown => "var(--grade-unknown)",
}
}
pub(crate) fn grade_badge_class(grade: ProjectHealth) -> &'static str {
match grade {
ProjectHealth::Healthy => "badge-grade-healthy",
ProjectHealth::NeedsAttention => "badge-grade-needs",
ProjectHealth::Risky => "badge-grade-risky",
ProjectHealth::Unknown => "badge-grade-unknown",
}
}
pub(crate) fn grade_label(grade: ProjectHealth) -> &'static str {
match grade {
ProjectHealth::Healthy => "healthy",
ProjectHealth::NeedsAttention => "needs-attention",
ProjectHealth::Risky => "risky",
ProjectHealth::Unknown => "unknown",
}
}
pub(crate) fn severity_badge_class(severity: RiskSeverity) -> &'static str {
match severity {
RiskSeverity::High => "badge-risk-high",
RiskSeverity::Medium => "badge-risk-medium",
RiskSeverity::Low => "badge-risk-low",
RiskSeverity::Info => "badge-risk-info",
}
}
pub(crate) fn severity_label(severity: RiskSeverity) -> &'static str {
match severity {
RiskSeverity::High => "high",
RiskSeverity::Medium => "medium",
RiskSeverity::Low => "low",
RiskSeverity::Info => "info",
}
}
pub(crate) fn signal_status_badge_class(status: HealthSignalStatus) -> &'static str {
match status {
HealthSignalStatus::Pass => "badge-ok",
HealthSignalStatus::Warn => "badge-warn",
HealthSignalStatus::Info => "badge-info",
}
}
pub(crate) fn signal_status_label(status: HealthSignalStatus) -> &'static str {
match status {
HealthSignalStatus::Pass => "PASS",
HealthSignalStatus::Warn => "WARN",
HealthSignalStatus::Info => "INFO",
}
}
pub(crate) fn signal_kind_label(kind: HealthSignalKind) -> &'static str {
match kind {
HealthSignalKind::Readme => "README",
HealthSignalKind::License => "License",
HealthSignalKind::Ci => "CI",
HealthSignalKind::Tests => "Tests",
HealthSignalKind::Lockfiles => "Lockfiles",
HealthSignalKind::Vcs => "Source Control",
HealthSignalKind::Activity => "Activity",
HealthSignalKind::Docs => "Docs",
}
}
pub(crate) fn license_kind_label(kind: LicenseKind) -> &'static str {
match kind {
LicenseKind::Mit => "MIT",
LicenseKind::Apache2 => "Apache-2.0",
LicenseKind::Gpl => "GPL",
LicenseKind::Bsd => "BSD",
LicenseKind::Unknown => "unknown",
LicenseKind::Missing => "missing",
}
}
pub(crate) fn vcs_kind_label(kind: VcsKind) -> &'static str {
match kind {
VcsKind::None => "none",
VcsKind::Git => "git",
VcsKind::Hg => "hg",
VcsKind::Svn => "svn",
VcsKind::Fossil => "fossil",
VcsKind::Bzr => "bzr",
}
}
pub(crate) fn risk_code_label(code: RiskCode) -> &'static str {
match code {
RiskCode::MissingReadme => "missing-readme",
RiskCode::MissingLicense => "missing-license",
RiskCode::MissingCi => "missing-ci",
RiskCode::NoTestsDetected => "no-tests-detected",
RiskCode::ManifestWithoutLockfile => "manifest-without-lockfile",
RiskCode::LargeProjectWithoutIgnoreRules => "large-project-without-ignore-rules",
RiskCode::UnknownLicense => "unknown-license",
RiskCode::MultipleVcsRootsFound => "multiple-vcs-roots-found",
RiskCode::NestedVcsRoot => "nested-vcs-root",
}
}
pub(crate) fn build_system_label(kind: BuildSystemKind) -> &'static str {
match kind {
BuildSystemKind::Cargo => "Cargo",
BuildSystemKind::NodePackage => "Node",
BuildSystemKind::PythonProject => "Python",
BuildSystemKind::PythonRequirements => "Requirements",
BuildSystemKind::CMake => "CMake",
BuildSystemKind::GoModule => "Go module",
}
}
pub(crate) fn ci_provider_label(provider: CiProvider) -> &'static str {
match provider {
CiProvider::GithubActions => "GitHub Actions",
CiProvider::GiteeGo => "Gitee Go",
CiProvider::GitlabCi => "GitLab CI",
CiProvider::CircleCi => "CircleCI",
CiProvider::Jenkins => "Jenkins",
}
}
pub(crate) fn format_last_commit_age(days: Option<u32>) -> String {
match days {
None => "—".to_owned(),
Some(d) if d < 60 => format!("{d}d"),
Some(d) if d < 365 => format!("{}mo", d / 30),
Some(d) => format!("{}y", d / 365),
}
}
pub(crate) fn health_ring_svg(score: usize, grade: ProjectHealth) -> String {
let circumference = 2.0_f32 * std::f32::consts::PI * 40.0;
let percent = (score.min(100) as f32 / 100.0).clamp(0.0, 1.0);
let dash = percent * circumference;
let color = grade_color(grade);
format!(
r#"<svg viewBox="0 0 100 100" class="health-ring" role="img" aria-label="health score {score}">
<circle cx="50" cy="50" r="40" stroke="var(--border)" stroke-width="10" fill="none"/>
<circle cx="50" cy="50" r="40" stroke="{color}" stroke-width="10" fill="none"
stroke-dasharray="{dash:.2} {circumference:.2}" stroke-linecap="round"
transform="rotate(-90 50 50)"/>
<text x="50" y="56" text-anchor="middle" font-size="24" font-weight="600" fill="currentColor">{score}</text>
</svg>"#
)
}
const BAR_PALETTE: &[&str] = &[
"#2563eb", "#16a34a", "#d97706", "#9333ea", "#0891b2", "#dc2626", "#ca8a04", "#0284c7",
"#9ca3af",
];
pub(crate) fn bar_color(index: usize) -> &'static str {
BAR_PALETTE[index % BAR_PALETTE.len()]
}
pub(crate) fn stacked_bar_svg(segments: &[(String, f32)]) -> String {
let total: f32 = segments.iter().map(|(_, pct)| pct.max(0.0)).sum();
let scale = if total > 0.0 { 100.0 / total } else { 0.0 };
let mut svg = String::from(
r#"<svg viewBox="0 0 100 8" preserveAspectRatio="none" class="bar" role="img" aria-label="distribution">"#,
);
let mut x = 0.0_f32;
for (idx, (_label, pct)) in segments.iter().enumerate() {
let width = pct.max(0.0) * scale;
if width <= 0.0 {
continue;
}
svg.push_str(&format!(
r#"<rect x="{x:.2}" y="0" width="{width:.2}" height="8" fill="{color}"/>"#,
color = bar_color(idx)
));
x += width;
}
svg.push_str("</svg>");
svg
}
pub fn render_html(scan: &ProjectScan) -> String {
let title = format!("Projd Report — {}", scan.project_name);
let mut body = String::from("<main>");
body.push_str(&render_header(scan));
body.push_str(&render_summary_cards(scan));
body.push_str(&render_signals_table(scan));
body.push_str(&render_markers_section(scan));
body.push_str(&render_vcs_section(scan));
body.push_str(&render_languages_section(scan));
body.push_str(&render_dependencies_section(scan));
body.push_str(&render_tests_section(scan));
body.push_str(&render_risks_section(scan));
body.push_str("</main>");
body.push_str(&render_footer());
html_doc(&title, &body)
}
fn render_markers_section(scan: &ProjectScan) -> String {
let license = license_kind_label(scan.license.kind);
let license_path = match &scan.license.path {
Some(p) => format!(
r#" · <code>{}</code>"#,
escape_html(&p.display().to_string())
),
None => String::new(),
};
let providers: Vec<String> = scan
.ci
.providers
.iter()
.map(|p| {
format!(
r#"<span class="badge">{}</span>"#,
escape_html(ci_provider_label(p.provider))
)
})
.collect();
let providers_html = if providers.is_empty() {
r#"<span class="muted">none</span>"#.to_owned()
} else {
providers.join(" ")
};
let build_systems: Vec<String> = scan
.build_systems
.iter()
.map(|b| {
format!(
r#"<span class="badge">{}</span>"#,
escape_html(build_system_label(b.kind))
)
})
.collect();
let mut seen = std::collections::BTreeSet::<String>::new();
let build_systems_unique: Vec<String> = build_systems
.into_iter()
.filter(|html| seen.insert(html.clone()))
.collect();
let build_html = if build_systems_unique.is_empty() {
r#"<span class="muted">none</span>"#.to_owned()
} else {
build_systems_unique.join(" ")
};
let dockerfile = if scan.containers.has_dockerfile {
"yes"
} else {
"no"
};
let compose = if scan.containers.has_compose_file {
"yes"
} else {
"no"
};
format!(
r#"<section>
<h2>Project Markers</h2>
<table><tbody>
<tr><th style="width:160px;">License</th><td>{license}{license_path}</td></tr>
<tr><th>Build systems</th><td>{build_html}</td></tr>
<tr><th>CI providers</th><td>{providers_html}</td></tr>
<tr><th>Dockerfile</th><td>{dockerfile}</td></tr>
<tr><th>Compose file</th><td>{compose}</td></tr>
</tbody></table>
</section>"#
)
}
fn render_header(scan: &ProjectScan) -> String {
let identity_extra = match scan.identity.version.as_deref() {
Some(v) => format!(" · version <code>{}</code>", escape_html(v)),
None => String::new(),
};
format!(
r#"<header>
<h1>Projd Report — {name}</h1>
<p class="meta">Root: <code>{root}</code>{extra}</p>
</header>"#,
name = escape_html(&scan.project_name),
root = escape_html(&scan.root.display().to_string()),
extra = identity_extra,
)
}
fn render_summary_cards(scan: &ProjectScan) -> String {
let health = render_health_card(scan);
let risk = render_risk_card(scan);
let vcs = render_vcs_card(scan);
let files = render_files_card(scan);
format!(r#"<section><div class="cards-row">{health}{risk}{vcs}{files}</div></section>"#)
}
fn render_health_card(scan: &ProjectScan) -> String {
let ring = health_ring_svg(scan.health.score, scan.health.grade);
let badge_class = grade_badge_class(scan.health.grade);
let label = grade_label(scan.health.grade);
format!(
r#"<div class="card">
<h3>Health</h3>
{ring}
<p class="sub" style="text-align:center;"><span class="badge {badge_class}">{label}</span></p>
</div>"#
)
}
fn render_risk_card(scan: &ProjectScan) -> String {
let badge_class = severity_badge_class(scan.health.risk_level);
let label = severity_label(scan.health.risk_level);
let total = scan.risks.findings.len();
format!(
r#"<div class="card">
<h3>Risk Level</h3>
<p class="big-number"><span class="badge {badge_class}">{label}</span></p>
<p class="sub">{total} finding(s)</p>
</div>"#
)
}
fn render_vcs_card(scan: &ProjectScan) -> String {
if !scan.vcs.is_repository {
return r#"<div class="card"><h3>Source Control</h3><p class="big-number muted">—</p><p class="sub">not detected</p></div>"#.to_owned();
}
let kind = vcs_kind_label(scan.vcs.kind);
let branch = scan
.vcs
.branch
.as_deref()
.map(escape_html)
.unwrap_or_else(|| "unknown".to_owned());
let activity = match scan.vcs.activity.days_since_last_commit {
Some(0) => "today".to_owned(),
Some(days) if days < 60 => format!("{days}d ago"),
Some(days) if days < 365 => format!("{}mo ago", days / 30),
Some(days) => format!("{}y ago", days / 365),
None => "no activity data".to_owned(),
};
format!(
r#"<div class="card">
<h3>Source Control</h3>
<p class="big-number">{kind}</p>
<p class="sub"><code>{branch}</code> · last commit {activity}</p>
</div>"#
)
}
fn render_files_card(scan: &ProjectScan) -> String {
format!(
r#"<div class="card">
<h3>Files Scanned</h3>
<p class="big-number">{files}</p>
<p class="sub">{lines} code line(s)</p>
</div>"#,
files = scan.files_scanned,
lines = scan.code.code_lines,
)
}
fn render_signals_table(scan: &ProjectScan) -> String {
if scan.health.signals.is_empty() {
return String::new();
}
let mut rows = String::new();
for signal in &scan.health.signals {
rows.push_str(&render_signal_row(signal));
}
format!(
r#"<section>
<h2>Health Signals</h2>
<table>
<thead><tr><th>Signal</th><th>Status</th><th>Evidence</th><th>Score</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</section>"#
)
}
fn render_signal_row(signal: &HealthSignal) -> String {
let badge_class = signal_status_badge_class(signal.status);
let status = signal_status_label(signal.status);
let kind = signal_kind_label(signal.kind);
let evidence = escape_html(&signal.evidence);
let delta = signal.score_delta;
let delta_str = if delta > 0 {
format!("+{delta}")
} else {
delta.to_string()
};
format!(
r#"<tr><td>{kind}</td><td><span class="badge {badge_class}">{status}</span></td><td>{evidence}</td><td>{delta_str}</td></tr>"#
)
}
fn render_vcs_section(scan: &ProjectScan) -> String {
if !scan.vcs.is_repository {
return r#"<section><h2>Source Control</h2><p class="muted">No source control detected.</p></section>"#.to_owned();
}
let mut rows: Vec<(String, String)> = Vec::new();
rows.push(("Kind".to_owned(), vcs_kind_label(scan.vcs.kind).to_owned()));
if let Some(branch) = &scan.vcs.branch {
let label = match scan.vcs.kind {
VcsKind::Svn => "URL",
_ => "Branch",
};
rows.push((label.to_owned(), escape_html(branch)));
}
if let Some(rev) = &scan.vcs.revision {
rows.push(("Revision".to_owned(), escape_html(&short_revision(rev))));
}
if let Some(last) = &scan.vcs.last_commit {
rows.push(("Last commit".to_owned(), escape_html(last)));
}
let status = if scan.vcs.is_dirty {
format!(
"dirty · {} modified · {} untracked",
scan.vcs.tracked_modified_files, scan.vcs.untracked_files
)
} else {
"clean".to_owned()
};
rows.push(("Status".to_owned(), escape_html(&status)));
if let Some(days) = scan.vcs.activity.days_since_last_commit {
rows.push(("Activity".to_owned(), format!("{days} day(s) ago")));
}
if let Some(count) = scan.vcs.activity.commits_last_90d {
rows.push(("Commits 90d".to_owned(), count.to_string()));
}
if let Some(count) = scan.vcs.activity.contributors_count {
rows.push(("Contributors".to_owned(), count.to_string()));
}
if let Some(first) = &scan.vcs.activity.first_commit_date {
rows.push(("First commit".to_owned(), escape_html(first)));
}
if let Some(root) = &scan.vcs.root {
rows.push((
"VCS root".to_owned(),
escape_html(&root.display().to_string()),
));
}
let mut table_rows = String::new();
for (label, value) in rows {
table_rows.push_str(&format!(
r#"<tr><th style="width:160px;">{label}</th><td>{value}</td></tr>"#
));
}
let mut section = format!(
r#"<section>
<h2>Source Control</h2>
<table><tbody>{table_rows}</tbody></table>"#
);
if !scan.vcs.activity.contributors.is_empty() {
section.push_str(r#"<h3 style="margin-top:16px;font-size:15px;">Contributors</h3><table><thead><tr><th>Name</th><th>Email</th><th>Commits</th></tr></thead><tbody>"#);
for c in &scan.vcs.activity.contributors {
section.push_str(&format!(
r#"<tr><td>{}</td><td><code>{}</code></td><td>{}</td></tr>"#,
escape_html(&c.name),
escape_html(&c.email),
c.commit_count
));
}
section.push_str("</tbody></table>");
}
section.push_str("</section>");
section
}
fn short_revision(value: &str) -> String {
let trimmed = value.trim();
let mut out: String = trimmed.chars().take(12).collect();
if trimmed.chars().count() > out.chars().count() {
out.push('…');
}
out
}
fn render_languages_section(scan: &ProjectScan) -> String {
if scan.code.languages.is_empty() {
return r#"<section><h2>Languages</h2><p class="muted">No source language files detected.</p></section>"#.to_owned();
}
let total: usize = scan.code.languages.iter().map(|l| l.total_lines).sum();
let mut bar_segments: Vec<(String, f32)> = Vec::new();
let mut rows = String::new();
for (idx, lang) in scan.code.languages.iter().enumerate() {
let pct = if total > 0 {
(lang.total_lines as f32 / total as f32) * 100.0
} else {
0.0
};
bar_segments.push((format!("{:?}", lang.kind), pct));
rows.push_str(&format!(
r#"<tr><td><span style="display:inline-block;width:10px;height:10px;background:{color};margin-right:6px;border-radius:2px;"></span>{name}</td><td>{files}</td><td>{lines}</td><td>{pct:.1}%</td></tr>"#,
color = bar_color(idx),
name = escape_html(&format!("{:?}", lang.kind)),
files = lang.files,
lines = lang.total_lines,
pct = pct,
));
}
let bar = stacked_bar_svg(&bar_segments);
format!(
r#"<section>
<h2>Languages</h2>
<div style="margin-bottom:12px;">{bar}</div>
<table><thead><tr><th>Language</th><th>Files</th><th>Lines</th><th>Share</th></tr></thead><tbody>{rows}</tbody></table>
</section>"#
)
}
fn render_dependencies_section(scan: &ProjectScan) -> String {
if scan.dependencies.ecosystems.is_empty() {
return r#"<section><h2>Dependencies</h2><p class="muted">No dependency manifests detected.</p></section>"#.to_owned();
}
let mut rows = String::new();
for eco in &scan.dependencies.ecosystems {
let manifest = eco.manifest.display().to_string();
let lock = match &eco.lockfile {
Some(path) => format!("<code>{}</code>", escape_html(&path.display().to_string())),
None => r#"<span class="muted">none</span>"#.to_owned(),
};
rows.push_str(&format!(
r#"<tr><td>{eco:?}</td><td><code>{manifest}</code></td><td>{lock}</td><td>{total}</td></tr>"#,
eco = eco.ecosystem,
manifest = escape_html(&manifest),
total = eco.total,
));
}
format!(
r#"<section>
<h2>Dependencies</h2>
<p class="muted">{count} manifest(s) · {total} dependency entries</p>
<table><thead><tr><th>Ecosystem</th><th>Manifest</th><th>Lockfile</th><th>Total</th></tr></thead><tbody>{rows}</tbody></table>
</section>"#,
count = scan.dependencies.total_manifests,
total = scan.dependencies.total_dependencies,
)
}
fn render_tests_section(scan: &ProjectScan) -> String {
if scan.tests.test_files == 0 && scan.tests.commands.is_empty() {
return r#"<section><h2>Tests</h2><p class="muted">No tests or test commands detected.</p></section>"#.to_owned();
}
let mut commands = String::new();
for cmd in &scan.tests.commands {
commands.push_str(&format!(
r#"<li><code>{}</code> <span class="muted">({})</span></li>"#,
escape_html(&cmd.command),
escape_html(&cmd.name),
));
}
format!(
r#"<section>
<h2>Tests</h2>
<p>{files} test file(s) · {cmd_count} command(s)</p>
<ul>{commands}</ul>
</section>"#,
files = scan.tests.test_files,
cmd_count = scan.tests.commands.len(),
)
}
fn render_risks_section(scan: &ProjectScan) -> String {
if scan.risks.findings.is_empty() {
return r#"<section><h2>Risks</h2><p class="muted">No risks detected. 👌</p></section>"#
.to_owned();
}
let mut rows = String::new();
for finding in &scan.risks.findings {
rows.push_str(&render_risk_row(finding));
}
format!(
r#"<section>
<h2>Risks</h2>
<p class="muted">{total} finding(s) · high: {high} · medium: {medium} · low: {low} · info: {info}</p>
<table><thead><tr><th>Severity</th><th>Code</th><th>Message</th><th>Path</th></tr></thead><tbody>{rows}</tbody></table>
</section>"#,
total = scan.risks.total,
high = scan.risks.high,
medium = scan.risks.medium,
low = scan.risks.low,
info = scan.risks.info,
)
}
fn render_risk_row(finding: &RiskFinding) -> String {
let badge_class = severity_badge_class(finding.severity);
let label = severity_label(finding.severity);
let code = risk_code_label(finding.code);
let message = escape_html(&finding.message);
let path = match &finding.path {
Some(p) => format!("<code>{}</code>", escape_html(&p.display().to_string())),
None => r#"<span class="muted">—</span>"#.to_owned(),
};
format!(
r#"<tr><td><span class="badge {badge_class}">{label}</span></td><td><code>{code}</code></td><td>{message}</td><td>{path}</td></tr>"#
)
}
fn render_footer() -> String {
format!(
r#"<footer>Generated by projd-core {} · {}</footer>"#,
env!("CARGO_PKG_VERSION"),
"https://crates.io/crates/projd",
)
}
pub fn render_multi_html(scan: &MultiProjectScan) -> String {
let title = "Projd Recursive Scan Report".to_owned();
let mut body = String::from("<main>");
body.push_str(&render_multi_header(scan));
body.push_str(&render_multi_aggregate_cards(scan));
body.push_str(&render_multi_overview_table(scan));
body.push_str(&render_multi_project_details(scan));
if !scan.skipped.is_empty() {
body.push_str(&render_multi_skipped(scan));
}
body.push_str("</main>");
body.push_str(&render_footer());
html_doc(&title, &body)
}
fn render_multi_header(scan: &MultiProjectScan) -> String {
format!(
r#"<header>
<h1>Projd Recursive Scan Report</h1>
<p class="meta">Root: <code>{root}</code></p>
</header>"#,
root = escape_html(&scan.root.display().to_string()),
)
}
fn render_multi_aggregate_cards(scan: &MultiProjectScan) -> String {
let total_card = format!(
r#"<div class="card"><h3>Total Roots</h3><p class="big-number">{total}</p><p class="sub">{files} files scanned</p></div>"#,
total = scan.summary.total,
files = scan.summary.files_scanned,
);
let grade_parts: Vec<String> = [
ProjectHealth::Healthy,
ProjectHealth::NeedsAttention,
ProjectHealth::Risky,
ProjectHealth::Unknown,
]
.iter()
.filter_map(|grade| {
scan.summary.by_grade.get(grade).map(|count| {
format!(
r#"<span class="badge {cls}">{label} {count}</span>"#,
cls = grade_badge_class(*grade),
label = grade_label(*grade),
)
})
})
.collect();
let grade_card = format!(
r#"<div class="card"><h3>Grades</h3><p>{}</p></div>"#,
if grade_parts.is_empty() {
"<span class=\"muted\">—</span>".to_owned()
} else {
grade_parts.join(" ")
}
);
let risk_parts: Vec<String> = [
RiskSeverity::High,
RiskSeverity::Medium,
RiskSeverity::Low,
RiskSeverity::Info,
]
.iter()
.filter_map(|level| {
scan.summary.by_risk_level.get(level).map(|count| {
format!(
r#"<span class="badge {cls}">{label} {count}</span>"#,
cls = severity_badge_class(*level),
label = severity_label(*level),
)
})
})
.collect();
let risk_card = format!(
r#"<div class="card"><h3>Risk Levels</h3><p>{}</p></div>"#,
if risk_parts.is_empty() {
"<span class=\"muted\">—</span>".to_owned()
} else {
risk_parts.join(" ")
}
);
let skipped_card = if scan.skipped.is_empty() {
String::new()
} else {
format!(
r#"<div class="card"><h3>Skipped</h3><p class="big-number">{}</p><p class="sub">see below</p></div>"#,
scan.skipped.len()
)
};
format!(
r#"<section><div class="cards-row">{total_card}{grade_card}{risk_card}{skipped_card}</div></section>"#
)
}
fn render_multi_overview_table(scan: &MultiProjectScan) -> String {
if scan.roots.is_empty() {
return r#"<section><h2>Projects</h2><p class="muted">No project roots found.</p></section>"#.to_owned();
}
let mut rows = String::new();
for (idx, project) in scan.roots.iter().enumerate() {
let rel = relative_display(&scan.root, &project.root);
let last = format_last_commit_age(project.vcs.activity.days_since_last_commit);
let top_risk = top_risk_summary(project);
rows.push_str(&format!(
r#"<tr><td>{n}</td><td><code>{path}</code></td><td>{kinds}</td><td><span class="badge {grade_cls}">{grade}</span></td><td>{score}</td><td>{last}</td><td>{risk}</td></tr>"#,
n = idx + 1,
path = escape_html(&rel),
kinds = escape_html(&project_kind_labels(project)),
grade_cls = grade_badge_class(project.health.grade),
grade = grade_label(project.health.grade),
score = project.health.score,
last = escape_html(&last),
risk = escape_html(&top_risk),
));
}
format!(
r#"<section>
<h2>Projects</h2>
<table>
<thead><tr><th>#</th><th>Path</th><th>Kinds</th><th>Grade</th><th>Score</th><th>Last Commit</th><th>Top Risk</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</section>"#
)
}
fn render_multi_project_details(scan: &MultiProjectScan) -> String {
if scan.roots.is_empty() {
return String::new();
}
let mut blocks = String::new();
for (idx, project) in scan.roots.iter().enumerate() {
let rel = relative_display(&scan.root, &project.root);
let summary = format!(
"[{}/{}] {} — {} ({})",
idx + 1,
scan.roots.len(),
rel,
grade_label(project.health.grade),
project.health.score,
);
let mut inner = String::new();
inner.push_str(&render_summary_cards(project));
inner.push_str(&render_signals_table(project));
inner.push_str(&render_markers_section(project));
inner.push_str(&render_vcs_section(project));
inner.push_str(&render_languages_section(project));
inner.push_str(&render_dependencies_section(project));
inner.push_str(&render_tests_section(project));
inner.push_str(&render_risks_section(project));
blocks.push_str(&format!(
r#"<details><summary>{summary}</summary><div class="details-body">{inner}</div></details>"#,
summary = escape_html(&summary),
));
}
format!("<section><h2>Per-Project Reports</h2>{blocks}</section>")
}
fn render_multi_skipped(scan: &MultiProjectScan) -> String {
let mut rows = String::new();
for entry in &scan.skipped {
rows.push_str(&format!(
r#"<tr><td><code>{path}</code></td><td>{msg}</td></tr>"#,
path = escape_html(&entry.path.display().to_string()),
msg = escape_html(&entry.message),
));
}
format!(
r#"<section>
<h2>Skipped Roots</h2>
<table><thead><tr><th>Path</th><th>Reason</th></tr></thead><tbody>{rows}</tbody></table>
</section>"#
)
}
fn top_risk_summary(project: &ProjectScan) -> String {
if project.risks.findings.is_empty() {
return "—".to_owned();
}
let highest = project
.risks
.findings
.iter()
.min_by_key(|finding| finding.severity)
.expect("at least one finding");
risk_code_label(highest.code).to_owned()
}
fn project_kind_labels(project: &ProjectScan) -> String {
let mut parts: Vec<&str> = Vec::new();
if project.has_build_system(BuildSystemKind::Cargo) {
parts.push("cargo");
}
if project.has_build_system(BuildSystemKind::NodePackage) {
parts.push("npm");
}
if project.has_build_system(BuildSystemKind::PythonProject) {
parts.push("python");
}
if project.has_build_system(BuildSystemKind::GoModule) {
parts.push("go");
}
if project.has_build_system(BuildSystemKind::CMake) {
parts.push("cmake");
}
if project.vcs.is_repository {
parts.push(vcs_kind_label(project.vcs.kind));
}
if parts.is_empty() {
"—".to_owned()
} else {
parts.join(", ")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escape_html_replaces_special_chars() {
assert_eq!(
escape_html("<script>alert(\"x\")</script>"),
"<script>alert("x")</script>"
);
assert_eq!(escape_html("a & b"), "a & b");
}
#[test]
fn html_doc_has_doctype_and_charset() {
let out = html_doc("hello", "<main>hi</main>");
assert!(out.starts_with("<!DOCTYPE html>"));
assert!(out.contains("<meta charset=\"utf-8\">"));
assert!(out.contains("<title>hello</title>"));
}
#[test]
fn html_doc_escapes_title() {
let out = html_doc("<bad>", "");
assert!(out.contains("<title><bad></title>"));
}
#[test]
fn format_last_commit_age_brackets() {
assert_eq!(format_last_commit_age(None), "—");
assert_eq!(format_last_commit_age(Some(0)), "0d");
assert_eq!(format_last_commit_age(Some(59)), "59d");
assert_eq!(format_last_commit_age(Some(60)), "2mo");
assert_eq!(format_last_commit_age(Some(364)), "12mo");
assert_eq!(format_last_commit_age(Some(365)), "1y");
assert_eq!(format_last_commit_age(Some(800)), "2y");
}
}