repopilot 0.20.0

Local-first CLI for reviewing Git changes, security boundaries, and blast radius before merge.
Documentation
use crate::commands::progress::{finish_spinner, make_spinner};
use crate::commands::scan_config::{ScanConfigOverrides, build_scan_config};
use crate::commands::{CliExit, EXIT_RUNTIME, EXIT_USAGE};
use repopilot::config::loader::{load_default_config, load_optional_config};
use repopilot::config::presets::{Preset, apply_preset};
use repopilot::facts::RepoFactsSummary;
use repopilot::findings::feedback::apply_local_feedback;
use repopilot::findings::filter::FindingFilter;
use repopilot::findings::visibility::{FindingVisibilityProfile, apply_visibility_profile};
use repopilot::review::diff::ChangedFile;
use repopilot::scan::scanner::{
    scan_changed_session, scan_resolved_changed_session, scan_session,
    scan_session_with_facts_summary,
};
use repopilot::scan::session::AnalysisSession;
use repopilot::scan::types::ScanSummary;
use repopilot::scan::workspace_scan::scan_workspace_session;
use std::path::PathBuf;
use std::time::{Duration, Instant};

pub enum ProductScanMode {
    Full,
    Workspace,
    Changed {
        since: Option<String>,
    },
    ResolvedChanged {
        changed_files: Vec<ChangedFile>,
        base_ref: Option<String>,
    },
}

pub struct ProductScanRequest {
    pub path: PathBuf,
    pub config_path: Option<PathBuf>,
    pub overrides: ScanConfigOverrides,
    pub preset: Option<String>,
    pub mode: ProductScanMode,
    pub no_progress: bool,
    pub ignore_feedback: bool,
    pub visibility_profile: FindingVisibilityProfile,
    pub pre_visibility_filter: FindingFilter,
}

pub struct ProductScanResult {
    pub summary: ScanSummary,
    pub repo_facts_summary: Option<RepoFactsSummary>,
    pub session: AnalysisSession,
    pub scan_elapsed: Duration,
}

pub fn run_product_scan(
    request: ProductScanRequest,
) -> Result<ProductScanResult, Box<dyn std::error::Error>> {
    run_product_scan_internal(request, false)
}

pub fn run_product_scan_with_facts_summary(
    request: ProductScanRequest,
) -> Result<ProductScanResult, Box<dyn std::error::Error>> {
    run_product_scan_internal(request, true)
}

fn run_product_scan_internal(
    request: ProductScanRequest,
    include_repo_facts_summary: bool,
) -> Result<ProductScanResult, Box<dyn std::error::Error>> {
    let mut repo_config = match &request.config_path {
        Some(config_path) => load_optional_config(config_path)?,
        None => load_default_config()?,
    };

    if let Some(preset) = request.preset.as_deref() {
        let preset = preset.parse::<Preset>().map_err(|_| CliExit {
            code: EXIT_USAGE,
            message: format!("Invalid preset '{preset}'. Expected: strict, balanced, lenient"),
        })?;
        apply_preset(&mut repo_config, preset);
    }

    let scan_config = build_scan_config(&repo_config, request.overrides);
    let session = AnalysisSession::new(
        request.path.clone(),
        repo_config,
        scan_config,
        request.visibility_profile,
    );
    debug_assert!(!session.revision().id().is_empty());

    let pb = if request.no_progress {
        None
    } else {
        make_spinner("Scanning...")
    };
    let scan_start = Instant::now();
    let scan_result = match &request.mode {
        ProductScanMode::Full if include_repo_facts_summary => {
            scan_session_with_facts_summary(&session)
                .map(|(summary, facts_summary)| (summary, Some(facts_summary)))
        }
        ProductScanMode::Full => scan_session(&session).map(|summary| (summary, None)),
        ProductScanMode::Workspace => {
            scan_workspace_session(&session).map(|summary| (summary, None))
        }
        ProductScanMode::Changed { since } => {
            scan_changed_session(&session, since.as_deref()).map(|summary| (summary, None))
        }
        ProductScanMode::ResolvedChanged {
            changed_files,
            base_ref,
        } => scan_resolved_changed_session(&session, changed_files.clone(), base_ref.as_deref())
            .map(|summary| (summary, None)),
    };
    let scan_elapsed = scan_start.elapsed();
    finish_spinner(pb);

    let (mut summary, repo_facts_summary) = scan_result?;

    if !request.ignore_feedback {
        apply_local_feedback(&mut summary, session.analysis_path())?;
    }

    if !request.pre_visibility_filter.is_empty() {
        request.pre_visibility_filter.apply_to_summary(&mut summary);
    }

    apply_visibility_profile(&mut summary, session.visibility_profile());

    Ok(ProductScanResult {
        summary,
        repo_facts_summary,
        session,
        scan_elapsed,
    })
}

pub fn enforce_diagnostics_exit_policy(
    summary: &ScanSummary,
) -> Result<(), Box<dyn std::error::Error>> {
    let Some(diagnostic) = summary.first_error_diagnostic() else {
        return Ok(());
    };

    Err(Box::new(CliExit {
        code: EXIT_RUNTIME,
        message: format!(
            "RepoPilot scan completed with reportable error diagnostic `{}`: {}",
            diagnostic.code, diagnostic.message
        ),
    }))
}

pub fn emit_report_only_diagnostics(summary: &ScanSummary) {
    for diagnostic in &summary.artifacts.diagnostics {
        eprintln!(
            "[{:?}] {}: {}",
            diagnostic.severity, diagnostic.code, diagnostic.message
        );
    }
}