repopilot 0.16.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::model::RepoPilotConfig;
use repopilot::config::presets::{Preset, apply_preset};
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_with_config, scan_path_with_config, scan_resolved_changed_with_config,
};
use repopilot::scan::types::ScanSummary;
use repopilot::scan::workspace_scan::scan_workspace_with_config;
use std::path::PathBuf;
use std::time::{Duration, Instant};

pub enum ProductScanMode {
    Full,
    Workspace,
    Changed {
        since: Option<String>,
    },
    ResolvedChanged {
        repo_root: PathBuf,
        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_config: RepoPilotConfig,
    pub scan_elapsed: Duration,
}

pub fn run_product_scan(
    request: ProductScanRequest,
) -> 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 pb = if request.no_progress {
        None
    } else {
        make_spinner("Scanning...")
    };
    let scan_start = Instant::now();
    let scan_result = match &request.mode {
        ProductScanMode::Full => scan_path_with_config(&request.path, &scan_config),
        ProductScanMode::Workspace => scan_workspace_with_config(&request.path, &scan_config),
        ProductScanMode::Changed { since } => {
            scan_changed_with_config(&request.path, &scan_config, since.as_deref())
        }
        ProductScanMode::ResolvedChanged {
            repo_root,
            changed_files,
            base_ref,
        } => scan_resolved_changed_with_config(
            &request.path,
            &scan_config,
            repo_root.clone(),
            changed_files.clone(),
            base_ref.as_deref(),
        ),
    };
    let scan_elapsed = scan_start.elapsed();
    finish_spinner(pb);

    let mut summary = scan_result?;

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

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

    apply_visibility_profile(&mut summary, request.visibility_profile);

    Ok(ProductScanResult {
        summary,
        repo_config,
        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
        );
    }
}