guardy 0.2.4

Fast, secure git hooks in Rust with secret scanning and protected file synchronization
Documentation
use std::{process::Command, sync::Arc};

use anyhow::{Context, Result};

use crate::{config::hooks::HookCondition, git::GitRepo};

/// Evaluates hook conditions for skip/only logic
pub struct ConditionEvaluator {
    repo: Option<Arc<GitRepo>>,
}

impl Default for ConditionEvaluator {
    fn default() -> Self {
        Self::new()
    }
}

impl ConditionEvaluator {
    /// Create evaluator with an existing git repo (avoids redundant discovery)
    pub fn with_repo(repo: Arc<GitRepo>) -> Self {
        Self { repo: Some(repo) }
    }

    /// Create new evaluator (discovers git repo)
    pub fn new() -> Self {
        Self {
            repo: GitRepo::discover().ok().map(Arc::new),
        }
    }

    /// Evaluate a condition - returns true if condition is met
    pub fn evaluate(&self, condition: &HookCondition) -> Result<bool> {
        match condition {
            HookCondition::Bool(value) => Ok(*value),
            HookCondition::Array(conditions) => {
                // Array conditions are OR'd together - any true = true
                for cond in conditions {
                    if self.evaluate_single_condition(cond)? {
                        return Ok(true);
                    }
                }
                Ok(false)
            }
        }
    }

    /// Evaluate a single string condition
    fn evaluate_single_condition(&self, condition: &str) -> Result<bool> {
        // Parse condition format: "type:value"
        let parts: Vec<&str> = condition.splitn(2, ':').collect();
        if parts.len() != 2 {
            // Simple boolean conditions
            return match condition {
                "true" | "always" => Ok(true),
                "false" | "never" => Ok(false),
                _ => Ok(false), // Unknown conditions default to false
            };
        }

        let (cond_type, cond_value) = (parts[0], parts[1]);

        match cond_type {
            "branch" => self.check_branch_condition(cond_value),
            "ref" => self.check_ref_condition(cond_value),
            "files" => self.check_files_condition(cond_value),
            "merge" => self.check_merge_condition(),
            "rebase" => self.check_rebase_condition(),
            "ci" => self.check_ci_condition(),
            _ => Ok(false), // Unknown condition types default to false
        }
    }

    /// Check if current branch matches pattern
    fn check_branch_condition(&self, pattern: &str) -> Result<bool> {
        let Some(repo) = &self.repo else {
            return Ok(false);
        };

        let current_branch = repo
            .get_current_branch()
            .context("Failed to get current branch")?;

        // Support glob patterns
        let glob = globset::Glob::new(pattern).context("Invalid branch pattern")?;
        let matcher = glob.compile_matcher();

        Ok(matcher.is_match(&current_branch))
    }

    /// Check if current ref matches pattern
    fn check_ref_condition(&self, pattern: &str) -> Result<bool> {
        let Some(_repo) = &self.repo else {
            return Ok(false);
        };

        // Get current ref (could be branch, tag, or commit)
        let output = Command::new("git")
            .args(["symbolic-ref", "-q", "HEAD"])
            .output();

        let current_ref = if let Ok(output) = output {
            if output.status.success() {
                String::from_utf8_lossy(&output.stdout).trim().to_string()
            } else {
                // Not on a branch, get commit hash
                let commit_output = Command::new("git")
                    .args(["rev-parse", "HEAD"])
                    .output()
                    .context("Failed to get current commit")?;
                String::from_utf8_lossy(&commit_output.stdout)
                    .trim()
                    .to_string()
            }
        } else {
            return Ok(false);
        };

        // Support glob patterns
        let glob = globset::Glob::new(pattern).context("Invalid ref pattern")?;
        let matcher = glob.compile_matcher();

        Ok(matcher.is_match(&current_ref))
    }

    /// Check if files matching pattern have changed
    fn check_files_condition(&self, pattern: &str) -> Result<bool> {
        let Some(repo) = &self.repo else {
            return Ok(false);
        };

        // Get changed files (staged + modified)
        let staged = repo.get_staged_files()?;
        let modified = repo.get_modified_files()?;

        let glob = globset::Glob::new(pattern).context("Invalid file pattern")?;
        let matcher = glob.compile_matcher();

        // Check if any changed file matches the pattern
        for file in staged.iter().chain(modified.iter()) {
            if matcher.is_match(file) {
                return Ok(true);
            }
        }

        Ok(false)
    }

