repopilot 0.7.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use crate::cli::{FailOnArg, OutputFormatArg};
use crate::commands::{CliExit, build_scan_config};
use indicatif::{ProgressBar, ProgressStyle};
use repopilot::baseline::diff::{all_findings_new, diff_summary_against_baseline};
use repopilot::baseline::gate::evaluate_ci_gate;
use repopilot::baseline::reader::read_baseline;
use repopilot::config::loader::{load_default_config, load_optional_config};
use repopilot::findings::types::Severity;
use repopilot::output::{render_baseline_scan_report, render_scan_summary};
use repopilot::report::writer::write_report;
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::BTreeMap;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::time::Duration;

#[allow(clippy::too_many_arguments)]
pub fn run(
    path: PathBuf,
    format: Option<OutputFormatArg>,
    output: Option<PathBuf>,
    config: Option<PathBuf>,
    baseline: Option<PathBuf>,
    fail_on: Option<FailOnArg>,
    max_file_loc: Option<usize>,
    max_directory_modules: Option<usize>,
    max_directory_depth: Option<usize>,
    workspace: bool,
    min_severity: Option<Severity>,
) -> Result<(), Box<dyn std::error::Error>> {
    let repo_config = match config {
        Some(config_path) => load_optional_config(&config_path)?,
        None => load_default_config()?,
    };
    let scan_config = build_scan_config(
        &repo_config,
        max_file_loc,
        max_directory_modules,
        max_directory_depth,
    );
    let output_format = format
        .map(Into::into)
        .unwrap_or(repo_config.output.default_format);

    let pb = make_spinner();

    let mut summary = if workspace {
        scan_workspace(&path, &scan_config)?
    } else {
        scan_path_with_config(&path, &scan_config)?
    };

    finish_spinner(pb);

    if let Some(min) = min_severity {
        summary.findings.retain(|f| f.severity >= min);
    }

    if baseline.is_some() || fail_on.is_some() {
        let baseline_report = match baseline {
            Some(baseline_path) => {
                let baseline_file = read_baseline(&baseline_path)?;
                diff_summary_against_baseline(summary, &baseline_file, baseline_path)
            }
            None => all_findings_new(summary),
        };

        let ci_gate = fail_on
            .map(Into::into)
            .map(|fail_on| evaluate_ci_gate(&baseline_report, fail_on));
        let rendered_report =
            render_baseline_scan_report(&baseline_report, output_format, ci_gate.as_ref())?;

        write_report(&rendered_report, output.as_deref())?;

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

        return Ok(());
    }

    let rendered_report = render_scan_summary(&summary, output_format)?;
    write_report(&rendered_report, output.as_deref())?;

    Ok(())
}

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 root_scan_config = workspace_root_config(scan_config, path, &packages);
    let mut merged = scan_path_with_config(path, &root_scan_config)?;

    for pkg in &packages {
        match scan_path_with_config(&pkg.root, scan_config) {
            Ok(pkg_summary) => merge_package_summary(&mut merged, pkg_summary, &pkg.name),
            Err(err) => eprintln!(
                "Warning: failed to scan workspace package '{}': {err}",
                pkg.name
            ),
        }
    }

    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.directories_count += package.directories_count;
    merged.lines_of_code += package.lines_of_code;
    merged.skipped_files_count += package.skipped_files_count;
    merged.skipped_bytes = merged.skipped_bytes.saturating_add(package.skipped_bytes);
    merged.scan_duration_us = merged
        .scan_duration_us
        .saturating_add(package.scan_duration_us);
    merge_language_summaries(&mut merged.languages, package.languages);
    merged.findings.extend(package.findings);
}

fn merge_language_summaries(target: &mut Vec<LanguageSummary>, source: Vec<LanguageSummary>) {
    let mut counts: BTreeMap<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 make_spinner() -> Option<ProgressBar> {
    if !std::io::stderr().is_terminal() {
        return None;
    }
    let pb = ProgressBar::new_spinner();
    pb.set_style(
        ProgressStyle::with_template("{spinner:.cyan} {msg}")
            .unwrap()
            .tick_strings(&["", "", "", "", "", "", "", "", "", ""]),
    );
    pb.set_message("Scanning...");
    pb.enable_steady_tick(Duration::from_millis(80));
    Some(pb)
}

fn finish_spinner(pb: Option<ProgressBar>) {
    if let Some(pb) = pb {
        pb.finish_and_clear();
    }
}