guardy 0.2.4

Fast, secure git hooks in Rust with secret scanning and protected file synchronization
Documentation
use std::collections::HashSet;

use anyhow::Result;

use crate::{cli::banner, cli::output::*, config::CONFIG};

/// Validate the hooks configuration for correctness and best practices
pub async fn validate_hooks_config() -> Result<()> {
    // Print banner without context
    banner::print_banner(None);
    styled!("Validating {} configuration...", ("hooks", "primary"));

    let hooks_config = &CONFIG.hooks;
    let mut errors = Vec::new();
    let mut warnings = Vec::new();

    // Validate pre-commit hook
    validate_hook_definition(
        "pre-commit",
        &hooks_config.pre_commit,
        &mut errors,
        &mut warnings,
    )
    .await;

    // Validate commit-msg hook
    validate_hook_definition(
        "commit-msg",
        &hooks_config.commit_msg,
        &mut errors,
        &mut warnings,
    )
    .await;

    // Validate post-checkout hook if it has configuration
    validate_hook_definition(
        "post-checkout",
        &hooks_config.post_checkout,
        &mut errors,
        &mut warnings,
    )
    .await;

    // Validate post-commit hook if it has configuration
    validate_hook_definition(
        "post-commit",
        &hooks_config.post_commit,
        &mut errors,
        &mut warnings,
    )
    .await;

    // Validate post-merge hook if it has configuration
    validate_hook_definition(
        "post-merge",
        &hooks_config.post_merge,
        &mut errors,
        &mut warnings,
    )
    .await;

    // Check for global configuration issues
    validate_global_config(hooks_config, &mut warnings).await;

    // Display results
    if errors.is_empty() && warnings.is_empty() {
        styled!("{} Configuration is valid!", ("", "success_symbol"));
        return Ok(());
    }

    if !warnings.is_empty() {
        styled!("{} Warnings:", ("⚠️", "warning_symbol"));
        for warning in &warnings {
            styled!("  • {}", (warning, "warning"));
        }
    }

    if !errors.is_empty() {
        styled!("{} Errors:", ("", "error_symbol"));
        for error in &errors {
            styled!("  • {}", (error, "error"));
        }

        styled!(
            "{} Configuration validation failed!",
            ("", "error_symbol")
        );
        return Err(anyhow::anyhow!(
            "Hook configuration has {} errors",
            errors.len()
        ));
    }

    if !warnings.is_empty() {
        styled!(
            "{} Configuration is valid with warnings",
            ("⚠️", "warning_symbol")
        );
    }

    Ok(())
}

async fn validate_hook_definition(
    hook_name: &str,
    hook_def: &crate::config::hooks::HookDefinition,
    errors: &mut Vec<String>,
    warnings: &mut Vec<String>,
) {
    // Skip validation if hook has no configuration
    if hook_def.commands.is_empty() && hook_def.scripts.is_empty() {
        return;
    }

    styled!("Validating {} hook...", (hook_name, "property"));

    // Validate commands
    let mut command_names = HashSet::new();
    for (name, command) in &hook_def.commands {
        // Check for duplicate command names
        if !command_names.insert(name) {
            errors.push(format!("{hook_name}: Duplicate command name '{name}'"));
        }

        // Validate command structure
        if command.run.is_empty() {
            errors.push(format!(
                "{hook_name}: Command '{name}' has empty 'run' field"
            ));
        }

        // Check for common issues
        if command.run.contains("guardy") && command.run.contains("hooks run") {
            warnings.push(format!(
                "{hook_name}: Command '{name}' appears to call guardy hooks recursively"
            ));
        }

        // Validate glob patterns
        for glob_pattern in &*command.glob {
            if glob_pattern.is_empty() {
                warnings.push(format!(
                    "{hook_name}: Command '{name}' has empty glob pattern"
                ));
            }

            // Check if glob pattern is valid
            if globset::Glob::new(glob_pattern).is_err() {
                errors.push(format!(
                    "{hook_name}: Command '{name}' has invalid glob pattern '{glob_pattern}'"
                ));
            }
        }

        // Validate file types
        for file_type in &command.file_types {
            if file_type.is_empty() {
                warnings.push(format!("{hook_name}: Command '{name}' has empty file type"));
            }
        }

        // Validate environment variables
        for (env_key, env_value) in &command.env {
            if env_key.is_empty() {
                warnings.push(format!(
                    "{hook_name}: Command '{name}' has empty environment variable name"
                ));
            }
            if env_value.is_empty() {
                warnings.push(format!(
                    "{hook_name}: Command '{name}' has empty environment variable value for \
                     '{env_key}'"
                ));
            }
        }

        // Check for reasonable command descriptions
        if command.description.is_empty() {
            warnings.push(format!("{hook_name}: Command '{name}' has no description"));
        }
    }

    // Validate scripts
    let mut script_names = HashSet::new();
    for (name, script) in &hook_def.scripts {
        // Check for duplicate script names
        if !script_names.insert(name) {
            errors.push(format!("{hook_name}: Duplicate script name '{name}'"));
        }

        // Check for conflicts between command and script names
        if command_names.contains(name) {
            errors.push(format!(
                "{hook_name}: Name '{name}' is used for both command and script"
            ));
        }

        // Validate script structure
        if script.runner.is_empty() {
            errors.push(format!(
                "{hook_name}: Script '{name}' has empty 'runner' field"
            ));
        }

        // Validate common runners
        let common_runners = ["bash", "sh", "node", "python", "python3", "ruby", "perl"];
        if !common_runners.contains(&script.runner.as_str()) && !script.runner.starts_with('/') {
            warnings.push(format!(
                "{hook_name}: Script '{name}' uses uncommon runner '{}'",
                script.runner
            ));
        }
    }

    // Check for hook-specific recommendations
    match hook_name {
        "pre-commit" => {
            if hook_def.commands.is_empty() && hook_def.scripts.is_empty() {
                warnings
                    .push("pre-commit: Hook is enabled but has no commands or scripts".to_string());
            }
        }
        "commit-msg" => {
            let has_msg_validation = hook_def.commands.values().any(|cmd| {
                cmd.run.contains("conventional")
                    || cmd.run.contains("commitizen")
                    || cmd.run.contains("validate")
            });

            if !has_msg_validation {
                warnings.push("commit-msg: Consider adding commit message validation".to_string());
            }
        }
        _ => {}
    }
}

async fn validate_global_config(
    hooks_config: &crate::config::hooks::HooksConfig,
    warnings: &mut Vec<String>,
) {
    // Check for reasonable global settings
    if hooks_config.skip_all {
        warnings
            .push("Global setting 'skip_all' is enabled - all hooks will be skipped".to_string());
    }

    // Check if any hooks are actually configured
    let has_any_hooks = !hooks_config.pre_commit.commands.is_empty()
        || !hooks_config.pre_commit.scripts.is_empty()
        || !hooks_config.commit_msg.commands.is_empty()
        || !hooks_config.commit_msg.scripts.is_empty()
        || !hooks_config.post_checkout.commands.is_empty()
        || !hooks_config.post_checkout.scripts.is_empty()
        || !hooks_config.post_commit.commands.is_empty()
        || !hooks_config.post_commit.scripts.is_empty()
        || !hooks_config.post_merge.commands.is_empty()
        || !hooks_config.post_merge.scripts.is_empty();

    if !has_any_hooks {
        warnings
            .push("No hooks are configured - consider adding some commands or scripts".to_string());
    }
}