repopilot 0.20.0

Local-first CLI for reviewing Git changes, security boundaries, and blast radius before merge.
Documentation
use crate::graph::context::{ContextGraphFileMetric, ContextGraphSummary};
use crate::graph::coupling_file_metrics;
use crate::scan::types::ScanSummary;
use std::fmt::Write as FmtWrite;

pub(super) fn render_hot_files(out: &mut String, summary: &ScanSummary) {
    if let Some(graph) = &summary.artifacts.context_graph_summary {
        render_context_risk_graph(out, summary, graph);
        return;
    }

    let graph = match &summary.artifacts.coupling_graph {
        Some(g) => g,
        None => return,
    };

    let mut metrics = coupling_file_metrics(graph);
    metrics.retain(|m| m.fan_in > 0);
    if metrics.is_empty() {
        return;
    }
    metrics.sort_by(|a, b| b.fan_in.cmp(&a.fan_in).then(a.fan_out.cmp(&b.fan_out)));

    let _ = writeln!(out, "## Hot Files (most imported)\n");
    let _ = writeln!(out, "| File | Fan-in | Fan-out | Instability |");
    let _ = writeln!(out, "|---|---|---|---|");
    for m in metrics.iter().take(5) {
        let path = m.path.display().to_string();
        let _ = writeln!(
            out,
            "| {} | {} | {} | {:.2} |",
            path, m.fan_in, m.fan_out, m.instability
        );
    }
    out.push('\n');
}

fn render_context_risk_graph(out: &mut String, summary: &ScanSummary, graph: &ContextGraphSummary) {
    if graph.files == 0 {
        return;
    }

    let _ = writeln!(out, "## Context Risk Graph\n");
    if let Some(cache) = &summary.artifacts.context_graph_cache {
        let _ = writeln!(
            out,
            "- Cache: {} ({})",
            cache.status,
            cache.reason.replace('|', "/")
        );
    }
    let _ = writeln!(
        out,
        "- Graph size: {} files, {} import edges",
        graph.files, graph.import_edges
    );

    render_edit_order(out, graph);
    render_blast_radius(out, graph);
    render_high_context_files(out, graph);
    render_verification_focus(out, graph);
    out.push('\n');
}

fn render_edit_order(out: &mut String, graph: &ContextGraphSummary) {
    let _ = writeln!(out, "\n### Edit Order");
    if graph.risky_clusters.is_empty() && graph.top_dependencies.is_empty() {
        let _ = writeln!(
            out,
            "- Start with the changed files, then shared dependencies."
        );
        return;
    }

    for cluster in graph.risky_clusters.iter().take(4) {
        let _ = writeln!(
            out,
            "- {} in `{}`: {} finding(s), max risk {}",
            cluster.rule_id, cluster.scope, cluster.count, cluster.max_score
        );
    }
    for file in graph.top_dependencies.iter().take(3) {
        let _ = writeln!(
            out,
            "- Shared dependency `{}` before downstream callers (fan-in {}, fan-out {})",
            file.path.display(),
            file.fan_in,
            file.fan_out
        );
    }
}

fn render_blast_radius(out: &mut String, graph: &ContextGraphSummary) {
    let _ = writeln!(out, "\n### Blast Radius");
    if graph.changed_blast_radius.is_empty() {
        let _ = writeln!(
            out,
            "- No import-based downstream files detected for this scope."
        );
        return;
    }

    for path in graph.changed_blast_radius.iter().take(8) {
        let _ = writeln!(out, "- `{}`", path.display());
    }
}

fn render_high_context_files(out: &mut String, graph: &ContextGraphSummary) {
    let _ = writeln!(out, "\n### High-Context Files");
    let mut rendered = 0usize;
    for file in graph.top_dependencies.iter().take(5) {
        render_file_metric(out, "dependency", file);
        rendered += 1;
    }
    for file in graph.top_hubs.iter().take(5usize.saturating_sub(rendered)) {
        render_file_metric(out, "hub", file);
    }
}

fn render_file_metric(out: &mut String, label: &str, file: &ContextGraphFileMetric) {
    let roles = if file.roles.is_empty() {
        String::new()
    } else {
        format!("; roles {}", file.roles.join(","))
    };
    let _ = writeln!(
        out,
        "- `{}`: {label}, fan-in {}, fan-out {}, instability {:.2}{}",
        file.path.display(),
        file.fan_in,
        file.fan_out,
        file.instability,
        roles
    );
}

fn render_verification_focus(out: &mut String, graph: &ContextGraphSummary) {
    let _ = writeln!(out, "\n### Verification Focus");
    if !graph.cycles.is_empty() {
        let _ = writeln!(
            out,
            "- Re-run architecture checks around {} import cycle(s).",
            graph.cycles.len()
        );
    }
    if !graph.risky_clusters.is_empty() {
        let _ = writeln!(out, "- Re-scan the top risky clusters after edits.");
    }
    if !graph.changed_blast_radius.is_empty() {
        let _ = writeln!(
            out,
            "- Test files in the blast radius, not only touched files."
        );
    }
    if graph.cycles.is_empty()
        && graph.risky_clusters.is_empty()
        && graph.changed_blast_radius.is_empty()
    {
        let _ = writeln!(
            out,
            "- Run the focused scan command for the selected severity scope."
        );
    }
}