repopilot 0.12.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use crate::cli::ScanOptions;
use crate::commands::filters::{scan_pre_visibility_filter, scan_priority_filter};
use crate::commands::progress::{finish_spinner, make_spinner};
use crate::commands::scan_config::{ScanConfigOverrides, build_scan_config};
use crate::commands::{CliExit, EXIT_FINDINGS, EXIT_RUNTIME, EXIT_USAGE};
use repopilot::baseline::diff::{all_findings_new, diff_summary_against_baseline};
use repopilot::baseline::gate::{FailOn, evaluate_ci_gate};
use repopilot::baseline::reader::read_baseline;
use repopilot::config::loader::{load_default_config, load_optional_config};
use repopilot::config::presets::{Preset, apply_preset};
use repopilot::findings::feedback::apply_local_feedback;
use repopilot::findings::visibility::{FindingVisibilityProfile, apply_visibility_profile};
use repopilot::output::{render_baseline_scan_report, render_scan_summary};
use repopilot::receipt::{build_audit_receipt, render_receipt_json};
use repopilot::report::writer::write_report;
use repopilot::scan::scanner::{scan_changed_with_config, scan_path_with_config};
use repopilot::scan::types::ScanSummary;
use repopilot::scan::workspace_scan::scan_workspace_with_config;
use std::path::Path;
use std::time::Instant;

pub fn run(options: ScanOptions) -> Result<(), Box<dyn std::error::Error>> {
    let pre_visibility_filter = scan_pre_visibility_filter(&options);
    let priority_filter = scan_priority_filter(&options);
    let fail_on_priority = options.fail_on_priority.map(Into::into);

    if options.fail_on.is_some() && fail_on_priority.is_some() {
        return Err(Box::new(CliExit {
            code: EXIT_USAGE,
            message: "`--fail-on` and `--fail-on-priority` cannot be used together".to_string(),
        }));
    }
    if options.changed && options.since.is_some() {
        return Err(Box::new(CliExit {
            code: EXIT_USAGE,
            message: "`--changed` and `--since` cannot be used together".to_string(),
        }));
    }
    if options.workspace && (options.changed || options.since.is_some()) {
        return Err(Box::new(CliExit {
            code: EXIT_USAGE,
            message: "`--workspace` cannot be used with changed scans".to_string(),
        }));
    }
    let mut repo_config = match &options.config {
        Some(config_path) => load_optional_config(config_path)?,
        None => load_default_config()?,
    };

    if let Some(preset_str) = options.preset.as_deref() {
        match preset_str.parse::<Preset>() {
            Ok(p) => apply_preset(&mut repo_config, p),
            Err(_) => {
                return Err(Box::new(CliExit {
                    code: EXIT_USAGE,
                    message: format!(
                        "Invalid preset '{preset_str}'. Expected: strict, balanced, lenient"
                    ),
                }));
            }
        }
    }

    let scan_config = build_scan_config(
        &repo_config,
        ScanConfigOverrides {
            max_file_loc: options.max_file_loc,
            max_directory_modules: options.max_directory_modules,
            max_directory_depth: options.max_directory_depth,
            exclude_patterns: options.exclude.clone(),
            include_low_signal: options.include_low_signal,
            max_file_size: options.max_file_size,
            max_files: options.max_files,
        },
    );
    let output_format = options
        .format
        .map(Into::into)
        .unwrap_or(repo_config.output.default_format);

    let pb = make_spinner("Scanning...");
    let scan_start = Instant::now();

    let mut summary = if options.changed || options.since.is_some() {
        scan_changed_with_config(&options.path, &scan_config, options.since.as_deref())?
    } else if options.workspace {
        scan_workspace_with_config(&options.path, &scan_config)?
    } else {
        scan_path_with_config(&options.path, &scan_config)?
    };

    let scan_elapsed = scan_start.elapsed();
    finish_spinner(pb);

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

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

    let visibility_profile = scan_visibility_profile(&options);
    apply_visibility_profile(&mut summary, visibility_profile);

    if options.baseline.is_some() || options.fail_on.is_some() || fail_on_priority.is_some() {
        let mut baseline_report = match options.baseline.clone() {
            Some(baseline_path) => {
                let baseline_file = read_baseline(&baseline_path)?;
                diff_summary_against_baseline(summary, &baseline_file, baseline_path)
            }
            None => all_findings_new(summary),
        };
        if !priority_filter.is_empty() {
            baseline_report.apply_filter(&priority_filter);
        }

        let ci_gate = options
            .fail_on
            .map(Into::into)
            .map(|fail_on| evaluate_ci_gate(&baseline_report, fail_on))
            .or_else(|| {
                fail_on_priority
                    .map(|priority| evaluate_ci_gate(&baseline_report, FailOn::Priority(priority)))
            });
        let render_start = Instant::now();
        let rendered_report =
            render_baseline_scan_report(&baseline_report, output_format, ci_gate.as_ref())?;
        let render_elapsed = render_start.elapsed();

        write_scan_receipt_if_requested(&baseline_report.summary, options.receipt.as_deref())?;
        write_report(&rendered_report, options.output.as_deref())?;

        if options.verbose {
            let internal_us = baseline_report.summary.scan_duration_us;
            let total_ms = scan_elapsed.as_millis();
            let render_ms = render_elapsed.as_millis();
            eprintln!(
                "\n[verbose] Scan: {total_ms}ms (engine: {}ms) · Render: {render_ms}ms",
                internal_us / 1000
            );
        }

        if options.timing {
            print_timing_breakdown(&baseline_report.summary);
        }

        enforce_diagnostics_exit_policy(&baseline_report.summary)?;

        if let Some(ci_gate) = ci_gate
            && let Some(message) = ci_gate.failure_message()
        {
            return Err(Box::new(CliExit {
                code: EXIT_FINDINGS,
                message,
            }));
        }

        return Ok(());
    }

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

    let render_start = Instant::now();
    let rendered_report = render_scan_summary(&summary, output_format)?;
    let render_elapsed = render_start.elapsed();

    write_scan_receipt_if_requested(&summary, options.receipt.as_deref())?;
    write_report(&rendered_report, options.output.as_deref())?;

    if options.verbose {
        let internal_us = summary.scan_duration_us;
        let total_ms = scan_elapsed.as_millis();
        let render_ms = render_elapsed.as_millis();
        eprintln!(
            "\n[verbose] Scan: {total_ms}ms (engine: {:.0}ms) · Render: {render_ms}ms",
            internal_us as f64 / 1000.0
        );
    }

    if options.timing {
        print_timing_breakdown(&summary);
    }

    enforce_diagnostics_exit_policy(&summary)?;

    Ok(())
}

