Skip to main content

projd_core/
html.rs

1//! HTML renderer for `ProjectScan` and `MultiProjectScan`.
2//!
3//! Produces a self-contained HTML document with embedded CSS and inline SVG
4//! charts. No external assets, no JavaScript, no CDN. Safe to open by
5//! double-clicking the resulting file or to email as an attachment.
6
7use crate::{
8    BuildSystemKind, CiProvider, HealthSignal, HealthSignalKind, HealthSignalStatus, LicenseKind,
9    MultiProjectScan, ProjectHealth, ProjectScan, RiskCode, RiskFinding, RiskSeverity, VcsKind,
10    relative_display,
11};
12
13const STYLES: &str = r#"
14*, *::before, *::after { box-sizing: border-box; }
15body {
16    margin: 0;
17    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu,
18        "Helvetica Neue", Arial, sans-serif;
19    background: var(--bg);
20    color: var(--fg);
21    line-height: 1.5;
22}
23:root {
24    --bg: #fafbfc;
25    --fg: #1a1a1a;
26    --muted: #6b7280;
27    --card-bg: #ffffff;
28    --border: #e5e7eb;
29    --table-stripe: #f9fafb;
30    --code-bg: #f3f4f6;
31    --ok: #16a34a;
32    --warn: #d97706;
33    --info: #2563eb;
34    --risk-high: #dc2626;
35    --risk-medium: #d97706;
36    --risk-low: #2563eb;
37    --risk-info: #6b7280;
38    --grade-healthy: #16a34a;
39    --grade-needs: #d97706;
40    --grade-risky: #dc2626;
41    --grade-unknown: #6b7280;
42    --accent: #2563eb;
43}
44@media (prefers-color-scheme: dark) {
45    :root {
46        --bg: #0f172a;
47        --fg: #e5e7eb;
48        --muted: #94a3b8;
49        --card-bg: #1e293b;
50        --border: #334155;
51        --table-stripe: #1a2336;
52        --code-bg: #1a2336;
53        --accent: #60a5fa;
54    }
55}
56main { max-width: 1200px; margin: 0 auto; padding: 32px 24px 16px; }
57header h1 { margin: 0 0 4px 0; font-size: 28px; font-weight: 600; }
58header .meta { color: var(--muted); font-size: 14px; }
59section { margin-top: 32px; }
60section h2 {
61    font-size: 20px;
62    font-weight: 600;
63    margin: 0 0 12px 0;
64    padding-bottom: 6px;
65    border-bottom: 1px solid var(--border);
66}
67.cards-row { display: flex; flex-wrap: wrap; gap: 16px; }
68.card {
69    flex: 1 1 200px;
70    background: var(--card-bg);
71    border: 1px solid var(--border);
72    border-radius: 8px;
73    padding: 16px;
74    min-width: 160px;
75}
76.card h3 {
77    margin: 0 0 8px 0;
78    font-size: 13px;
79    font-weight: 500;
80    color: var(--muted);
81    text-transform: uppercase;
82    letter-spacing: 0.05em;
83}
84.card .big-number { font-size: 28px; font-weight: 600; margin: 0; }
85.card .sub { font-size: 13px; color: var(--muted); margin: 4px 0 0 0; }
86.health-ring { width: 88px; height: 88px; display: block; margin: 0 auto; color: var(--fg); }
87.badge {
88    display: inline-block;
89    padding: 2px 10px;
90    border-radius: 999px;
91    font-size: 12px;
92    font-weight: 500;
93    background: var(--card-bg);
94    border: 1px solid var(--border);
95    color: var(--fg);
96}
97.badge-ok { background: var(--ok); color: white; border-color: var(--ok); }
98.badge-warn { background: var(--warn); color: white; border-color: var(--warn); }
99.badge-info { background: var(--info); color: white; border-color: var(--info); }
100.badge-risk-high { background: var(--risk-high); color: white; border-color: var(--risk-high); }
101.badge-risk-medium { background: var(--risk-medium); color: white; border-color: var(--risk-medium); }
102.badge-risk-low { background: var(--risk-low); color: white; border-color: var(--risk-low); }
103.badge-risk-info { background: var(--risk-info); color: white; border-color: var(--risk-info); }
104.badge-grade-healthy { background: var(--grade-healthy); color: white; border-color: var(--grade-healthy); }
105.badge-grade-needs { background: var(--grade-needs); color: white; border-color: var(--grade-needs); }
106.badge-grade-risky { background: var(--grade-risky); color: white; border-color: var(--grade-risky); }
107.badge-grade-unknown { background: var(--grade-unknown); color: white; border-color: var(--grade-unknown); }
108table { width: 100%; border-collapse: collapse; font-size: 14px; }
109th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
110th { font-weight: 600; background: var(--table-stripe); }
111tr:nth-child(even) td { background: var(--table-stripe); }
112code, .code-inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: var(--code-bg); padding: 1px 6px; border-radius: 4px; font-size: 13px; }
113.muted { color: var(--muted); }
114.bar { width: 100%; height: 8px; border-radius: 4px; overflow: hidden; background: var(--border); }
115.bar-row { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; font-size: 13px; }
116.bar-row .label { flex: 0 0 140px; }
117.bar-row .bar-wrap { flex: 1; }
118.bar-row .value { flex: 0 0 80px; text-align: right; color: var(--muted); }
119details { margin: 12px 0; border: 1px solid var(--border); border-radius: 8px; background: var(--card-bg); }
120details > summary { cursor: pointer; padding: 12px 16px; font-weight: 500; user-select: none; }
121details[open] > summary { border-bottom: 1px solid var(--border); }
122details > .details-body { padding: 12px 16px 16px 16px; }
123footer { color: var(--muted); font-size: 13px; padding: 24px; text-align: center; }
124@media (max-width: 700px) {
125    .cards-row { flex-direction: column; }
126    .bar-row .label { flex-basis: 90px; }
127}
128"#;
129
130pub(crate) fn html_doc(title: &str, body: &str) -> String {
131    format!(
132        r#"<!DOCTYPE html>
133<html lang="en">
134<head>
135<meta charset="utf-8">
136<meta name="viewport" content="width=device-width, initial-scale=1">
137<title>{title}</title>
138<style>{STYLES}</style>
139</head>
140<body>
141{body}
142</body>
143</html>
144"#,
145        title = escape_html(title),
146    )
147}
148
149pub(crate) fn escape_html(value: &str) -> String {
150    let mut out = String::with_capacity(value.len());
151    for ch in value.chars() {
152        match ch {
153            '&' => out.push_str("&amp;"),
154            '<' => out.push_str("&lt;"),
155            '>' => out.push_str("&gt;"),
156            '"' => out.push_str("&quot;"),
157            '\'' => out.push_str("&#39;"),
158            _ => out.push(ch),
159        }
160    }
161    out
162}
163
164pub(crate) fn grade_color(grade: ProjectHealth) -> &'static str {
165    match grade {
166        ProjectHealth::Healthy => "var(--grade-healthy)",
167        ProjectHealth::NeedsAttention => "var(--grade-needs)",
168        ProjectHealth::Risky => "var(--grade-risky)",
169        ProjectHealth::Unknown => "var(--grade-unknown)",
170    }
171}
172
173pub(crate) fn grade_badge_class(grade: ProjectHealth) -> &'static str {
174    match grade {
175        ProjectHealth::Healthy => "badge-grade-healthy",
176        ProjectHealth::NeedsAttention => "badge-grade-needs",
177        ProjectHealth::Risky => "badge-grade-risky",
178        ProjectHealth::Unknown => "badge-grade-unknown",
179    }
180}
181
182pub(crate) fn grade_label(grade: ProjectHealth) -> &'static str {
183    match grade {
184        ProjectHealth::Healthy => "healthy",
185        ProjectHealth::NeedsAttention => "needs-attention",
186        ProjectHealth::Risky => "risky",
187        ProjectHealth::Unknown => "unknown",
188    }
189}
190
191pub(crate) fn severity_badge_class(severity: RiskSeverity) -> &'static str {
192    match severity {
193        RiskSeverity::High => "badge-risk-high",
194        RiskSeverity::Medium => "badge-risk-medium",
195        RiskSeverity::Low => "badge-risk-low",
196        RiskSeverity::Info => "badge-risk-info",
197    }
198}
199
200pub(crate) fn severity_label(severity: RiskSeverity) -> &'static str {
201    match severity {
202        RiskSeverity::High => "high",
203        RiskSeverity::Medium => "medium",
204        RiskSeverity::Low => "low",
205        RiskSeverity::Info => "info",
206    }
207}
208
209pub(crate) fn signal_status_badge_class(status: HealthSignalStatus) -> &'static str {
210    match status {
211        HealthSignalStatus::Pass => "badge-ok",
212        HealthSignalStatus::Warn => "badge-warn",
213        HealthSignalStatus::Info => "badge-info",
214    }
215}
216
217pub(crate) fn signal_status_label(status: HealthSignalStatus) -> &'static str {
218    match status {
219        HealthSignalStatus::Pass => "PASS",
220        HealthSignalStatus::Warn => "WARN",
221        HealthSignalStatus::Info => "INFO",
222    }
223}
224
225pub(crate) fn signal_kind_label(kind: HealthSignalKind) -> &'static str {
226    match kind {
227        HealthSignalKind::Readme => "README",
228        HealthSignalKind::License => "License",
229        HealthSignalKind::Ci => "CI",
230        HealthSignalKind::Tests => "Tests",
231        HealthSignalKind::Lockfiles => "Lockfiles",
232        HealthSignalKind::Vcs => "Source Control",
233        HealthSignalKind::Activity => "Activity",
234        HealthSignalKind::Docs => "Docs",
235    }
236}
237
238pub(crate) fn license_kind_label(kind: LicenseKind) -> &'static str {
239    match kind {
240        LicenseKind::Mit => "MIT",
241        LicenseKind::Apache2 => "Apache-2.0",
242        LicenseKind::Gpl => "GPL",
243        LicenseKind::Bsd => "BSD",
244        LicenseKind::Unknown => "unknown",
245        LicenseKind::Missing => "missing",
246    }
247}
248
249pub(crate) fn vcs_kind_label(kind: VcsKind) -> &'static str {
250    match kind {
251        VcsKind::None => "none",
252        VcsKind::Git => "git",
253        VcsKind::Hg => "hg",
254        VcsKind::Svn => "svn",
255        VcsKind::Fossil => "fossil",
256        VcsKind::Bzr => "bzr",
257    }
258}
259
260pub(crate) fn risk_code_label(code: RiskCode) -> &'static str {
261    match code {
262        RiskCode::MissingReadme => "missing-readme",
263        RiskCode::MissingLicense => "missing-license",
264        RiskCode::MissingCi => "missing-ci",
265        RiskCode::NoTestsDetected => "no-tests-detected",
266        RiskCode::ManifestWithoutLockfile => "manifest-without-lockfile",
267        RiskCode::LargeProjectWithoutIgnoreRules => "large-project-without-ignore-rules",
268        RiskCode::UnknownLicense => "unknown-license",
269        RiskCode::MultipleVcsRootsFound => "multiple-vcs-roots-found",
270        RiskCode::NestedVcsRoot => "nested-vcs-root",
271    }
272}
273
274pub(crate) fn build_system_label(kind: BuildSystemKind) -> &'static str {
275    match kind {
276        BuildSystemKind::Cargo => "Cargo",
277        BuildSystemKind::NodePackage => "Node",
278        BuildSystemKind::PythonProject => "Python",
279        BuildSystemKind::PythonRequirements => "Requirements",
280        BuildSystemKind::CMake => "CMake",
281        BuildSystemKind::GoModule => "Go module",
282    }
283}
284
285pub(crate) fn ci_provider_label(provider: CiProvider) -> &'static str {
286    match provider {
287        CiProvider::GithubActions => "GitHub Actions",
288        CiProvider::GiteeGo => "Gitee Go",
289        CiProvider::GitlabCi => "GitLab CI",
290        CiProvider::CircleCi => "CircleCI",
291        CiProvider::Jenkins => "Jenkins",
292    }
293}
294
295pub(crate) fn format_last_commit_age(days: Option<u32>) -> String {
296    match days {
297        None => "—".to_owned(),
298        Some(d) if d < 60 => format!("{d}d"),
299        Some(d) if d < 365 => format!("{}mo", d / 30),
300        Some(d) => format!("{}y", d / 365),
301    }
302}
303
304pub(crate) fn health_ring_svg(score: usize, grade: ProjectHealth) -> String {
305    let circumference = 2.0_f32 * std::f32::consts::PI * 40.0;
306    let percent = (score.min(100) as f32 / 100.0).clamp(0.0, 1.0);
307    let dash = percent * circumference;
308    let color = grade_color(grade);
309    format!(
310        r#"<svg viewBox="0 0 100 100" class="health-ring" role="img" aria-label="health score {score}">
311<circle cx="50" cy="50" r="40" stroke="var(--border)" stroke-width="10" fill="none"/>
312<circle cx="50" cy="50" r="40" stroke="{color}" stroke-width="10" fill="none"
313        stroke-dasharray="{dash:.2} {circumference:.2}" stroke-linecap="round"
314        transform="rotate(-90 50 50)"/>
315<text x="50" y="56" text-anchor="middle" font-size="24" font-weight="600" fill="currentColor">{score}</text>
316</svg>"#
317    )
318}
319
320const BAR_PALETTE: &[&str] = &[
321    "#2563eb", "#16a34a", "#d97706", "#9333ea", "#0891b2", "#dc2626", "#ca8a04", "#0284c7",
322    "#9ca3af",
323];
324
325pub(crate) fn bar_color(index: usize) -> &'static str {
326    BAR_PALETTE[index % BAR_PALETTE.len()]
327}
328
329/// Renders a horizontal stacked bar. Percentages are clamped to `[0, 100]` and
330/// scaled so segments cover at most the bar width.
331pub(crate) fn stacked_bar_svg(segments: &[(String, f32)]) -> String {
332    let total: f32 = segments.iter().map(|(_, pct)| pct.max(0.0)).sum();
333    let scale = if total > 0.0 { 100.0 / total } else { 0.0 };
334    let mut svg = String::from(
335        r#"<svg viewBox="0 0 100 8" preserveAspectRatio="none" class="bar" role="img" aria-label="distribution">"#,
336    );
337    let mut x = 0.0_f32;
338    for (idx, (_label, pct)) in segments.iter().enumerate() {
339        let width = pct.max(0.0) * scale;
340        if width <= 0.0 {
341            continue;
342        }
343        svg.push_str(&format!(
344            r#"<rect x="{x:.2}" y="0" width="{width:.2}" height="8" fill="{color}"/>"#,
345            color = bar_color(idx)
346        ));
347        x += width;
348    }
349    svg.push_str("</svg>");
350    svg
351}
352
353pub fn render_html(scan: &ProjectScan) -> String {
354    let title = format!("Projd Report — {}", scan.project_name);
355    let mut body = String::from("<main>");
356    body.push_str(&render_header(scan));
357    body.push_str(&render_summary_cards(scan));
358    body.push_str(&render_signals_table(scan));
359    body.push_str(&render_markers_section(scan));
360    body.push_str(&render_vcs_section(scan));
361    body.push_str(&render_languages_section(scan));
362    body.push_str(&render_dependencies_section(scan));
363    body.push_str(&render_tests_section(scan));
364    body.push_str(&render_risks_section(scan));
365    body.push_str("</main>");
366    body.push_str(&render_footer());
367    html_doc(&title, &body)
368}
369
370fn render_markers_section(scan: &ProjectScan) -> String {
371    let license = license_kind_label(scan.license.kind);
372    let license_path = match &scan.license.path {
373        Some(p) => format!(
374            r#" · <code>{}</code>"#,
375            escape_html(&p.display().to_string())
376        ),
377        None => String::new(),
378    };
379    let providers: Vec<String> = scan
380        .ci
381        .providers
382        .iter()
383        .map(|p| {
384            format!(
385                r#"<span class="badge">{}</span>"#,
386                escape_html(ci_provider_label(p.provider))
387            )
388        })
389        .collect();
390    let providers_html = if providers.is_empty() {
391        r#"<span class="muted">none</span>"#.to_owned()
392    } else {
393        providers.join(" ")
394    };
395    let build_systems: Vec<String> = scan
396        .build_systems
397        .iter()
398        .map(|b| {
399            format!(
400                r#"<span class="badge">{}</span>"#,
401                escape_html(build_system_label(b.kind))
402            )
403        })
404        .collect();
405    // Dedup build system badges by label to keep summary compact.
406    let mut seen = std::collections::BTreeSet::<String>::new();
407    let build_systems_unique: Vec<String> = build_systems
408        .into_iter()
409        .filter(|html| seen.insert(html.clone()))
410        .collect();
411    let build_html = if build_systems_unique.is_empty() {
412        r#"<span class="muted">none</span>"#.to_owned()
413    } else {
414        build_systems_unique.join(" ")
415    };
416    let dockerfile = if scan.containers.has_dockerfile {
417        "yes"
418    } else {
419        "no"
420    };
421    let compose = if scan.containers.has_compose_file {
422        "yes"
423    } else {
424        "no"
425    };
426    format!(
427        r#"<section>
428<h2>Project Markers</h2>
429<table><tbody>
430<tr><th style="width:160px;">License</th><td>{license}{license_path}</td></tr>
431<tr><th>Build systems</th><td>{build_html}</td></tr>
432<tr><th>CI providers</th><td>{providers_html}</td></tr>
433<tr><th>Dockerfile</th><td>{dockerfile}</td></tr>
434<tr><th>Compose file</th><td>{compose}</td></tr>
435</tbody></table>
436</section>"#
437    )
438}
439
440fn render_header(scan: &ProjectScan) -> String {
441    let identity_extra = match scan.identity.version.as_deref() {
442        Some(v) => format!(" · version <code>{}</code>", escape_html(v)),
443        None => String::new(),
444    };
445    format!(
446        r#"<header>
447<h1>Projd Report — {name}</h1>
448<p class="meta">Root: <code>{root}</code>{extra}</p>
449</header>"#,
450        name = escape_html(&scan.project_name),
451        root = escape_html(&scan.root.display().to_string()),
452        extra = identity_extra,
453    )
454}
455
456fn render_summary_cards(scan: &ProjectScan) -> String {
457    let health = render_health_card(scan);
458    let risk = render_risk_card(scan);
459    let vcs = render_vcs_card(scan);
460    let files = render_files_card(scan);
461    format!(r#"<section><div class="cards-row">{health}{risk}{vcs}{files}</div></section>"#)
462}
463
464fn render_health_card(scan: &ProjectScan) -> String {
465    let ring = health_ring_svg(scan.health.score, scan.health.grade);
466    let badge_class = grade_badge_class(scan.health.grade);
467    let label = grade_label(scan.health.grade);
468    format!(
469        r#"<div class="card">
470<h3>Health</h3>
471{ring}
472<p class="sub" style="text-align:center;"><span class="badge {badge_class}">{label}</span></p>
473</div>"#
474    )
475}
476
477fn render_risk_card(scan: &ProjectScan) -> String {
478    let badge_class = severity_badge_class(scan.health.risk_level);
479    let label = severity_label(scan.health.risk_level);
480    let total = scan.risks.findings.len();
481    format!(
482        r#"<div class="card">
483<h3>Risk Level</h3>
484<p class="big-number"><span class="badge {badge_class}">{label}</span></p>
485<p class="sub">{total} finding(s)</p>
486</div>"#
487    )
488}
489
490fn render_vcs_card(scan: &ProjectScan) -> String {
491    if !scan.vcs.is_repository {
492        return r#"<div class="card"><h3>Source Control</h3><p class="big-number muted">—</p><p class="sub">not detected</p></div>"#.to_owned();
493    }
494    let kind = vcs_kind_label(scan.vcs.kind);
495    let branch = scan
496        .vcs
497        .branch
498        .as_deref()
499        .map(escape_html)
500        .unwrap_or_else(|| "unknown".to_owned());
501    let activity = match scan.vcs.activity.days_since_last_commit {
502        Some(0) => "today".to_owned(),
503        Some(days) if days < 60 => format!("{days}d ago"),
504        Some(days) if days < 365 => format!("{}mo ago", days / 30),
505        Some(days) => format!("{}y ago", days / 365),
506        None => "no activity data".to_owned(),
507    };
508    format!(
509        r#"<div class="card">
510<h3>Source Control</h3>
511<p class="big-number">{kind}</p>
512<p class="sub"><code>{branch}</code> · last commit {activity}</p>
513</div>"#
514    )
515}
516
517fn render_files_card(scan: &ProjectScan) -> String {
518    format!(
519        r#"<div class="card">
520<h3>Files Scanned</h3>
521<p class="big-number">{files}</p>
522<p class="sub">{lines} code line(s)</p>
523</div>"#,
524        files = scan.files_scanned,
525        lines = scan.code.code_lines,
526    )
527}
528
529fn render_signals_table(scan: &ProjectScan) -> String {
530    if scan.health.signals.is_empty() {
531        return String::new();
532    }
533    let mut rows = String::new();
534    for signal in &scan.health.signals {
535        rows.push_str(&render_signal_row(signal));
536    }
537    format!(
538        r#"<section>
539<h2>Health Signals</h2>
540<table>
541<thead><tr><th>Signal</th><th>Status</th><th>Evidence</th><th>Score</th></tr></thead>
542<tbody>{rows}</tbody>
543</table>
544</section>"#
545    )
546}
547
548fn render_signal_row(signal: &HealthSignal) -> String {
549    let badge_class = signal_status_badge_class(signal.status);
550    let status = signal_status_label(signal.status);
551    let kind = signal_kind_label(signal.kind);
552    let evidence = escape_html(&signal.evidence);
553    let delta = signal.score_delta;
554    let delta_str = if delta > 0 {
555        format!("+{delta}")
556    } else {
557        delta.to_string()
558    };
559    format!(
560        r#"<tr><td>{kind}</td><td><span class="badge {badge_class}">{status}</span></td><td>{evidence}</td><td>{delta_str}</td></tr>"#
561    )
562}
563
564fn render_vcs_section(scan: &ProjectScan) -> String {
565    if !scan.vcs.is_repository {
566        return r#"<section><h2>Source Control</h2><p class="muted">No source control detected.</p></section>"#.to_owned();
567    }
568    let mut rows: Vec<(String, String)> = Vec::new();
569    rows.push(("Kind".to_owned(), vcs_kind_label(scan.vcs.kind).to_owned()));
570    if let Some(branch) = &scan.vcs.branch {
571        let label = match scan.vcs.kind {
572            VcsKind::Svn => "URL",
573            _ => "Branch",
574        };
575        rows.push((label.to_owned(), escape_html(branch)));
576    }
577    if let Some(rev) = &scan.vcs.revision {
578        rows.push(("Revision".to_owned(), escape_html(&short_revision(rev))));
579    }
580    if let Some(last) = &scan.vcs.last_commit {
581        rows.push(("Last commit".to_owned(), escape_html(last)));
582    }
583    let status = if scan.vcs.is_dirty {
584        format!(
585            "dirty · {} modified · {} untracked",
586            scan.vcs.tracked_modified_files, scan.vcs.untracked_files
587        )
588    } else {
589        "clean".to_owned()
590    };
591    rows.push(("Status".to_owned(), escape_html(&status)));
592    if let Some(days) = scan.vcs.activity.days_since_last_commit {
593        rows.push(("Activity".to_owned(), format!("{days} day(s) ago")));
594    }
595    if let Some(count) = scan.vcs.activity.commits_last_90d {
596        rows.push(("Commits 90d".to_owned(), count.to_string()));
597    }
598    if let Some(count) = scan.vcs.activity.contributors_count {
599        rows.push(("Contributors".to_owned(), count.to_string()));
600    }
601    if let Some(first) = &scan.vcs.activity.first_commit_date {
602        rows.push(("First commit".to_owned(), escape_html(first)));
603    }
604    if let Some(root) = &scan.vcs.root {
605        rows.push((
606            "VCS root".to_owned(),
607            escape_html(&root.display().to_string()),
608        ));
609    }
610
611    let mut table_rows = String::new();
612    for (label, value) in rows {
613        table_rows.push_str(&format!(
614            r#"<tr><th style="width:160px;">{label}</th><td>{value}</td></tr>"#
615        ));
616    }
617    let mut section = format!(
618        r#"<section>
619<h2>Source Control</h2>
620<table><tbody>{table_rows}</tbody></table>"#
621    );
622    if !scan.vcs.activity.contributors.is_empty() {
623        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>"#);
624        for c in &scan.vcs.activity.contributors {
625            section.push_str(&format!(
626                r#"<tr><td>{}</td><td><code>{}</code></td><td>{}</td></tr>"#,
627                escape_html(&c.name),
628                escape_html(&c.email),
629                c.commit_count
630            ));
631        }
632        section.push_str("</tbody></table>");
633    }
634    section.push_str("</section>");
635    section
636}
637
638fn short_revision(value: &str) -> String {
639    let trimmed = value.trim();
640    let mut out: String = trimmed.chars().take(12).collect();
641    if trimmed.chars().count() > out.chars().count() {
642        out.push('…');
643    }
644    out
645}
646
647fn render_languages_section(scan: &ProjectScan) -> String {
648    if scan.code.languages.is_empty() {
649        return r#"<section><h2>Languages</h2><p class="muted">No source language files detected.</p></section>"#.to_owned();
650    }
651    let total: usize = scan.code.languages.iter().map(|l| l.total_lines).sum();
652    let mut bar_segments: Vec<(String, f32)> = Vec::new();
653    let mut rows = String::new();
654    for (idx, lang) in scan.code.languages.iter().enumerate() {
655        let pct = if total > 0 {
656            (lang.total_lines as f32 / total as f32) * 100.0
657        } else {
658            0.0
659        };
660        bar_segments.push((format!("{:?}", lang.kind), pct));
661        rows.push_str(&format!(
662            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>"#,
663            color = bar_color(idx),
664            name = escape_html(&format!("{:?}", lang.kind)),
665            files = lang.files,
666            lines = lang.total_lines,
667            pct = pct,
668        ));
669    }
670    let bar = stacked_bar_svg(&bar_segments);
671    format!(
672        r#"<section>
673<h2>Languages</h2>
674<div style="margin-bottom:12px;">{bar}</div>
675<table><thead><tr><th>Language</th><th>Files</th><th>Lines</th><th>Share</th></tr></thead><tbody>{rows}</tbody></table>
676</section>"#
677    )
678}
679
680fn render_dependencies_section(scan: &ProjectScan) -> String {
681    if scan.dependencies.ecosystems.is_empty() {
682        return r#"<section><h2>Dependencies</h2><p class="muted">No dependency manifests detected.</p></section>"#.to_owned();
683    }
684    let mut rows = String::new();
685    for eco in &scan.dependencies.ecosystems {
686        let manifest = eco.manifest.display().to_string();
687        let lock = match &eco.lockfile {
688            Some(path) => format!("<code>{}</code>", escape_html(&path.display().to_string())),
689            None => r#"<span class="muted">none</span>"#.to_owned(),
690        };
691        rows.push_str(&format!(
692            r#"<tr><td>{eco:?}</td><td><code>{manifest}</code></td><td>{lock}</td><td>{total}</td></tr>"#,
693            eco = eco.ecosystem,
694            manifest = escape_html(&manifest),
695            total = eco.total,
696        ));
697    }
698    format!(
699        r#"<section>
700<h2>Dependencies</h2>
701<p class="muted">{count} manifest(s) · {total} dependency entries</p>
702<table><thead><tr><th>Ecosystem</th><th>Manifest</th><th>Lockfile</th><th>Total</th></tr></thead><tbody>{rows}</tbody></table>
703</section>"#,
704        count = scan.dependencies.total_manifests,
705        total = scan.dependencies.total_dependencies,
706    )
707}
708
709fn render_tests_section(scan: &ProjectScan) -> String {
710    if scan.tests.test_files == 0 && scan.tests.commands.is_empty() {
711        return r#"<section><h2>Tests</h2><p class="muted">No tests or test commands detected.</p></section>"#.to_owned();
712    }
713    let mut commands = String::new();
714    for cmd in &scan.tests.commands {
715        commands.push_str(&format!(
716            r#"<li><code>{}</code> <span class="muted">({})</span></li>"#,
717            escape_html(&cmd.command),
718            escape_html(&cmd.name),
719        ));
720    }
721    format!(
722        r#"<section>
723<h2>Tests</h2>
724<p>{files} test file(s) · {cmd_count} command(s)</p>
725<ul>{commands}</ul>
726</section>"#,
727        files = scan.tests.test_files,
728        cmd_count = scan.tests.commands.len(),
729    )
730}
731
732fn render_risks_section(scan: &ProjectScan) -> String {
733    if scan.risks.findings.is_empty() {
734        return r#"<section><h2>Risks</h2><p class="muted">No risks detected. 👌</p></section>"#
735            .to_owned();
736    }
737    let mut rows = String::new();
738    for finding in &scan.risks.findings {
739        rows.push_str(&render_risk_row(finding));
740    }
741    format!(
742        r#"<section>
743<h2>Risks</h2>
744<p class="muted">{total} finding(s) · high: {high} · medium: {medium} · low: {low} · info: {info}</p>
745<table><thead><tr><th>Severity</th><th>Code</th><th>Message</th><th>Path</th></tr></thead><tbody>{rows}</tbody></table>
746</section>"#,
747        total = scan.risks.total,
748        high = scan.risks.high,
749        medium = scan.risks.medium,
750        low = scan.risks.low,
751        info = scan.risks.info,
752    )
753}
754
755fn render_risk_row(finding: &RiskFinding) -> String {
756    let badge_class = severity_badge_class(finding.severity);
757    let label = severity_label(finding.severity);
758    let code = risk_code_label(finding.code);
759    let message = escape_html(&finding.message);
760    let path = match &finding.path {
761        Some(p) => format!("<code>{}</code>", escape_html(&p.display().to_string())),
762        None => r#"<span class="muted">—</span>"#.to_owned(),
763    };
764    format!(
765        r#"<tr><td><span class="badge {badge_class}">{label}</span></td><td><code>{code}</code></td><td>{message}</td><td>{path}</td></tr>"#
766    )
767}
768
769fn render_footer() -> String {
770    format!(
771        r#"<footer>Generated by projd-core {} · {}</footer>"#,
772        env!("CARGO_PKG_VERSION"),
773        "https://crates.io/crates/projd",
774    )
775}
776
777pub fn render_multi_html(scan: &MultiProjectScan) -> String {
778    let title = "Projd Recursive Scan Report".to_owned();
779    let mut body = String::from("<main>");
780    body.push_str(&render_multi_header(scan));
781    body.push_str(&render_multi_aggregate_cards(scan));
782    body.push_str(&render_multi_overview_table(scan));
783    body.push_str(&render_multi_project_details(scan));
784    if !scan.skipped.is_empty() {
785        body.push_str(&render_multi_skipped(scan));
786    }
787    body.push_str("</main>");
788    body.push_str(&render_footer());
789    html_doc(&title, &body)
790}
791
792fn render_multi_header(scan: &MultiProjectScan) -> String {
793    format!(
794        r#"<header>
795<h1>Projd Recursive Scan Report</h1>
796<p class="meta">Root: <code>{root}</code></p>
797</header>"#,
798        root = escape_html(&scan.root.display().to_string()),
799    )
800}
801
802fn render_multi_aggregate_cards(scan: &MultiProjectScan) -> String {
803    let total_card = format!(
804        r#"<div class="card"><h3>Total Roots</h3><p class="big-number">{total}</p><p class="sub">{files} files scanned</p></div>"#,
805        total = scan.summary.total,
806        files = scan.summary.files_scanned,
807    );
808
809    let grade_parts: Vec<String> = [
810        ProjectHealth::Healthy,
811        ProjectHealth::NeedsAttention,
812        ProjectHealth::Risky,
813        ProjectHealth::Unknown,
814    ]
815    .iter()
816    .filter_map(|grade| {
817        scan.summary.by_grade.get(grade).map(|count| {
818            format!(
819                r#"<span class="badge {cls}">{label} {count}</span>"#,
820                cls = grade_badge_class(*grade),
821                label = grade_label(*grade),
822            )
823        })
824    })
825    .collect();
826    let grade_card = format!(
827        r#"<div class="card"><h3>Grades</h3><p>{}</p></div>"#,
828        if grade_parts.is_empty() {
829            "<span class=\"muted\">—</span>".to_owned()
830        } else {
831            grade_parts.join(" ")
832        }
833    );
834
835    let risk_parts: Vec<String> = [
836        RiskSeverity::High,
837        RiskSeverity::Medium,
838        RiskSeverity::Low,
839        RiskSeverity::Info,
840    ]
841    .iter()
842    .filter_map(|level| {
843        scan.summary.by_risk_level.get(level).map(|count| {
844            format!(
845                r#"<span class="badge {cls}">{label} {count}</span>"#,
846                cls = severity_badge_class(*level),
847                label = severity_label(*level),
848            )
849        })
850    })
851    .collect();
852    let risk_card = format!(
853        r#"<div class="card"><h3>Risk Levels</h3><p>{}</p></div>"#,
854        if risk_parts.is_empty() {
855            "<span class=\"muted\">—</span>".to_owned()
856        } else {
857            risk_parts.join(" ")
858        }
859    );
860
861    let skipped_card = if scan.skipped.is_empty() {
862        String::new()
863    } else {
864        format!(
865            r#"<div class="card"><h3>Skipped</h3><p class="big-number">{}</p><p class="sub">see below</p></div>"#,
866            scan.skipped.len()
867        )
868    };
869
870    format!(
871        r#"<section><div class="cards-row">{total_card}{grade_card}{risk_card}{skipped_card}</div></section>"#
872    )
873}
874
875fn render_multi_overview_table(scan: &MultiProjectScan) -> String {
876    if scan.roots.is_empty() {
877        return r#"<section><h2>Projects</h2><p class="muted">No project roots found.</p></section>"#.to_owned();
878    }
879    let mut rows = String::new();
880    for (idx, project) in scan.roots.iter().enumerate() {
881        let rel = relative_display(&scan.root, &project.root);
882        let last = format_last_commit_age(project.vcs.activity.days_since_last_commit);
883        let top_risk = top_risk_summary(project);
884        rows.push_str(&format!(
885            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>"#,
886            n = idx + 1,
887            path = escape_html(&rel),
888            kinds = escape_html(&project_kind_labels(project)),
889            grade_cls = grade_badge_class(project.health.grade),
890            grade = grade_label(project.health.grade),
891            score = project.health.score,
892            last = escape_html(&last),
893            risk = escape_html(&top_risk),
894        ));
895    }
896    format!(
897        r#"<section>
898<h2>Projects</h2>
899<table>
900<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>
901<tbody>{rows}</tbody>
902</table>
903</section>"#
904    )
905}
906
907fn render_multi_project_details(scan: &MultiProjectScan) -> String {
908    if scan.roots.is_empty() {
909        return String::new();
910    }
911    let mut blocks = String::new();
912    for (idx, project) in scan.roots.iter().enumerate() {
913        let rel = relative_display(&scan.root, &project.root);
914        let summary = format!(
915            "[{}/{}] {} — {} ({})",
916            idx + 1,
917            scan.roots.len(),
918            rel,
919            grade_label(project.health.grade),
920            project.health.score,
921        );
922        // Each per-project block reuses the single-project sections.
923        let mut inner = String::new();
924        inner.push_str(&render_summary_cards(project));
925        inner.push_str(&render_signals_table(project));
926        inner.push_str(&render_markers_section(project));
927        inner.push_str(&render_vcs_section(project));
928        inner.push_str(&render_languages_section(project));
929        inner.push_str(&render_dependencies_section(project));
930        inner.push_str(&render_tests_section(project));
931        inner.push_str(&render_risks_section(project));
932        blocks.push_str(&format!(
933            r#"<details><summary>{summary}</summary><div class="details-body">{inner}</div></details>"#,
934            summary = escape_html(&summary),
935        ));
936    }
937    format!("<section><h2>Per-Project Reports</h2>{blocks}</section>")
938}
939
940fn render_multi_skipped(scan: &MultiProjectScan) -> String {
941    let mut rows = String::new();
942    for entry in &scan.skipped {
943        rows.push_str(&format!(
944            r#"<tr><td><code>{path}</code></td><td>{msg}</td></tr>"#,
945            path = escape_html(&entry.path.display().to_string()),
946            msg = escape_html(&entry.message),
947        ));
948    }
949    format!(
950        r#"<section>
951<h2>Skipped Roots</h2>
952<table><thead><tr><th>Path</th><th>Reason</th></tr></thead><tbody>{rows}</tbody></table>
953</section>"#
954    )
955}
956
957fn top_risk_summary(project: &ProjectScan) -> String {
958    if project.risks.findings.is_empty() {
959        return "—".to_owned();
960    }
961    let highest = project
962        .risks
963        .findings
964        .iter()
965        .min_by_key(|finding| finding.severity)
966        .expect("at least one finding");
967    risk_code_label(highest.code).to_owned()
968}
969
970fn project_kind_labels(project: &ProjectScan) -> String {
971    let mut parts: Vec<&str> = Vec::new();
972    if project.has_build_system(BuildSystemKind::Cargo) {
973        parts.push("cargo");
974    }
975    if project.has_build_system(BuildSystemKind::NodePackage) {
976        parts.push("npm");
977    }
978    if project.has_build_system(BuildSystemKind::PythonProject) {
979        parts.push("python");
980    }
981    if project.has_build_system(BuildSystemKind::GoModule) {
982        parts.push("go");
983    }
984    if project.has_build_system(BuildSystemKind::CMake) {
985        parts.push("cmake");
986    }
987    if project.vcs.is_repository {
988        parts.push(vcs_kind_label(project.vcs.kind));
989    }
990    if parts.is_empty() {
991        "—".to_owned()
992    } else {
993        parts.join(", ")
994    }
995}
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000
1001    #[test]
1002    fn escape_html_replaces_special_chars() {
1003        assert_eq!(
1004            escape_html("<script>alert(\"x\")</script>"),
1005            "&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;"
1006        );
1007        assert_eq!(escape_html("a & b"), "a &amp; b");
1008    }
1009
1010    #[test]
1011    fn html_doc_has_doctype_and_charset() {
1012        let out = html_doc("hello", "<main>hi</main>");
1013        assert!(out.starts_with("<!DOCTYPE html>"));
1014        assert!(out.contains("<meta charset=\"utf-8\">"));
1015        assert!(out.contains("<title>hello</title>"));
1016    }
1017
1018    #[test]
1019    fn html_doc_escapes_title() {
1020        let out = html_doc("<bad>", "");
1021        assert!(out.contains("<title>&lt;bad&gt;</title>"));
1022    }
1023
1024    #[test]
1025    fn format_last_commit_age_brackets() {
1026        assert_eq!(format_last_commit_age(None), "—");
1027        assert_eq!(format_last_commit_age(Some(0)), "0d");
1028        assert_eq!(format_last_commit_age(Some(59)), "59d");
1029        assert_eq!(format_last_commit_age(Some(60)), "2mo");
1030        assert_eq!(format_last_commit_age(Some(364)), "12mo");
1031        assert_eq!(format_last_commit_age(Some(365)), "1y");
1032        assert_eq!(format_last_commit_age(Some(800)), "2y");
1033    }
1034}