repopilot 0.18.0

Local-first CLI for reviewing Git changes, security boundaries, and blast radius before merge.
Documentation
use crate::baseline::diff::BaselineScanReport;
use crate::findings::types::{Finding, Severity};
use crate::output::FindingRenderLimit;
use crate::output::render_helpers::escape_table_cell;
use crate::output::report_stats::{
    category_order, first_location, indexed_findings_for_rule, indexed_sorted_findings,
    rule_ids_for_indexed_findings,
};
use crate::output::report_text::{category_label_rank, category_title, first_sentence};
use std::collections::BTreeMap;
use std::fmt::Write;

pub(crate) fn render_findings_index(
    output: &mut String,
    findings: &[Finding],
    baseline: Option<&BaselineScanReport>,
    findings_limit: FindingRenderLimit,
) {
    output.push_str("## Findings Index\n\n");

    if findings.is_empty() {
        output.push_str("No findings found.\n\n");
        return;
    }

    let indexed_findings = limited_indexed_findings(findings, findings_limit);
    render_limit_note(
        output,
        findings.len(),
        indexed_findings.len(),
        findings_limit,
    );

    let rows = grouped_index_rows(&indexed_findings, baseline);
    if baseline.is_some() {
        output.push_str(
            "| Category | Rule | Max severity | Count | New | Existing | First location |\n",
        );
        output.push_str("| --- | --- | --- | ---: | ---: | ---: | --- |\n");
        for row in rows {
            writeln!(
                output,
                "| {} | `{}` | {} | {} | {} | {} | {} |",
                escape_table_cell(&row.category),
                escape_table_cell(&row.rule_id),
                row.severity.label(),
                row.count,
                row.new_count,
                row.existing_count,
                escape_table_cell(&row.first_location.unwrap_or_else(|| "n/a".to_string()))
            )
            .unwrap();
        }
    } else {
        output.push_str("| Category | Rule | Max severity | Count | First location |\n");
        output.push_str("| --- | --- | --- | ---: | --- |\n");
        for row in rows {
            writeln!(
                output,
                "| {} | `{}` | {} | {} | {} |",
                escape_table_cell(&row.category),
                escape_table_cell(&row.rule_id),
                row.severity.label(),
                row.count,
                escape_table_cell(&row.first_location.unwrap_or_else(|| "n/a".to_string()))
            )
            .unwrap();
        }
    }
    output.push('\n');
}

pub(crate) fn render_grouped_findings<F>(
    output: &mut String,
    findings: &[Finding],
    findings_limit: FindingRenderLimit,
    status_for: F,
) where
    F: Fn(usize) -> Option<&'static str>,
{
    output.push_str("## Findings\n\n");

    if findings.is_empty() {
        output.push_str("No findings found.\n");
        return;
    }

    let indexed_findings = limited_indexed_findings(findings, findings_limit);

    for category in category_order() {
        let category_findings = indexed_findings
            .iter()
            .copied()
            .filter(|(_, finding)| finding.category == category)
            .collect::<Vec<_>>();
        if category_findings.is_empty() {
            continue;
        }

        writeln!(output, "### {}\n", category_title(&category)).unwrap();
        let rules = rule_ids_for_indexed_findings(&category_findings);
        for rule_id in rules {
            let rule_findings = indexed_findings_for_rule(&category_findings, &rule_id);
            writeln!(output, "#### `{rule_id}` ({})\n", rule_findings.len()).unwrap();

            for (index, finding) in rule_findings {
                render_finding_detail(output, finding, status_for(index));
            }
        }
    }
}

