repopilot 0.11.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::progress::{finish_spinner, make_spinner};
use crate::commands::{
    CliExit, EXIT_FINDINGS, EXIT_USAGE, ScanConfigOverrides, apply_min_priority_filter,
    apply_min_severity_filter, build_scan_config, finding_meets_min_priority,
    scan_options_min_priority, scan_options_min_severity,
};
use rayon::prelude::*;
use repopilot::baseline::diff::{
    BaselineScanReport, 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::types::Finding;
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::risk::{
    RiskPriority, apply_cluster_overlay, apply_workspace_hotspot_overlay, sort_findings,
};
use repopilot::scan::config::ScanConfig;
use repopilot::scan::scanner::scan_path_with_config;
use repopilot::scan::types::{LanguageSummary, ScanSummary};
use repopilot::scan::workspace::{WorkspacePackage, detect_workspace_packages};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::time::Instant;

pub fn run(options: ScanOptions) -> Result<(), Box<dyn std::error::Error>> {
    let min_severity = scan_options_min_severity(&options);
    let min_priority = scan_options_min_priority(&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(),
        }));
    }
    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.workspace {
        scan_workspace(&options.path, &scan_config)?
    } else {
        scan_path_with_config(&options.path, &scan_config)?
    };

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

    if let Some(min) = min_severity {
        apply_min_severity_filter(&mut summary, min);
    }

    if !options.rule.is_empty() {
        apply_rule_filter(&mut summary, &options.rule);
    }

    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 let Some(min) = min_priority {
            apply_min_priority_filter_to_baseline_report(&mut baseline_report, min);
        }

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

        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 let Some(min) = min_priority {
        apply_min_priority_filter(&mut summary, min);
    }

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

    Ok(())
}

fn apply_min_priority_filter_to_baseline_report(
    report: &mut BaselineScanReport,
    min: RiskPriority,
) {
    let mut paired = report
        .summary
        .findings
        .drain(..)
        .zip(report.findings.drain(..))
        .collect::<Vec<_>>();

    paired.retain(|(finding, _)| finding_meets_min_priority(finding, min));

    for (finding, status) in paired {
        report.summary.findings.push(finding);
        report.findings.push(status);
    }

    report.summary.health_score =
        ScanSummary::compute_health_score(&report.summary.findings, report.summary.lines_of_code);
}

fn scan_workspace(path: &Path, scan_config: &ScanConfig) -> Result<ScanSummary, std::io::Error> {
    let packages = detect_workspace_packages(path);
    if packages.is_empty() {
        eprintln!(
            "Warning: --workspace specified but no workspace packages found under {}. \
             Falling back to single-package scan.",
            path.display()
        );
        return scan_path_with_config(path, scan_config);
    }

    let wall_start = Instant::now();

    let root_scan_config = workspace_root_config(scan_config, path, &packages);
    let mut merged = scan_path_with_config(path, &root_scan_config)?;

    let pkg_results: Vec<(String, Result<_, _>)> = packages
        .par_iter()
        .map(|pkg| {
            (
                pkg.name.clone(),
                scan_path_with_config(&pkg.root, scan_config),
            )
        })
        .collect();

    for (name, result) in pkg_results {
        match result {
            Ok(pkg_summary) => merge_package_summary(&mut merged, pkg_summary, &name),
            Err(err) => eprintln!("Warning: failed to scan workspace package '{name}': {err}"),
        }
    }

    deduplicate_workspace_findings(&mut merged.findings);
    apply_workspace_hotspot_overlay(&mut merged.findings);
    apply_cluster_overlay(&mut merged.findings);
    sort_findings(&mut merged.findings);
    merged.health_score = ScanSummary::compute_health_score(&merged.findings, merged.lines_of_code);
    merged.scan_duration_us = wall_start.elapsed().as_micros() as u64;
    Ok(merged)
}

fn workspace_root_config(
    scan_config: &ScanConfig,
    root: &Path,
    packages: &[WorkspacePackage],
) -> ScanConfig {
    let mut config = scan_config.clone();
    for package in packages {
        if let Some(relative_path) = workspace_relative_path(root, &package.root) {
            config.ignored_paths.push(relative_path);
        }
    }
    config
}

fn workspace_relative_path(root: &Path, package_root: &Path) -> Option<String> {
    package_root
        .strip_prefix(root)
        .ok()
        .and_then(|path| path.to_str())
        .filter(|path| !path.is_empty())
        .map(|path| path.replace('\\', "/"))
}

fn merge_package_summary(merged: &mut ScanSummary, mut package: ScanSummary, package_name: &str) {
    for finding in &mut package.findings {
        finding.workspace_package = Some(package_name.to_string());
    }

    merged.files_count += package.files_count;
    merged.files_discovered += package.files_discovered;
    merged.directories_count += package.directories_count;
    merged.lines_of_code += package.lines_of_code;
    merged.skipped_files_count += package.skipped_files_count;
    merged.files_skipped_low_signal += package.files_skipped_low_signal;
    merged.binary_files_skipped += package.binary_files_skipped;
    merged.files_skipped_by_limit += package.files_skipped_by_limit;
    merged.files_skipped_repopilotignore += package.files_skipped_repopilotignore;

    if merged.repopilotignore_path.is_none() {
        merged.repopilotignore_path = package.repopilotignore_path.clone();
    }
    merged.skipped_bytes = merged.skipped_bytes.saturating_add(package.skipped_bytes);
    merge_language_summaries(&mut merged.languages, package.languages);
    merged.findings.extend(package.findings);
}

fn apply_rule_filter(summary: &mut ScanSummary, rules: &[String]) {
    summary
        .findings
        .retain(|f| rules.iter().any(|r| r == &f.rule_id));
}

fn deduplicate_workspace_findings(findings: &mut Vec<Finding>) {
    let mut seen: HashSet<(String, std::path::PathBuf, usize)> = HashSet::new();
    findings.retain(|f| {
        let key = f
            .evidence
            .first()
            .map(|e| (f.rule_id.clone(), e.path.clone(), e.line_start))
            .unwrap_or_else(|| (f.rule_id.clone(), std::path::PathBuf::new(), 0));
        seen.insert(key)
    });
}

fn merge_language_summaries(target: &mut Vec<LanguageSummary>, source: Vec<LanguageSummary>) {
    let mut counts: HashMap<String, usize> = target
        .drain(..)
        .map(|language| (language.name, language.files_count))
        .collect();

    for language in source {
        *counts.entry(language.name).or_insert(0) += language.files_count;
    }

    let mut merged: Vec<_> = counts
        .into_iter()
        .map(|(name, files_count)| LanguageSummary { name, files_count })
        .collect();
    merged.sort_by(|left, right| {
        right
            .files_count
            .cmp(&left.files_count)
            .then_with(|| left.name.cmp(&right.name))
    });

    *target = merged;
}

fn print_timing_breakdown(summary: &ScanSummary) {
    if let Some(timings) = &summary.scan_timings {
        let total =
            timings.file_scan_us + timings.framework_detection_us + timings.post_scan_audits_us;
        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,
            total / 1000,
        );
    }
}

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