repopilot 0.20.0

Local-first CLI for reviewing Git changes, security boundaries, and blast radius before merge.
Documentation
mod budget;
mod findings;
mod guardrails;
mod header;
mod hotfiles;
mod json;
mod recommendations;
mod repo_facts;

pub use budget::SectionBreakdown;
pub use json::{AI_CONTEXT_ARTIFACT_VERSION, AI_CONTEXT_JSON_SCHEMA_VERSION, render_json};

use crate::facts::RepoFactsSummary;
use crate::findings::types::Finding;
use crate::output::ai_context::budget::BreakdownSection;
use crate::scan::types::ScanSummary;
use std::path::Path;
use std::str::FromStr;

pub const DEFAULT_TOKEN_BUDGET: usize = 4096;

pub struct AiContextRenderOptions {
    pub focus: Option<AiFocusCategory>,
    /// Approximate token budget (1 token ≈ 4 chars).
    pub budget_tokens: usize,
    pub no_header: bool,
    /// Suppress the AI task instruction block (implies no_header keeps it hidden too).
    pub no_task: bool,
}

impl Default for AiContextRenderOptions {
    fn default() -> Self {
        Self {
            focus: None,
            budget_tokens: DEFAULT_TOKEN_BUDGET,
            no_header: false,
            no_task: false,
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AiFocusCategory {
    Security,
    Architecture,
    Quality,
    Framework,
    All,
}

impl FromStr for AiFocusCategory {
    type Err = ();

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "security" => Ok(Self::Security),
            "arch" | "architecture" => Ok(Self::Architecture),
            "quality" => Ok(Self::Quality),
            "framework" => Ok(Self::Framework),
            "all" => Ok(Self::All),
            _ => Err(()),
        }
    }
}

impl AiFocusCategory {
    pub(crate) fn matches(&self, category: &crate::findings::types::FindingCategory) -> bool {
        use crate::findings::types::FindingCategory;
        match self {
            AiFocusCategory::All => true,
            AiFocusCategory::Security => matches!(category, FindingCategory::Security),
            AiFocusCategory::Architecture => matches!(category, FindingCategory::Architecture),
            AiFocusCategory::Quality => matches!(
                category,
                FindingCategory::CodeQuality | FindingCategory::Testing
            ),
            AiFocusCategory::Framework => matches!(category, FindingCategory::Framework),
        }
    }

