repopilot 0.9.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use crate::scan::config::ScanConfig;
use ignore::WalkBuilder;
use std::io;
use std::path::{Path, PathBuf};

const REPOPILOT_IGNORE_FILENAME: &str = ".repopilotignore";

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct CollectedPaths {
    pub(super) file_paths: Vec<PathBuf>,
    pub(super) directories_count: usize,
    pub(super) files_skipped_repopilotignore: usize,
    pub(super) repopilotignore_path: Option<PathBuf>,
}

pub(super) fn collect_paths(path: &Path, config: &ScanConfig) -> io::Result<CollectedPaths> {
    let repopilotignore_path = find_repopilotignore(path);

    let with_repopilotignore = collect_paths_with_custom_ignore(path, config, true)?;

    let files_skipped_repopilotignore = if repopilotignore_path.is_some() {
        let without_repopilotignore_count = count_files_with_custom_ignore(path, config, false)?;

        without_repopilotignore_count.saturating_sub(with_repopilotignore.file_paths.len())
    } else {
        0
    };

    Ok(CollectedPaths {
        file_paths: with_repopilotignore.file_paths,
        directories_count: with_repopilotignore.directories_count,
        files_skipped_repopilotignore,
        repopilotignore_path,
    })
}

fn collect_paths_with_custom_ignore(
    path: &Path,
    config: &ScanConfig,
    use_repopilotignore: bool,
) -> io::Result<CollectedPaths> {
    let mut file_paths = Vec::new();
    let mut directories_count = 0usize;

    for result in build_walker_with_custom_ignore(path, config, use_repopilotignore) {
        let entry = result.map_err(io::Error::other)?;
        let entry_path = entry.path();

        if entry_path == path {
            continue;
        }

        let Some(file_type) = entry.file_type() else {
            continue;
        };

        if file_type.is_dir() {
            directories_count += 1;
        } else if file_type.is_file() && !is_repopilot_control_file(entry_path) {
            file_paths.push(entry_path.to_path_buf());
        }
    }

    Ok(CollectedPaths {
        file_paths,
        directories_count,
        files_skipped_repopilotignore: 0,
        repopilotignore_path: find_repopilotignore(path),
    })
}

fn count_files_with_custom_ignore(
    path: &Path,
    config: &ScanConfig,
    use_repopilotignore: bool,
) -> io::Result<usize> {
    let mut files_count = 0usize;

    for result in build_walker_with_custom_ignore(path, config, use_repopilotignore) {
        let entry = result.map_err(io::Error::other)?;
        let entry_path = entry.path();

        if entry_path == path {
            continue;
        }

        let Some(file_type) = entry.file_type() else {
            continue;
        };

        if file_type.is_file() && !is_repopilot_control_file(entry_path) {
            files_count += 1;
        }
    }

    Ok(files_count)
}

fn build_walker_with_custom_ignore(
    path: &Path,
    config: &ScanConfig,
    use_repopilotignore: bool,
) -> ignore::Walk {
    let root = path.to_path_buf();

    let mut ignored_paths = config.ignored_paths.clone();
    ignored_paths.extend(
        config
            .exclude_patterns
            .iter()
            .map(|pattern| pattern.trim_end_matches("/**").to_string()),
    );

    let mut builder = WalkBuilder::new(path);

    builder
        .hidden(false)
        .git_ignore(true)
        .git_global(true)
        .git_exclude(true);

    if use_repopilotignore {
        builder.add_custom_ignore_filename(REPOPILOT_IGNORE_FILENAME);
    }

    builder
        .filter_entry(move |entry| !is_ignored_path(entry.path(), &root, &ignored_paths))
        .build()
}

fn find_repopilotignore(path: &Path) -> Option<PathBuf> {
    let root = if path.is_file() {
        path.parent().unwrap_or(path)
    } else {
        path
    };

    let candidate = root.join(REPOPILOT_IGNORE_FILENAME);

    if candidate.is_file() {
        Some(candidate)
    } else {
        None
    }
}

fn is_ignored_path(path: &Path, root: &Path, ignored_paths: &[String]) -> bool {
    if path == root {
        return false;
    }

    ignored_paths.iter().any(|ignored_path| {
        let ignored_path = ignored_path.trim_matches('/');

        if ignored_path.is_empty() {
            return false;
        }

        path.strip_prefix(root)
            .ok()
            .and_then(|relative_path| relative_path.to_str())
            .map(|relative_path| relative_path == ignored_path)
            .unwrap_or(false)
            || path
                .file_name()
                .and_then(|name| name.to_str())
                .map(|name| name == ignored_path)
                .unwrap_or(false)
    })
}

fn is_repopilot_control_file(path: &Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .map(|name| matches!(name, REPOPILOT_IGNORE_FILENAME | "repopilot.toml"))
        .unwrap_or(false)
}