    /// Check if in a merge state
    fn check_merge_condition(&self) -> Result<bool> {
        Ok(std::path::Path::new(".git/MERGE_HEAD").exists())
    }

    /// Check if in a rebase state
    fn check_rebase_condition(&self) -> Result<bool> {
        Ok(std::path::Path::new(".git/rebase-merge").exists()
            || std::path::Path::new(".git/rebase-apply").exists())
    }

    /// Check if running in CI environment
    fn check_ci_condition(&self) -> Result<bool> {
        // Check common CI environment variables
        Ok(std::env::var("CI").is_ok()
            || std::env::var("CONTINUOUS_INTEGRATION").is_ok()
            || std::env::var("GITHUB_ACTIONS").is_ok()
            || std::env::var("GITLAB_CI").is_ok()
            || std::env::var("CIRCLECI").is_ok()
            || std::env::var("TRAVIS").is_ok()
            || std::env::var("JENKINS_URL").is_ok())
    }
}

/// Helper to determine if a command should be skipped
pub fn should_skip(
    skip: &HookCondition,
    only: &HookCondition,
    evaluator: &ConditionEvaluator,
) -> Result<bool> {
    // If skip condition is true, skip
    if evaluator.evaluate(skip)? {
        return Ok(true);
    }

    // If only condition exists and is false, skip
    match only {
        HookCondition::Bool(false) => Ok(false), // Bool(false) means no "only" restriction
        HookCondition::Bool(true) => Ok(false),  // Bool(true) means always run
        HookCondition::Array(conditions) if conditions.is_empty() => Ok(false), /* Empty array = no restriction */
        _ => {
            // Has "only" conditions - skip if none match
            Ok(!evaluator.evaluate(only)?)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_bool_conditions() {
        let evaluator = ConditionEvaluator::new();

        // Test boolean true
        assert!(evaluator.evaluate(&HookCondition::Bool(true)).unwrap());

        // Test boolean false
        assert!(!evaluator.evaluate(&HookCondition::Bool(false)).unwrap());
    }

    #[test]
    fn test_array_conditions() {
        let evaluator = ConditionEvaluator::new();

        // Test array with "true" condition
        let conditions = HookCondition::Array(vec!["true".to_string()]);
        assert!(evaluator.evaluate(&conditions).unwrap());

        // Test array with "false" condition
        let conditions = HookCondition::Array(vec!["false".to_string()]);
        assert!(!evaluator.evaluate(&conditions).unwrap());

        // Test array with mixed conditions (OR logic)
        let conditions = HookCondition::Array(vec!["false".to_string(), "true".to_string()]);
        assert!(evaluator.evaluate(&conditions).unwrap());
    }

    #[test]
    fn test_ci_condition() {
        let evaluator = ConditionEvaluator::new();

        // Test CI detection (will be false in local tests)
        let conditions = HookCondition::Array(vec!["ci".to_string()]);
        let result = evaluator.evaluate(&conditions).unwrap();

        // In local environment, should be false
        if std::env::var("CI").is_err() {
            assert!(!result);
        }
    }

    #[test]
    fn test_should_skip_logic() {
        let evaluator = ConditionEvaluator::new();

        // Test skip=true
        assert!(
            should_skip(
                &HookCondition::Bool(true),
                &HookCondition::Bool(false),
                &evaluator
            )
            .unwrap()
        );

        // Test skip=false, only not set
        assert!(
            !should_skip(
                &HookCondition::Bool(false),
                &HookCondition::Bool(false),
                &evaluator
            )
            .unwrap()
        );

        // Test skip=false, only=true
        assert!(
            !should_skip(
                &HookCondition::Bool(false),
                &HookCondition::Bool(true),
                &evaluator
            )
            .unwrap()
        );

        // Test skip=false, only has conditions that don't match
        assert!(
            should_skip(
                &HookCondition::Bool(false),
                &HookCondition::Array(vec!["branch:nonexistent".to_string()]),
                &evaluator
            )
            .unwrap()
        );
    }
}