    fn includes_architecture_context(&self) -> bool {
        matches!(self, AiFocusCategory::Architecture | AiFocusCategory::All)
    }
}

/// Render the AI context, discarding breakdown info.
pub fn render(summary: &ScanSummary, opts: &AiContextRenderOptions) -> String {
    let (content, _) = render_internal(summary, None, opts);
    content
}

/// Render the AI context and return per-section token breakdown for display to the user.
pub fn render_with_breakdown(
    summary: &ScanSummary,
    opts: &AiContextRenderOptions,
) -> (String, SectionBreakdown) {
    render_internal(summary, None, opts)
}

/// Render AI context with aggregate repository facts and token breakdown.
pub fn render_with_facts_summary_and_breakdown(
    summary: &ScanSummary,
    facts_summary: Option<&RepoFactsSummary>,
    opts: &AiContextRenderOptions,
) -> (String, SectionBreakdown) {
    render_internal(summary, facts_summary, opts)
}

fn render_internal(
    summary: &ScanSummary,
    facts_summary: Option<&RepoFactsSummary>,
    opts: &AiContextRenderOptions,
) -> (String, SectionBreakdown) {
    let budget_chars = opts.budget_tokens * 4;

    let findings: Vec<&Finding> = summary
        .artifacts
        .findings
        .iter()
        .filter(|f| {
            opts.focus
                .as_ref()
                .is_none_or(|focus| focus.matches(&f.category))
        })
        .collect();

    let mut out = String::new();
    let mut sections: Vec<BreakdownSection> = Vec::new();

    // Task instruction + working rules (shown before header so the AI sees
    // intent first). These are agent guidance, so they are suppressed with the
    // task block — an agent supplying its own instructions gets fact-only context.
    if !opts.no_header && !opts.no_task {
        let pre = out.len();
        header::render_task_instruction(&mut out, &findings, summary);
        guardrails::render_working_rules(&mut out);
        let added = out.len() - pre;
        if added > 0 {
            sections.push(BreakdownSection {
                label: "Task instruction".into(),
                tokens: added / 4,
            });
        }
    }

    // Project header (risk, tech stack, size, health).
    if !opts.no_header {
        let pre = out.len();
        header::render_header(&mut out, summary, &findings);
        sections.push(BreakdownSection {
            label: "Header".into(),
            tokens: (out.len() - pre) / 4,
        });
    }

    if let Some(facts_summary) = facts_summary {
        let pre = out.len();
        repo_facts::render_summary(&mut out, facts_summary);
        sections.push(BreakdownSection {
            label: "Repository Facts".into(),
            tokens: (out.len() - pre) / 4,
        });
    }

    // Findings — 2-pass budget allocation.
    let compact = opts.no_header;
    let infos = findings::render_findings_by_category(&mut out, &findings, budget_chars, compact);
    for info in &infos {
        if info.chars_added > 0 {
            sections.push(BreakdownSection {
                label: info.label.clone(),
                tokens: info.chars_added / 4,
            });
        }
    }

    // Hot files coupling table (architecture-relevant only).
    if opts
        .focus
        .as_ref()
        .is_none_or(AiFocusCategory::includes_architecture_context)
    {
        let pre = out.len();
        hotfiles::render_hot_files(&mut out, summary);
        let added = out.len() - pre;
        if added > 0 {
            sections.push(BreakdownSection {
                label: "Hot Files".into(),
                tokens: added / 4,
            });
        }
    }

    // Remediation plan: Context Risk Graph edit order + P0–P3 finding clusters.
    // Replaces the lighter "Top Recommendations" list — same clustering, but
    // prioritized and ordered for action.
    let pre = out.len();
    crate::output::ai_plan::render_plan_section(
        &mut out,
        summary,
        opts.focus.as_ref(),
        budget_chars,
    );
    let plan_added = out.len() - pre;
    if plan_added > 0 {
        sections.push(BreakdownSection {
            label: "Remediation Plan".into(),
            tokens: plan_added / 4,
        });
    }

    // Verification checklist — part of the AI task guidance.
    if !opts.no_header && !opts.no_task {
        let pre = out.len();
        crate::output::ai_plan::render_verification(&mut out);
        let added = out.len() - pre;
        if added > 0 {
            sections.push(BreakdownSection {
                label: "Verify".into(),
                tokens: added / 4,
            });
        }
    }

    // Footer.
    let pre = out.len();
    let content_len = out.len();
    recommendations::render_footer(
        &mut out,
        content_len,
        opts.budget_tokens,
        summary.scan_duration_us,
    );
    sections.push(BreakdownSection {
        label: "Footer".into(),
        tokens: (out.len() - pre) / 4,
    });

    let total_tokens = out.len() / 4;
    let shown_findings: usize = infos.iter().map(|i| i.shown).sum();
    let hidden_findings = findings.len().saturating_sub(shown_findings);

    let breakdown = SectionBreakdown {
        sections,
        total_tokens,
        budget_tokens: opts.budget_tokens,
        hidden_findings,
    };

    (out, breakdown)
}

pub(crate) fn project_name(summary: &ScanSummary) -> String {
    path_name(&summary.root_path)
        .or_else(|| {
            (summary.root_path == Path::new("."))
                .then(|| std::env::current_dir().ok())
                .flatten()
                .and_then(|path| path_name(&path))
        })
        .unwrap_or_else(|| "project".to_string())
}

fn path_name(path: &Path) -> Option<String> {
    path.file_name()
        .and_then(|name| name.to_str())
        .filter(|name| !name.is_empty() && *name != ".")
        .map(str::to_string)
}

#[cfg(test)]
mod tests;