gruff-rs 0.1.1

Rust static analyzer and quality linter for CI: dead-code, complexity, security, secrets, and architecture rules with deterministic SARIF/JSON output and baseline support.
use super::*;

pub(crate) struct DiscoveryResult {
    pub(crate) files: Vec<SourceFile>,
    pub(crate) missing_paths: Vec<String>,
    pub(crate) ignored_paths: Vec<String>,
}

pub(crate) struct DiscoverySession<'a> {
    pub(crate) project_root: &'a Path,
    pub(crate) options: &'a AnalysisOptions,
    pub(crate) config: &'a Config,
    pub(crate) ignored_paths: &'a Arc<Mutex<BTreeSet<String>>>,
}

pub(crate) struct DiscoveryFilters<'a> {
    pub(crate) project_root: &'a Path,
    pub(crate) config: &'a Config,
    pub(crate) ignored_paths: &'a Mutex<BTreeSet<String>>,
    pub(crate) apply_project_ignore: bool,
    pub(crate) include_ignored: bool,
}

pub(crate) fn discover_sources(
    project_root: &Path,
    options: &AnalysisOptions,
    config: &Config,
) -> DiscoveryResult {
    let mut files = Vec::new();
    let mut missing_paths = Vec::new();
    let ignored_paths = Arc::new(Mutex::new(BTreeSet::new()));
    let session = DiscoverySession {
        project_root,
        options,
        config,
        ignored_paths: &ignored_paths,
    };
    for input in resolve_input_paths(options) {
        collect_input_path_sources(&input, &session, &mut files, &mut missing_paths);
    }
    sort_and_dedupe_source_files(&mut files);
    DiscoveryResult {
        files,
        missing_paths,
        ignored_paths: collected_ignored_paths(&ignored_paths),
    }
}

fn resolve_input_paths(options: &AnalysisOptions) -> Vec<PathBuf> {
    if options.paths.is_empty() {
        vec![PathBuf::from(".")]
    } else {
        options.paths.to_vec()
    }
}

fn collect_input_path_sources(
    input: &Path,
    session: &DiscoverySession<'_>,
    files: &mut Vec<SourceFile>,
    missing_paths: &mut Vec<String>,
) {
    let absolute = absolutize(session.project_root, input);
    if !absolute.exists() {
        missing_paths.push(input.display().to_string());
        return;
    }
    if absolute.is_file() {
        push_source_file(session.project_root, &absolute, files);
        return;
    }
    collect_directory_sources(&absolute, session, files);
}

fn sort_and_dedupe_source_files(files: &mut Vec<SourceFile>) {
    files.sort_by(|left, right| left.display_path.cmp(&right.display_path));
    files.dedup_by(|left, right| left.absolute_path == right.absolute_path);
}

fn collected_ignored_paths(ignored_paths: &Arc<Mutex<BTreeSet<String>>>) -> Vec<String> {
    ignored_paths
        .lock()
        .expect("ignored paths lock")
        .iter()
        .cloned()
        .collect()
}

pub(crate) fn collect_directory_sources(
    absolute: &Path,
    session: &DiscoverySession<'_>,
    files: &mut Vec<SourceFile>,
) {
    let apply_project_ignore =
        !path_is_project_ignored(session.project_root, absolute, session.config);
    let include_ignored = session.options.include_ignored;
    let builder = directory_walk_builder(absolute, session, apply_project_ignore, include_ignored);

    let outer_filters = DiscoveryFilters {
        project_root: session.project_root,
        config: session.config,
        ignored_paths: session.ignored_paths,
        apply_project_ignore,
        include_ignored,
    };
    for entry in builder.build().filter_map(Result::ok).filter(|entry| {
        entry
            .file_type()
            .is_some_and(|file_type| file_type.is_file())
    }) {
        if should_include_file(&entry, &outer_filters) {
            push_source_file(session.project_root, entry.path(), files);
        }
    }
}

fn directory_walk_builder(
    absolute: &Path,
    session: &DiscoverySession<'_>,
    apply_project_ignore: bool,
    include_ignored: bool,
) -> WalkBuilder {
    let filter_root = session.project_root.to_path_buf();
    let filter_config = session.config.clone();
    let filter_ignored_paths = Arc::clone(session.ignored_paths);
    let mut builder = WalkBuilder::new(absolute);
    builder
        .hidden(false)
        .parents(false)
        .ignore(!include_ignored)
        .git_ignore(!include_ignored)
        .git_global(false)
        .git_exclude(!include_ignored)
        .filter_entry(move |entry| {
            let filters = DiscoveryFilters {
                project_root: &filter_root,
                config: &filter_config,
                ignored_paths: &filter_ignored_paths,
                apply_project_ignore,
                include_ignored,
            };
            should_descend(entry, &filters)
        });
    builder
}