fn render_finding_detail(output: &mut String, finding: &Finding, status: Option<&str>) {
    writeln!(
        output,
        "- **[{}] {}**",
        finding.severity_label(),
        finding.title
    )
    .unwrap();
    writeln!(output, "  - Confidence: {}", finding.confidence_label()).unwrap();
    writeln!(
        output,
        "  - Priority: {} (risk {}/100)",
        finding.risk.priority.label(),
        finding.risk.score
    )
    .unwrap();
    if let Some(reasons) = risk_reason_text(finding) {
        writeln!(output, "  - Risk signals: {reasons}").unwrap();
    }
    if let Some(status) = status {
        writeln!(output, "  - Baseline: {status}").unwrap();
    }
    if let Some(location) = first_location(finding) {
        writeln!(output, "  - Location: `{location}`").unwrap();
    }
    for evidence in &finding.evidence {
        let location = if evidence.line_start > 0 {
            format!("{}:{}", evidence.path.display(), evidence.line_start)
        } else {
            evidence.path.display().to_string()
        };
        let snippet = evidence.snippet.trim();
        if snippet.is_empty() {
            writeln!(output, "  - Evidence: `{location}`").unwrap();
        } else {
            writeln!(
                output,
                "  - Evidence: `{location}` - {}",
                inline_snippet(snippet)
            )
            .unwrap();
        }
    }
    if !finding.description.is_empty() {
        writeln!(
            output,
            "  - Context: {}",
            first_sentence(&finding.description, 180)
        )
        .unwrap();
    }
    writeln!(
        output,
        "  - Recommendation: {}",
        first_sentence(finding.recommendation_or_default(), 220)
    )
    .unwrap();
    if let Some(url) = &finding.docs_url {
        writeln!(output, "  - Docs: {url}").unwrap();
    }
    output.push('\n');
}

fn risk_reason_text(finding: &Finding) -> Option<String> {
    let reasons = finding
        .risk
        .signals
        .iter()
        .take(4)
        .map(|signal| format!("{:+} {}: {}", signal.weight, signal.label, signal.reason))
        .collect::<Vec<_>>();

    (!reasons.is_empty()).then(|| reasons.join(", "))
}

struct IndexRow {
    category: String,
    rule_id: String,
    severity: Severity,
    count: usize,
    new_count: usize,
    existing_count: usize,
    first_location: Option<String>,
}

fn grouped_index_rows(
    findings: &[(usize, &Finding)],
    baseline: Option<&BaselineScanReport>,
) -> Vec<IndexRow> {
    let mut rows: BTreeMap<(String, String), IndexRow> = BTreeMap::new();

    for (index, finding) in findings.iter().copied() {
        let key = (
            finding.category.label().to_string(),
            finding.rule_id.clone(),
        );
        let row = rows.entry(key).or_insert_with(|| IndexRow {
            category: finding.category.label().to_string(),
            rule_id: finding.rule_id.clone(),
            severity: finding.severity,
            count: 0,
            new_count: 0,
            existing_count: 0,
            first_location: first_location(finding),
        });
        row.count += 1;
        row.severity = row.severity.max(finding.severity);
        if row.first_location.is_none() {
            row.first_location = first_location(finding);
        }
        if let Some(report) = baseline {
            match report.finding_status(index).lowercase_label() {
                "new" => row.new_count += 1,
                "existing" => row.existing_count += 1,
                _ => {}
            }
        }
    }

    let mut rows = rows.into_values().collect::<Vec<_>>();
    rows.sort_by(|left, right| {
        category_label_rank(&left.category)
            .cmp(&category_label_rank(&right.category))
            .then_with(|| right.severity.cmp(&left.severity))
            .then_with(|| right.count.cmp(&left.count))
            .then_with(|| left.rule_id.cmp(&right.rule_id))
    });
    rows
}

fn limited_indexed_findings(
    findings: &[Finding],
    findings_limit: FindingRenderLimit,
) -> Vec<(usize, &Finding)> {
    let shown = findings_limit.detailed_limit(findings.len());
    indexed_sorted_findings(findings)
        .into_iter()
        .take(shown)
        .collect()
}

fn render_limit_note(
    output: &mut String,
    total: usize,
    shown: usize,
    findings_limit: FindingRenderLimit,
) {
    if matches!(findings_limit, FindingRenderLimit::Limit(_)) && shown < total {
        writeln!(
            output,
            "Showing {shown} of {total} findings. Use `--max-findings none` to render all findings.\n"
        )
        .unwrap();
    }
}

fn inline_snippet(snippet: &str) -> String {
    snippet.replace('`', "'")
}