guardy 0.2.4

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

use serde::{Deserialize, Serialize};

use crate::config_build;

// Custom deserializer to accept both string and array for glob field
fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result<Arc<Vec<String>>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    use serde::de::{self, Deserialize};

    struct StringOrVec;

    impl<'de> serde::de::Visitor<'de> for StringOrVec {
        type Value = Arc<Vec<String>>;

        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
            formatter.write_str("string or array of strings")
        }

        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
        where
            E: de::Error,
        {
            Ok(Arc::new(vec![value.to_string()]))
        }

        fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
        where
            A: de::SeqAccess<'de>,
        {
            let vec = Vec::<String>::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
            Ok(Arc::new(vec))
        }
    }

    deserializer.deserialize_any(StringOrVec)
}

// Complete hooks configuration with runtime settings + file-loaded hook definitions
config_build! {
    HooksConfig<crate::cli::commands::hooks::HooksArgs> {
        // Global runtime settings (CLI/env overridable)
        skip_all: bool => {
            cli: |args: &crate::cli::commands::hooks::HooksArgs| args.skip_all,
            env: "GUARDY_HOOKS_SKIP_ALL",
            default: false,
        },

        parallel: bool => {
            cli: |args: &crate::cli::commands::hooks::HooksArgs| args.parallel,
            env: "GUARDY_HOOKS_PARALLEL",
            default: true,
        },


        continue_on_error: bool => {
            env: "GUARDY_HOOKS_CONTINUE_ON_ERROR",
            default: false,
        },

        auto_stage: bool => {
            env: "GUARDY_HOOKS_AUTO_STAGE",
            default: false,
        },

        // Individual hook definitions (file-loaded via serde from [hooks] section)
        pre_commit: Arc<HookDefinition> => {
            default: Arc::new(HookDefinition::default()),
        },

        commit_msg: Arc<HookDefinition> => {
            default: Arc::new(HookDefinition::default()),
        },

        pre_push: Arc<HookDefinition> => {
            default: Arc::new(HookDefinition::default()),
        },

        post_checkout: Arc<HookDefinition> => {
            default: Arc::new(HookDefinition::default()),
        },

        post_commit: Arc<HookDefinition> => {
            default: Arc::new(HookDefinition::default()),
        },

        post_merge: Arc<HookDefinition> => {
            default: Arc::new(HookDefinition::default()),
        },
    }
}

// Lefthook-style hook definitions loaded from file
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct HookDefinition {
    /// Run commands in parallel for this hook
    #[serde(default)]
    pub parallel: bool,

    /// Skip this hook entirely
    #[serde(default)]
    pub skip: bool,

    /// Built-in actions to run (e.g., ["scan_secrets", "conventional_commits"])
    #[serde(default)]
    pub builtin: Vec<String>,

    /// Individual commands to run
    #[serde(default)]
    pub commands: HashMap<String, HookCommand>,

    /// Script files to execute (lefthook compatibility)
    #[serde(default)]
    pub scripts: HashMap<String, HookScript>,

    /// Configuration for conventional_commits builtin
    #[serde(default, rename = "conventional-commits")]
    pub conventional_commits: Option<ConventionalCommitsConfig>,
}

/// Configuration for the conventional_commits builtin hook
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct ConventionalCommitsConfig {
    /// Allowed commit types (e.g., ["feat", "fix", "docs"])
    /// If not specified, uses standard conventional commit types
    #[serde(default)]
    pub allowed_types: Option<Vec<String>>,

    /// Require scope in commit messages (e.g., "feat(auth): message")
    #[serde(default)]
    pub enforce_scope: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct HookCommand {
    /// The command to run
    pub run: String,

    /// Command description (for display purposes)
    #[serde(default)]
    pub description: String,

    /// Continue execution on error
    #[serde(default)]
    pub continue_on_error: bool,

    /// Process all files instead of just staged files
    #[serde(default)]
    pub all_files: bool,

    /// File glob patterns to match
    #[serde(default, deserialize_with = "deserialize_string_or_vec")]
    pub glob: Arc<Vec<String>>,

    /// File types to include
    #[serde(default)]
    pub file_types: Vec<String>,

    /// Auto-stage fixed files after successful run
    #[serde(default)]
    pub stage_fixed: bool,

    /// Skip condition (boolean or array of conditions)
    #[serde(default)]
    pub skip: HookCondition,

    /// Only run condition (boolean or array of conditions)  
    #[serde(default)]
    pub only: HookCondition,

    /// Environment variables
    #[serde(default)]
    pub env: HashMap<String, String>,

    /// Working directory root
    #[serde(default)]
    pub root: Option<String>,

    /// Exclude patterns
    #[serde(default)]
    pub exclude: Vec<String>,

    /// Execution priority (lower runs first)
    #[serde(default)]
    pub priority: i32,

    /// Custom failure message
    #[serde(default)]
    pub fail_text: Option<String>,

    /// Run interactively (pass stdin/stdout)
    #[serde(default)]
    pub interactive: bool,

    /// Use stdin for input
    #[serde(default)]
    pub use_stdin: bool,

    /// Tags for command categorization
    #[serde(default)]
    pub tags: Vec<String>,

    /// Custom command to discover files (Lefthook compatibility)
    #[serde(default)]
    pub files: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookScript {
    /// Script runner (e.g., "node", "python", "bash")
    pub runner: String,

    /// Environment variables
    #[serde(default)]
    pub env: HashMap<String, String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum HookCondition {
    Bool(bool),
    Array(Vec<String>),
}

impl Default for HookCondition {
    fn default() -> Self {
        Self::Bool(false)
    }
}