guardy 0.2.4

Fast, secure git hooks in Rust with secret scanning and protected file synchronization
Documentation
use crate::config::hooks::HookCommand;

/// Methods for discovering files to process in hook commands
#[derive(Debug, Clone, PartialEq)]
pub enum FileDiscoveryMethod {
    /// Execute custom command to discover files (e.g., "git diff --name-only")
    CustomCommand(String),
    /// Use staged files from git index ({staged_files} placeholder)
    StagedFiles,
    /// Use all tracked files in repository ({all_files} placeholder)
    AllFiles,
    /// Use push-specific files ({push_files} placeholder)
    PushFiles,
    /// No file processing needed - execute command as-is
    NoFiles,
    /// Skip command - has {files} placeholder but no files: command
    Skip,
}

impl FileDiscoveryMethod {
    /// Determine the appropriate file discovery method for a hook command
    ///
    /// Priority order:
    /// 1. Explicit `files:` command (highest priority)
    /// 2. File placeholders in command string ({staged_files}, {all_files}, etc.)
    /// 3. Glob/exclude patterns present (use staged files)
    /// 4. No file operations = skip
    pub fn from_hook_command(cmd: &HookCommand, hook_name: &str) -> Self {
        tracing::trace!("Determining file discovery method for command: {}", cmd.run);

        // 1. Check for explicit files command (highest priority)
        if let Some(files_cmd) = &cmd.files {
            tracing::trace!("Using custom files command: {}", files_cmd);
            return Self::CustomCommand(files_cmd.clone());
        }

        // 2. Check command for file placeholders
        if cmd.run.contains("{staged_files}") {
            tracing::trace!("Found {{staged_files}} placeholder");
            return Self::StagedFiles;
        }
        if cmd.run.contains("{all_files}") {
            tracing::trace!("Found {{all_files}} placeholder");
            return Self::AllFiles;
        }
        if cmd.run.contains("{push_files}") {
            tracing::trace!("Found {{push_files}} placeholder");
            return Self::PushFiles;
        }
        if cmd.run.contains("{files}") {
            // {files} without explicit files: command = skip
            tracing::trace!("Found {{files}} placeholder without files: command - will skip");
            return Self::Skip;
        }

        // 3. Check for glob/exclude patterns that need file filtering
        if !cmd.glob.is_empty() || !cmd.exclude.is_empty() || !cmd.file_types.is_empty() {
            tracing::trace!(
                "Has filtering patterns but no file placeholders - using staged files for {}",
                hook_name
            );
            return Self::StagedFiles;
        }

        // 4. No file operations = execute normally (don't skip!)
        tracing::trace!("No file operations needed - command will execute without file filtering");
        Self::NoFiles
    }

    /// Check if the command should be skipped entirely
    pub fn should_skip(&self) -> bool {
        matches!(self, Self::Skip)
    }
}

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

    #[test]
    fn test_custom_files_command_takes_priority() {
        let cmd = HookCommand {
            run: "echo {files}".to_string(),
            files: Some("git diff --name-only".to_string()),
            ..Default::default()
        };

        let method = FileDiscoveryMethod::from_hook_command(&cmd, "pre-commit");
        assert_eq!(
            method,
            FileDiscoveryMethod::CustomCommand("git diff --name-only".to_string())
        );
    }

    #[test]
    fn test_staged_files_placeholder() {
        let cmd = HookCommand {
            run: "echo {staged_files}".to_string(),
            ..Default::default()
        };

        let method = FileDiscoveryMethod::from_hook_command(&cmd, "pre-commit");
        assert_eq!(method, FileDiscoveryMethod::StagedFiles);
    }

    #[test]
    fn test_all_files_placeholder() {
        let cmd = HookCommand {
            run: "echo {all_files}".to_string(),
            ..Default::default()
        };

        let method = FileDiscoveryMethod::from_hook_command(&cmd, "pre-commit");
        assert_eq!(method, FileDiscoveryMethod::AllFiles);
    }

    #[test]
    fn test_files_without_files_command_skips() {
        let cmd = HookCommand {
            run: "echo {files}".to_string(),
            files: None,
            ..Default::default()
        };

        let method = FileDiscoveryMethod::from_hook_command(&cmd, "pre-commit");
        assert_eq!(method, FileDiscoveryMethod::Skip);
    }

    #[test]
    fn test_glob_patterns_use_staged_files() {
        let cmd = HookCommand {
            run: "echo test".to_string(),
            glob: std::sync::Arc::new(vec!["*.rs".to_string()]),
            ..Default::default()
        };

        let method = FileDiscoveryMethod::from_hook_command(&cmd, "pre-commit");
        assert_eq!(method, FileDiscoveryMethod::StagedFiles);
    }

    #[test]
    fn test_no_file_operations_skips() {
        let cmd = HookCommand {
            run: "echo no files".to_string(),
            ..Default::default()
        };

        let method = FileDiscoveryMethod::from_hook_command(&cmd, "pre-commit");
        assert_eq!(method, FileDiscoveryMethod::NoFiles);
    }

    #[test]
    fn test_should_skip() {
        assert!(!FileDiscoveryMethod::NoFiles.should_skip());
        assert!(FileDiscoveryMethod::Skip.should_skip());
        assert!(!FileDiscoveryMethod::StagedFiles.should_skip());
        assert!(!FileDiscoveryMethod::AllFiles.should_skip());
        assert!(!FileDiscoveryMethod::CustomCommand("test".to_string()).should_skip());
    }
}