1use 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("&"),
154 '<' => out.push_str("<"),
155 '>' => out.push_str(">"),
156 '"' => out.push_str("""),
157 '\'' => out.push_str("'"),
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
329pub(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 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 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 "<script>alert("x")</script>"
1006 );
1007 assert_eq!(escape_html("a & b"), "a & 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><bad></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}