repopilot 0.9.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use crate::findings::types::{Finding, FindingCategory, Severity};
use crate::output::finding_helpers::{
    RuleCluster, category_rank, clusters_by_rule, example_locations, finding_recommendation,
};
use crate::output::vibe::{DEFAULT_TOKEN_BUDGET, VibeCategory, project_name};
use crate::scan::types::ScanSummary;
use std::fmt::Write as FmtWrite;

pub struct HardenOptions {
    pub focus: Option<VibeCategory>,
    pub budget_tokens: usize,
}

impl Default for HardenOptions {
    fn default() -> Self {
        Self {
            focus: None,
            budget_tokens: DEFAULT_TOKEN_BUDGET,
        }
    }
}

pub fn render(summary: &ScanSummary, opts: &HardenOptions) -> String {
    let project_name = project_name(summary);
    let budget_chars = opts.budget_tokens.saturating_mul(4);

    let findings: Vec<&Finding> = summary
        .findings
        .iter()
        .filter(|finding| {
            opts.focus
                .as_ref()
                .is_none_or(|focus| focus.matches(&finding.category))
        })
        .collect();
    let mut clusters = clusters_by_rule(&findings);
    sort_harden_clusters(&mut clusters);

    let mut out = String::new();
    let _ = writeln!(out, "# RepoPilot Harden Plan - {project_name}\n");
    let _ = writeln!(
        out,
        "Prioritized remediation plan generated locally from RepoPilot findings. Start at P0 and stop when the remaining risk is acceptable for this release.\n"
    );
    render_summary(&mut out, &findings);

    if findings.is_empty() {
        let _ = writeln!(out, "No findings matched the selected scope.");
        render_footer(&mut out, summary.scan_duration_us);
        return out;
    }

    let mut current_priority = None;
    let content_start = out.len();

    for (index, cluster) in clusters.iter().enumerate() {
        let priority = priority_label(cluster_priority(cluster));
        if current_priority != Some(priority) {
            let _ = writeln!(out, "\n## {priority}");
            current_priority = Some(priority);
        }

        let mut item = String::new();
        render_cluster_plan(&mut item, cluster, index + 1);
        if index > 0 && out.len().saturating_sub(content_start) + item.len() > budget_chars {
            let _ = writeln!(out, "\n*[Plan truncated to stay within token budget]*");
            break;
        }
        out.push_str(&item);
    }

    render_verification(&mut out);
    render_footer(&mut out, summary.scan_duration_us);
    out
}

fn render_summary(out: &mut String, findings: &[&Finding]) {
    let critical = findings
        .iter()
        .filter(|finding| finding.severity == Severity::Critical)
        .count();
    let high = findings
        .iter()
        .filter(|finding| finding.severity == Severity::High)
        .count();
    let medium = findings
        .iter()
        .filter(|finding| finding.severity == Severity::Medium)
        .count();
    let _ = writeln!(
        out,
        "## Priority Summary\n\n- Total: {} findings\n- Critical: {critical}\n- High: {high}\n- Medium: {medium}",
        findings.len()
    );
}

fn render_cluster_plan(out: &mut String, cluster: &RuleCluster<'_>, index: usize) {
    let count_note = if cluster.findings.len() > 1 {
        format!(" ({} findings)", cluster.findings.len())
    } else {
        String::new()
    };
    let _ = writeln!(
        out,
        "\n### {index}. [{}] {}{}",
        cluster.severity.label(),
        cluster.title,
        count_note
    );
    let _ = writeln!(out, "- Rule: `{}`", cluster.rule_id);

    let examples = example_locations(&cluster.findings, 3);
    if !examples.is_empty() {
        let _ = writeln!(out, "- Examples: {}", examples.join(", "));
    }

    let first = cluster.findings[0];
    if !first.description.is_empty() {
        let _ = writeln!(out, "- Why: {}", first.description);
    }

    let _ = writeln!(out, "- Fix: {}", finding_recommendation(first));
}

fn render_verification(out: &mut String) {
    let _ = writeln!(
        out,
        "\n## Verify\n\n- Run `repopilot scan . --min-severity high` after P0/P1 fixes.\n- Run `repopilot review . --base origin/main --fail-on new-high` before merging.\n- Refresh a baseline only when the remaining findings are accepted technical debt."
    );
}

fn render_footer(out: &mut String, scan_duration_us: u64) {
    let scan_ms = scan_duration_us / 1000;
    let _ = writeln!(out, "\n---\n*Generated by RepoPilot in {scan_ms}ms.*");
}

fn priority_label(priority: u8) -> &'static str {
    match priority {
        0 => "P0 - Immediate risk",
        1 => "P1 - High-impact hardening",
        2 => "P2 - Quality and maintainability",
        _ => "P3 - Backlog cleanup",
    }
}

fn priority_rank(finding: &Finding) -> u8 {
    if finding.severity == Severity::Critical
        || (finding.severity == Severity::High && finding.category == FindingCategory::Security)
    {
        0
    } else if finding.severity == Severity::High {
        1
    } else if finding.severity == Severity::Medium {
        2
    } else {
        3
    }
}

fn sort_harden_clusters(clusters: &mut [RuleCluster<'_>]) {
    clusters.sort_by(|left, right| {
        cluster_priority(left)
            .cmp(&cluster_priority(right))
            .then_with(|| right.severity.cmp(&left.severity))
            .then_with(|| cluster_category_rank(left).cmp(&cluster_category_rank(right)))
            .then_with(|| right.findings.len().cmp(&left.findings.len()))
            .then_with(|| left.rule_id.cmp(right.rule_id))
    });
}

fn cluster_priority(cluster: &RuleCluster<'_>) -> u8 {
    cluster
        .findings
        .iter()
        .map(|finding| priority_rank(finding))
        .min()
        .unwrap_or(3)
}

fn cluster_category_rank(cluster: &RuleCluster<'_>) -> u8 {
    cluster
        .findings
        .first()
        .map(|finding| category_rank(&finding.category))
        .unwrap_or(u8::MAX)
}