repopilot 0.3.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 mut output = String::new();

    output.push_str("# RepoPilot Scan Report\n\n");

    output.push_str("## Summary\n\n");
    output.push_str(&format!("- **Path:** `{}`\n", summary.root_path.display()));
    output.push_str(&format!("- **Files analyzed:** {}\n", summary.files_count));
    output.push_str(&format!(
        "- **Directories analyzed:** {}\n",
        summary.directories_count
    ));
    output.push_str(&format!(
        "- **Lines of code:** {}\n\n",
        summary.lines_of_code
    ));

    output.push_str("## Languages\n\n");

    if summary.languages.is_empty() {
        output.push_str("No languages detected.\n\n");
    } else {
        output.push_str("| Language | Files |\n");
        output.push_str("| --- | ---: |\n");

        for language in &summary.languages {
            output.push_str(&format!(
                "| {} | {} |\n",
                escape_table_cell(&language.name),
                language.files_count
            ));
        }

        output.push('\n');
    }

    output.push_str("## Findings\n\n");

    if summary.findings.is_empty() {
        output.push_str("No findings found.\n\n");
    } else {
        output.push_str("| Severity | Rule | Title | Evidence |\n");
        output.push_str("| --- | --- | --- | --- |\n");

        for finding in &summary.findings {
            let evidence = finding
                .evidence
                .first()
                .map(|evidence| {
                    format!(
                        "`{}:{}` — {}",
                        evidence.path.display(),
                        evidence.line_start,
                        evidence.snippet.trim()
                    )
                })
                .unwrap_or_else(|| "No evidence".to_string());

            output.push_str(&format!(
                "| {} | `{}` | {} | {} |\n",
                finding.severity_label(),
                finding.rule_id,
                escape_table_cell(&finding.title),
                escape_table_cell(&evidence)
            ));
        }

        output.push('\n');
    }

    output.push_str("## Markers\n\n");

    let marker_findings: Vec<_> = summary
        .findings
        .iter()
        .filter(|f| f.rule_id.starts_with("code-marker."))
        .collect();

    if marker_findings.is_empty() {
        output.push_str("No TODO/FIXME/HACK markers found.\n");
    } else {
        output.push_str("| Type | File | Line | Snippet |\n");
        output.push_str("| --- | --- | ---: | --- |\n");

        for finding in marker_findings {
            let kind = finding
                .rule_id
                .strip_prefix("code-marker.")
                .unwrap_or("")
                .to_uppercase();

            if let Some(ev) = finding.evidence.first() {
                output.push_str(&format!(
                    "| {} | `{}` | {} | {} |\n",
                    kind,
                    ev.path.display(),
                    ev.line_start,
                    escape_table_cell(ev.snippet.trim())
                ));
            }
        }
    }

    output
}

fn escape_table_cell(value: &str) -> String {
    value.replace('|', "\\|").replace('\n', " ")
}