repopilot 0.4.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use crate::scan::types::ScanSummary;

pub fn render(summary: &ScanSummary) -> String {
    let findings_rows = summary
        .findings
        .iter()
        .map(|f| {
            let ev = f
                .evidence
                .first()
                .map(|e| {
                    format!(
                        "<code>{}:{}</code>",
                        escape_html(&e.path.to_string_lossy()),
                        e.line_start
                    )
                })
                .unwrap_or_default();

            let snippet = f
                .evidence
                .first()
                .map(|e| {
                    format!(
                        "<pre class=\"snippet\">{}</pre>",
                        escape_html(e.snippet.trim())
                    )
                })
                .unwrap_or_default();

            let severity_class = f.severity_label().to_lowercase();

            format!(
                "<tr>\
                    <td><span class=\"badge {severity_class}\">{}</span></td>\
                    <td><code>{}</code></td>\
                    <td>{}</td>\
                    <td>{ev}{snippet}</td>\
                 </tr>",
                escape_html(f.severity_label()),
                escape_html(&f.rule_id),
                escape_html(&f.title),
            )
        })
        .collect::<Vec<_>>()
        .join("\n");

    let lang_rows = summary
        .languages
        .iter()
        .map(|l| {
            format!(
                "<tr><td>{}</td><td class=\"num\">{}</td></tr>",
                escape_html(&l.name),
                l.files_count
            )
        })
        .collect::<Vec<_>>()
        .join("\n");

    let findings_empty = if summary.findings.is_empty() {
        "<p class=\"empty\">No findings found.</p>"
    } else {
        ""
    };

    let lang_empty = if summary.languages.is_empty() {
        "<p class=\"empty\">No languages detected.</p>"
    } else {
        ""
    };

    format!(
        r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RepoPilot Scan Report</title>
<style>
  body {{ font-family: system-ui, sans-serif; margin: 0; padding: 2rem; color: #1a1a1a; background: #f8f8f8; }}
  h1 {{ font-size: 1.6rem; margin-bottom: 0.25rem; }}
  h2 {{ font-size: 1.1rem; margin-top: 2rem; border-bottom: 1px solid #ddd; padding-bottom: 0.3rem; }}
  .meta {{ color: #666; font-size: 0.9rem; margin-bottom: 1.5rem; }}
  .cards {{ display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }}
  .card {{ background: #fff; border: 1px solid #e0e0e0; border-radius: 6px; padding: 1rem 1.5rem; min-width: 120px; }}
  .card .num {{ font-size: 1.8rem; font-weight: 700; }}
  .card .label {{ font-size: 0.75rem; color: #888; text-transform: uppercase; letter-spacing: .05em; }}
  table {{ width: 100%; border-collapse: collapse; background: #fff; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px #0001; }}
  th {{ text-align: left; padding: 0.6rem 1rem; background: #f0f0f0; font-size: 0.8rem; text-transform: uppercase; letter-spacing: .05em; }}
  td {{ padding: 0.6rem 1rem; border-top: 1px solid #eee; vertical-align: top; font-size: 0.88rem; }}
  .num {{ text-align: right; }}
  .badge {{ display: inline-block; padding: 0.15rem 0.55rem; border-radius: 3px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; }}
  .badge.info {{ background: #e8f4ff; color: #2563eb; }}
  .badge.low {{ background: #f0fdf4; color: #16a34a; }}
  .badge.medium {{ background: #fffbeb; color: #d97706; }}
  .badge.high {{ background: #fff7ed; color: #ea580c; }}
  .badge.critical {{ background: #fef2f2; color: #dc2626; }}
  pre.snippet {{ margin: 0.3rem 0 0; font-size: 0.8rem; background: #f5f5f5; padding: 0.4rem 0.6rem; border-radius: 4px; overflow: auto; white-space: pre-wrap; }}
  .empty {{ color: #999; font-style: italic; }}
  .filter-bar {{ display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.75rem; }}
  .filter-btn {{ padding: 0.25rem 0.75rem; border: 1px solid #ccc; border-radius: 20px; cursor: pointer; font-size: 0.8rem; background: #fff; }}
  .filter-btn.active {{ background: #1a1a1a; color: #fff; border-color: #1a1a1a; }}
</style>
</head>
<body>
<h1>RepoPilot Scan Report</h1>
<p class="meta">Path: <code>{path}</code></p>

<div class="cards">
  <div class="card"><div class="num">{files}</div><div class="label">Files</div></div>
  <div class="card"><div class="num">{dirs}</div><div class="label">Directories</div></div>
  <div class="card"><div class="num">{loc}</div><div class="label">Lines of Code</div></div>
  <div class="card"><div class="num">{finding_count}</div><div class="label">Findings</div></div>
</div>

<h2>Languages</h2>
{lang_empty}
{lang_table}

<h2>Findings</h2>
{findings_empty}
<div class="filter-bar" id="filter-bar"></div>
{findings_table}

<script>
  const rows = document.querySelectorAll('table#findings tbody tr');
  const bar = document.getElementById('filter-bar');
  const severities = [...new Set([...rows].map(r => r.querySelector('.badge').textContent.trim()))];
  let active = new Set();

  severities.forEach(sev => {{
    const btn = document.createElement('button');
    btn.className = 'filter-btn';
    btn.textContent = sev;
    btn.onclick = () => {{
      if (active.has(sev)) {{ active.delete(sev); btn.classList.remove('active'); }}
      else {{ active.add(sev); btn.classList.add('active'); }}
      rows.forEach(r => {{
        const rowSev = r.querySelector('.badge').textContent.trim();
        r.style.display = active.size === 0 || active.has(rowSev) ? '' : 'none';
      }});
    }};
    bar.appendChild(btn);
  }});
</script>
</body>
</html>"#,
        path = escape_html(&summary.root_path.to_string_lossy()),
        files = summary.files_count,
        dirs = summary.directories_count,
        loc = summary.lines_of_code,
        finding_count = summary.findings.len(),
        lang_empty = lang_empty,
        lang_table = if summary.languages.is_empty() {
            String::new()
        } else {
            format!(
                "<table><thead><tr><th>Language</th><th class=\"num\">Files</th></tr></thead><tbody>{lang_rows}</tbody></table>"
            )
        },
        findings_empty = findings_empty,
        findings_table = if summary.findings.is_empty() {
            String::new()
        } else {
            format!(
                "<table id=\"findings\"><thead><tr><th>Severity</th><th>Rule</th><th>Title</th><th>Evidence</th></tr></thead><tbody>{findings_rows}</tbody></table>"
            )
        },
    )
}

fn escape_html(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}