repopilot 0.12.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use crate::findings::types::Finding;
use crate::output::color;
use crate::output::report_stats::{
    category_order, findings_for_category, findings_for_rule, first_location, rule_ids_for_findings,
};
use crate::output::report_text::{category_title, first_sentence};
use std::fmt::Write;

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

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

    for category in category_order() {
        let category_findings = findings_for_category(findings, &category);
        if category_findings.is_empty() {
            continue;
        }

        writeln!(output, "  {}:", category_title(&category)).unwrap();
        let rules = rule_ids_for_findings(&category_findings);
        for rule_id in rules {
            let rule_findings = findings_for_rule(&category_findings, &rule_id);
            writeln!(output, "    {} ({})", rule_id, rule_findings.len()).unwrap();
            for finding in rule_findings {
                let index = findings
                    .iter()
                    .position(|candidate| std::ptr::eq(candidate, finding))
                    .unwrap_or(0);
                render_finding(output, finding, status_for(index));
            }
        }
        output.push('\n');
    }
}

fn render_finding(output: &mut String, finding: &Finding, status: Option<&str>) {
    let severity = color::severity_label(finding.severity_label());
    writeln!(output, "      [{}] {}", severity, 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} - {snippet}").unwrap();
        }
    }
    if !finding.description.is_empty() {
        writeln!(
            output,
            "        {}",
            color::dim(&first_sentence(&finding.description, 120))
        )
        .unwrap();
    }
    writeln!(
        output,
        "        Recommendation: {}",
        first_sentence(finding.recommendation_or_default(), 180)
    )
    .unwrap();
    if let Some(url) = &finding.docs_url {
        writeln!(output, "        Docs: {url}").unwrap();
    }
}

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

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