repopilot 0.9.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use super::escape::escape_html;
use crate::baseline::diff::BaselineScanReport;
use crate::baseline::gate::CiGateResult;
use crate::findings::types::Severity;
use crate::output::report_stats::{ReportStats, severity_order};
use crate::scan::types::ScanSummary;

mod frameworks;

pub(super) fn render_summary_cards(summary: &ScanSummary, stats: &ReportStats) -> String {
    let mut cards = vec![
        summary_card(stats.risk_label, "Risk"),
        summary_card(format!("{}/100", stats.health_score), "Health"),
        summary_card(stats.total_findings, "Findings"),
        summary_card(summary.files_count, "Files"),
        summary_card(summary.lines_of_code, "Lines of Code"),
        summary_card(format!("{:.1}/kloc", stats.finding_density), "Density"),
    ];

    if summary.skipped_files_count > 0 {
        cards.push(summary_card(summary.skipped_files_count, "Skipped"));
    }

    cards.join("\n  ")
}

pub(super) fn render_baseline_summary_cards(
    report: &BaselineScanReport,
    stats: &ReportStats,
) -> String {
    let mut cards = vec![
        summary_card(stats.risk_label, "Risk"),
        summary_card(format!("{}/100", stats.health_score), "Health"),
        summary_card(report.summary.findings.len(), "Findings"),
        summary_card(report.new_count(), "New"),
        summary_card(report.existing_count(), "Existing"),
        summary_card(report.summary.files_count, "Files"),
    ];

    if report.summary.skipped_files_count > 0 {
        cards.push(summary_card(report.summary.skipped_files_count, "Skipped"));
    }

    cards.join("\n  ")
}

pub(super) fn render_baseline_meta(
    report: &BaselineScanReport,
    ci_gate: Option<&CiGateResult>,
) -> String {
    let baseline = match &report.baseline_path {
        Some(path) => format!(
            "Baseline: <code>{}</code>",
            escape_html(&path.to_string_lossy())
        ),
        None => "Baseline: none (all findings treated as new)".to_string(),
    };
    let gate = ci_gate
        .map(|ci_gate| {
            let status = if ci_gate.passed() { "passed" } else { "failed" };
            format!(" CI gate: {status} ({})", escape_html(&ci_gate.label()))
        })
        .unwrap_or_default();

    format!(r#"<p class="meta">{baseline}.{gate}</p>"#)
}

pub(super) fn render_risk_section(stats: &ReportStats) -> String {
    let severity_items = severity_order()
        .iter()
        .filter_map(|severity| {
            let count = stats.severity_count(*severity);
            (count > 0).then(|| {
                format!(
                    r#"<li class="pill"><span class="badge {}">{}</span> {}</li>"#,
                    severity.lowercase_label(),
                    severity.label(),
                    count
                )
            })
        })
        .collect::<Vec<_>>();

    let category_items = stats
        .category_counts
        .iter()
        .map(|count| {
            format!(
                r#"<li class="pill">{} {}</li>"#,
                escape_html(&count.label),
                count.count
            )
        })
        .collect::<Vec<_>>();

    let severity = if severity_items.is_empty() {
        "<p class=\"empty\">No findings found.</p>".to_string()
    } else {
        format!(
            r#"<ul class="inline-list">{}</ul>"#,
            severity_items.join("")
        )
    };
    let categories = if category_items.is_empty() {
        String::new()
    } else {
        format!(
            r#"<h3>Categories</h3><ul class="inline-list">{}</ul>"#,
            category_items.join("")
        )
    };

    format!(
        r#"<section class="panel"><h2>Risk Summary</h2><h3>Severity</h3>{severity}{categories}</section>"#
    )
}

pub(super) fn render_top_rules_section(stats: &ReportStats) -> String {
    if stats.top_rules.is_empty() {
        return "<section class=\"panel\"><h2>Top Rules</h2><p class=\"empty\">No rules triggered.</p></section>".to_string();
    }

    let rows = stats
        .top_rules
        .iter()
        .map(|rule| {
            let severity = rule.severity.unwrap_or(Severity::Info);
            format!(
                "<tr><td><code>{}</code></td><td class=\"num-cell\">{}</td><td><span class=\"badge {}\">{}</span></td></tr>",
                escape_html(&rule.label),
                rule.count,
                severity.lowercase_label(),
                severity.label()
            )
        })
        .collect::<Vec<_>>()
        .join("\n");

    format!(
        "<section><h2>Top Rules</h2><table><thead><tr><th>Rule</th><th class=\"num-cell\">Count</th><th>Max severity</th></tr></thead><tbody>{rows}</tbody></table></section>"
    )
}

pub(super) fn render_filter_bar(stats: &ReportStats) -> String {
    if stats.total_findings == 0 {
        return String::new();
    }

    let mut chips = vec![
        r#"<button class="filter-chip clear" type="button" data-filter-clear>Clear filters</button>"#
            .to_string(),
    ];

    for severity in severity_order() {
        let count = stats.severity_count(severity);
        if count > 0 {
            chips.push(filter_chip(
                "severity",
                severity.lowercase_label(),
                &format!("{} ({count})", severity.label()),
            ));
        }
    }

    for category in &stats.category_counts {
        chips.push(filter_chip(
            "category",
            &category.label,
            &format!("{} ({})", category.label, category.count),
        ));
    }

    for rule in &stats.top_rules {
        chips.push(filter_chip(
            "rule",
            &rule.label,
            &format!("{} ({})", rule.label, rule.count),
        ));
    }

    format!(r#"<div class="filters">{}</div>"#, chips.join("\n"))
}

pub(super) fn render_languages_section(summary: &ScanSummary) -> String {
    if summary.languages.is_empty() {
        return "<p class=\"empty\">No languages detected.</p>".to_string();
    }

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

    format!(
        "<table><thead><tr><th>Language</th><th class=\"num-cell\">Files</th></tr></thead><tbody>{rows}</tbody></table>"
    )
}

pub(super) fn render_frameworks_section(summary: &ScanSummary) -> String {
    frameworks::render_frameworks_section(summary)
}

fn summary_card(value: impl ToString, label: &str) -> String {
    format!(
        r#"<div class="card"><div class="num">{}</div><div class="label">{}</div></div>"#,
        escape_html(&value.to_string()),
        escape_html(label)
    )
}

fn filter_chip(filter_type: &str, value: &str, label: &str) -> String {
    format!(
        r#"<button class="filter-chip" type="button" data-filter-type="{}" data-filter-value="{}">{}</button>"#,
        escape_html(filter_type),
        escape_html(value),
        escape_html(label)
    )
}