pub(crate) fn should_descend(entry: &DirEntry, filters: &DiscoveryFilters<'_>) -> bool {
    if entry.depth() == 0
        || !entry
            .file_type()
            .is_some_and(|file_type| file_type.is_dir())
    {
        return true;
    }

    let relative = display_path(filters.project_root, entry.path());
    if is_vcs_internal_dir(&relative) {
        record_ignored_path(filters.ignored_paths, relative);
        return false;
    }

    if !filters.include_ignored && is_default_ignored_dir(&relative) {
        record_ignored_path(filters.ignored_paths, relative);
        return false;
    }

    if !filters.include_ignored
        && filters.apply_project_ignore
        && path_is_project_ignored(filters.project_root, entry.path(), filters.config)
    {
        record_ignored_path(filters.ignored_paths, relative);
        return false;
    }

    true
}

pub(crate) fn should_include_file(entry: &DirEntry, filters: &DiscoveryFilters<'_>) -> bool {
    if filters.include_ignored || !filters.apply_project_ignore {
        return true;
    }
    if path_is_project_ignored(filters.project_root, entry.path(), filters.config) {
        record_ignored_path(
            filters.ignored_paths,
            display_path(filters.project_root, entry.path()),
        );
        return false;
    }
    true
}

pub(crate) fn record_ignored_path(ignored_paths: &Mutex<BTreeSet<String>>, path: String) {
    ignored_paths
        .lock()
        .expect("ignored paths lock")
        .insert(path);
}

pub(crate) fn path_is_project_ignored(project_root: &Path, path: &Path, config: &Config) -> bool {
    let relative = display_path(project_root, path);
    config
        .ignored_path_matchers
        .iter()
        .any(|matcher| matcher.matches(&relative))
}

pub(crate) fn is_default_ignored_dir(relative: &str) -> bool {
    let first = relative.split('/').next().unwrap_or(relative);
    matches!(
        first,
        ".git"
            | ".hg"
            | ".svn"
            | ".idea"
            | ".vscode"
            | "build"
            | "cache"
            | "coverage"
            | "dist"
            | "generated"
            | "node_modules"
            | "target"
            | "tmp"
            | "vendor"
    )
}

pub(crate) fn is_vcs_internal_dir(relative: &str) -> bool {
    relative
        .split('/')
        .any(|component| matches!(component, ".git" | ".hg" | ".svn"))
}

pub(crate) fn push_source_file(project_root: &Path, path: &Path, files: &mut Vec<SourceFile>) {
    let extension = path
        .extension()
        .and_then(|value| value.to_str())
        .unwrap_or_default();
    let file_name = path
        .file_name()
        .and_then(|value| value.to_str())
        .unwrap_or_default();
    let extension = extension.to_ascii_lowercase();
    let is_rust = extension == "rs";
    let is_text = is_supported_text_file(&extension, file_name);

    if is_rust || is_text {
        files.push(SourceFile {
            absolute_path: path.to_path_buf(),
            display_path: display_path(project_root, path),
            is_rust,
        });
    }
}

fn is_supported_text_file(extension: &str, file_name: &str) -> bool {
    matches!(
        extension,
        "bash"
            | "conf"
            | "config"
            | "crt"
            | "env"
            | "gradle"
            | "ini"
            | "json"
            | "key"
            | "lock"
            | "md"
            | "markdown"
            | "pem"
            | "properties"
            | "sh"
            | "tf"
            | "tfvars"
            | "toml"
            | "txt"
            | "xml"
            | "yaml"
            | "yml"
    ) || is_security_relevant_text_name(file_name)
}

fn is_security_relevant_text_name(file_name: &str) -> bool {
    let lower = file_name.to_ascii_lowercase();
    lower.starts_with(".env")
        || matches!(
            lower.as_str(),
            ".dockerignore"
                | ".netrc"
                | ".npmrc"
                | ".pypirc"
                | ".yarnrc"
                | "config"
                | "containerfile"
                | "dockerfile"
                | "gnumakefile"
                | "justfile"
                | "makefile"
                | "procfile"
        )
}