fn scan_visibility_profile(options: &ScanOptions) -> FindingVisibilityProfile {
    if options.include_maintainability || !options.rule.is_empty() {
        return FindingVisibilityProfile::Strict;
    }

    match options.profile {
        Some(crate::cli::ScanProfileArg::Strict) => FindingVisibilityProfile::Strict,
        Some(crate::cli::ScanProfileArg::Default) | None => FindingVisibilityProfile::Default,
    }
}

fn print_timing_breakdown(summary: &ScanSummary) {
    if let Some(timings) = &summary.scan_timings {
        eprintln!(
            "\n[timing] File scan: {}ms · Framework detection: {}ms · Post-scan audits: {}ms · Engine total: {}ms",
            timings.file_scan_us / 1000,
            timings.framework_detection_us / 1000,
            timings.post_scan_audits_us / 1000,
            timings.accounted_engine_us() / 1000,
        );
        eprintln!(
            "[timing] Pipeline: discovery {}ms · file analysis {}ms · enrichment {}ms · risk scoring {}ms · report finalization {}ms",
            timings.discovery_us / 1000,
            timings.file_analysis_us / 1000,
            timings.enrichment_us / 1000,
            timings.risk_scoring_us / 1000,
            timings.report_finalization_us / 1000,
        );
    }

    if let Some(cache) = &summary.cache_telemetry {
        let estimated_saved = cache
            .timings
            .estimated_time_saved_us
            .map(|value| format!("{}ms", value / 1000))
            .unwrap_or_else(|| "n/a".to_string());
        eprintln!(
            "[timing] Cache: load {}ms · hash {}ms · lookup {}ms · hit reuse {}ms · miss scan {}ms · write {}ms · estimated saved {}",
            cache.timings.load_us / 1000,
            cache.timings.file_hash_us / 1000,
            cache.timings.lookup_us / 1000,
            cache.timings.hit_reuse_us / 1000,
            cache.timings.miss_scan_us / 1000,
            cache.timings.write_us / 1000,
            estimated_saved,
        );
    }
}

fn write_scan_receipt_if_requested(
    summary: &ScanSummary,
    receipt_path: Option<&Path>,
) -> Result<(), Box<dyn std::error::Error>> {
    let Some(receipt_path) = receipt_path else {
        return Ok(());
    };

    let receipt = build_audit_receipt(summary);
    let rendered = render_receipt_json(&receipt)?;

    write_report(&rendered, Some(receipt_path))?;

    Ok(())
}

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
        ),
    }))
}