gruff-rs 0.4.0

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::*;

mod architecture;
mod dead_code;
mod dependencies;

pub(crate) use architecture::analyse_architecture_rules;
pub(crate) use dead_code::analyse_project_dead_code_rules;
pub(crate) use dependencies::analyse_dependency_rules;

/// Runs the project-level rules (cross-file dead code, architecture, dependency,
/// missing-README) over an already-built `ProjectContext`.
///
/// Usage: called once per analysis after `ProjectContext` is assembled from the
/// discovered, parsed sources.
///
/// Contract: returns the project-scope findings and appends any run diagnostics
/// (such as partial-context suppression) to `diagnostics`; each rule honours its
/// `config` enablement.
///
/// Failure behaviour: never returns an error - a rule that cannot produce an
/// authoritative result suppresses itself and records a diagnostic instead.
pub(crate) fn analyse_project(
    context: &ProjectContext,
    config: &Config,
    diagnostics: &mut Vec<RunDiagnostic>,
) -> Vec<Finding> {
    let mut findings = Vec::new();

    if !project_has_readme(&context.root_path) && config.is_rule_enabled("docs.missing-readme") {
        findings.push(Finding::new(FindingDescriptor {
            rule_id: "docs.missing-readme".to_string(),
            message: "Project root does not contain a README.md file.".to_string(),
            file_path: "README.md".to_string(),
            line: Some(1),
            severity: Severity::Advisory,
            pillar: Pillar::Documentation,
            confidence: Confidence::High,
            symbol: None,
            remediation: Some(
                "Add a README.md that explains the project purpose and local commands.".to_string(),
            ),
            metadata: json!({}),
        }));
    }

    analyse_dependency_rules(context, config, &mut findings);
    analyse_architecture_rules(context, config, &mut findings);
    analyse_project_dead_code_rules(context, config, diagnostics, &mut findings);

    findings
        .into_iter()
        .map(|finding| apply_configured_severity(finding, config))
        .collect()
}

fn apply_configured_severity(mut finding: Finding, config: &Config) -> Finding {
    finding.severity = config.severity(&finding.rule_id, finding.severity);
    finding
}

pub(crate) fn project_has_readme(root_path: &Path) -> bool {
    fs::read_dir(root_path)
        .ok()
        .into_iter()
        .flat_map(|entries| entries.filter_map(Result::ok))
        .any(|entry| {
            entry
                .file_name()
                .to_str()
                .is_some_and(|name| name.eq_ignore_ascii_case("README.md"))
        })
}

pub(crate) fn module_label(file_path: &str, module_path: &str) -> String {
    if module_path.is_empty() {
        file_path.to_string()
    } else {
        module_path.to_string()
    }
}

pub(crate) fn item_symbol(item: &ItemSummary) -> String {
    if item.kind == "method" {
        let qualified_method = match &item.container {
            Some(container) => format!("{container}::{}", item.name),
            None => item.name.to_string(),
        };
        return if item.module_path.is_empty() {
            qualified_method
        } else {
            format!("{}::{qualified_method}", item.module_path)
        };
    }
    if item.module_path.is_empty() {
        item.name.to_string()
    } else {
        format!("{}::{}", item.module_path, item.name)
    }
}

impl ProjectContext {
    pub(crate) fn identifier_count(&self, name: &str) -> usize {
        self.identifier_counts
            .get(name)
            .copied()
            .unwrap_or_default()
    }
}

pub(crate) fn is_missing_text(value: Option<&str>) -> bool {
    value.is_none_or(|value| value.trim().is_empty())
}

pub(crate) fn is_wildcard_requirement(requirement: &str) -> bool {
    requirement
        .split(',')
        .any(|part| part.trim() == "*" || part.trim().ends_with(".*"))
}