repopilot 0.10.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
pub mod baseline;
pub mod ci;
pub mod config;
pub mod git;
pub mod helpers;
pub mod next_steps;
pub mod scope;

use crate::baseline::reader::read_baseline;
use crate::doctor::model::{DoctorProject, DoctorReport, DoctorScanScope};
use crate::scan::config::ScanConfig;
use crate::scan::scanner::scan_path_with_config;
use std::io;
use std::path::Path;

use baseline::{check_baseline, check_baseline_readable};
use ci::{check_ci_config, check_repopilot_ci, detect_repopilot_ci_config, has_ci_config};
use config::{check_config, check_config_readable, check_repopilotignore, config_file_is_readable};
use git::check_git_repo;
use helpers::{detect_package_managers, find_upward, has_github_workflows};
use next_steps::{build_next_command, build_next_steps};
use scope::{
    check_report_receipt_readiness, check_scan_limit, check_scan_scope, report_receipt_paths_ready,
};

const CONFIG_FILE_NAME: &str = "repopilot.toml";
const BASELINE_FILE_PATH: &str = ".repopilot/baseline.json";

struct DoctorRecommendationState {
    has_config: bool,
    config_readable: bool,
    has_repopilotignore: bool,
    has_baseline: bool,
    baseline_readable: bool,
    has_ci_config: bool,
    has_repopilot_ci_gate: bool,
    report_receipt_ready: bool,
    files_analyzed: usize,
    files_skipped_by_limit: usize,
}

fn build_recommendations(state: DoctorRecommendationState) -> Vec<String> {
    let mut recommendations = Vec::new();

    if !state.has_config {
        recommendations
            .push("Run `repopilot init` to create an explicit audit configuration.".to_string());
    } else if !state.config_readable {
        recommendations.push(
            "Fix `repopilot.toml` so RepoPilot can parse committed audit settings.".to_string(),
        );
    }

    if !state.has_repopilotignore {
        recommendations.push(
            "Add `.repopilotignore` for generated files, fixtures, snapshots, and vendor folders."
                .to_string(),
        );
    }

    if !state.has_baseline {
        recommendations.push(
            "Create a baseline with `repopilot baseline create .` before enforcing CI gates."
                .to_string(),
        );
    } else if !state.baseline_readable {
        recommendations.push(
            "Fix or regenerate `.repopilot/baseline.json` before using new-finding gates."
                .to_string(),
        );
    }

    if !state.has_repopilot_ci_gate {
        recommendations.push(
            "Add a RepoPilot CI gate with `--fail-on new-high` after committing a baseline."
                .to_string(),
        );
    } else if !state.has_ci_config {
        recommendations
            .push("Keep the RepoPilot gate in a committed CI workflow file.".to_string());
    }

    if !state.report_receipt_ready {
        recommendations.push(
            "Clear the default report or receipt path conflict before generating adoption evidence."
                .to_string(),
        );
    }

    if state.files_analyzed == 0 {
        recommendations.push(
            "Relax ignore rules or run with `--include-low-signal` if the target only contains tests/examples."
                .to_string(),
        );
    }

    if state.files_skipped_by_limit > 0 {
        recommendations.push(
            "Increase `--max-files` or remove the limit to audit the full repository scope."
                .to_string(),
        );
    }

    if recommendations.is_empty() {
        recommendations.push(
            "Repository adoption setup looks ready for regular scan/review usage.".to_string(),
        );
    }

    recommendations
}

pub fn build_doctor_report(
    path: &Path,
    explicit_config_path: Option<&Path>,
    config: &ScanConfig,
) -> io::Result<DoctorReport> {
    let summary = scan_path_with_config(path, config)?;
    let root = summary.root_path.clone();

    let config_path = explicit_config_path
        .filter(|path| path.is_file())
        .map(Path::to_path_buf)
        .or_else(|| find_upward(&root, CONFIG_FILE_NAME));

    let git_dir = find_upward(&root, ".git");
    let baseline_path = root.join(BASELINE_FILE_PATH);
    let github_workflows_dir = root.join(".github").join("workflows");

    let has_repopilotignore = summary.repopilotignore_path.is_some();
    let has_baseline = baseline_path.is_file();
    let has_github_workflows = has_github_workflows(&github_workflows_dir);
    let has_ci_config = has_ci_config(&root, &github_workflows_dir);
    let repopilot_ci = detect_repopilot_ci_config(&root, &github_workflows_dir);
    let config_readable = config_path.as_deref().is_some_and(config_file_is_readable);
    let baseline_readable = has_baseline && read_baseline(&baseline_path).is_ok();
    let report_receipt_ready = report_receipt_paths_ready(&root).is_ok();
    let package_managers = detect_package_managers(&root);

    let project = DoctorProject {
        languages: summary
            .languages
            .iter()
            .map(|language| language.name.clone())
            .collect(),
        frameworks: summary
            .detected_frameworks
            .iter()
            .map(|framework| framework.label())
            .collect(),
        package_managers,
        react_native_detected: summary.react_native.is_some(),
    };

    let scan = DoctorScanScope {
        files_discovered: summary.files_discovered,
        files_analyzed: summary.files_count,
        files_skipped_low_signal: summary.files_skipped_low_signal,
        binary_files_skipped: summary.binary_files_skipped,
        large_files_skipped: summary.skipped_files_count,
        files_skipped_by_limit: summary.files_skipped_by_limit,
        files_skipped_repopilotignore: summary.files_skipped_repopilotignore,
    };

    let mut checks = vec![
        check_git_repo(git_dir.as_deref()),
        check_config(config_path.as_deref()),
    ];

    if let Some(path) = config_path.as_deref() {
        checks.push(check_config_readable(path));
    }

    checks.extend([
        check_repopilotignore(has_repopilotignore, summary.files_skipped_repopilotignore),
        check_baseline(has_baseline),
    ]);

    if has_baseline {
        checks.push(check_baseline_readable(&baseline_path));
    }

    checks.extend([
        check_ci_config(has_ci_config, has_github_workflows),
        check_repopilot_ci(&repopilot_ci, has_ci_config),
        check_report_receipt_readiness(&root),
        check_scan_scope(summary.files_count),
        check_scan_limit(summary.files_skipped_by_limit),
    ]);

    let recommendations = build_recommendations(DoctorRecommendationState {
        has_config: config_path.is_some(),
        config_readable,
        has_repopilotignore,
        has_baseline,
        baseline_readable,
        has_ci_config,
        has_repopilot_ci_gate: repopilot_ci.has_gate,
        report_receipt_ready,
        files_analyzed: summary.files_count,
        files_skipped_by_limit: summary.files_skipped_by_limit,
    });

    let next_steps = build_next_steps(
        path,
        config_path.is_some(),
        has_baseline,
        repopilot_ci.has_gate,
        summary.files_count,
    );
    let next_command = next_steps
        .first()
        .map(|step| step.command.clone())
        .unwrap_or_else(|| build_next_command(path, has_baseline));

    Ok(DoctorReport {
        root_path: root.display().to_string(),
        project,
        scan,
        checks,
        recommendations,
        next_steps,
        next_command,
    })
}