use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookType {
PreCommit,
PostCommit,
PrePush,
UserPromptSubmit,
ToolResultReturn,
Custom(String),
}
impl HookType {
fn from_filename(name: &str) -> Self {
match name.trim_end_matches(".sh") {
"pre-commit" => HookType::PreCommit,
"post-commit" => HookType::PostCommit,
"pre-push" => HookType::PrePush,
"user-prompt-submit" => HookType::UserPromptSubmit,
"tool-result-return" => HookType::ToolResultReturn,
custom => HookType::Custom(custom.to_string()),
}
}
}
#[derive(Debug, Clone)]
pub struct Hook {
pub name: String,
pub hook_type: HookType,
pub path: PathBuf,
pub is_executable: bool,
pub has_valid_shebang: bool,
}
#[derive(Debug, Error)]
pub enum HookError {
#[error("Missing shebang: hook must start with #!/bin/bash")]
MissingShebang,
#[error("Invalid shebang: expected #!/bin/bash, got {0}")]
InvalidShebang(String),
}
pub struct HooksParser;
impl HooksParser {
pub fn scan_directory(hooks_dir: &Path) -> Result<Vec<Hook>> {
let mut hooks = Vec::new();
if !hooks_dir.exists() {
return Ok(hooks);
}
for entry in fs::read_dir(hooks_dir)
.with_context(|| format!("Failed to read hooks directory: {}", hooks_dir.display()))?
{
let entry = entry.with_context(|| {
format!(
"Failed to read entry in hooks directory: {}",
hooks_dir.display()
)
})?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("sh") {
match Self::parse_hook(&path) {
Ok(hook) => hooks.push(hook),
Err(e) => {
eprintln!("Warning: Failed to parse hook {:?}: {}", path, e);
}
}
}
}
Ok(hooks)
}
pub fn parse_hook(path: &Path) -> Result<Hook> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read hook file: {}", path.display()))?;
let has_valid_shebang = Self::validate_shebang(&content).is_ok();
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let hook_type = HookType::from_filename(
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown"),
);
let metadata = fs::metadata(path)
.with_context(|| format!("Failed to read hook metadata: {}", path.display()))?;
#[cfg(unix)]
let is_executable = {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
};
#[cfg(not(unix))]
let is_executable = false;
Ok(Hook {
name,
hook_type,
path: path.to_path_buf(),
is_executable,
has_valid_shebang,
})
}
fn validate_shebang(content: &str) -> Result<(), HookError> {
let first_line = content.lines().next().unwrap_or("");
if first_line.is_empty() {
return Err(HookError::MissingShebang);
}
if !first_line.starts_with("#!/bin/bash") {
return Err(HookError::InvalidShebang(first_line.to_string()));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::TempDir;
#[test]
fn test_valid_hook_with_shebang() {
let temp_dir = TempDir::new().unwrap();
let hook_path = temp_dir.path().join("pre-commit.sh");
let content = "#!/bin/bash\necho 'Running pre-commit hook'";
fs::write(&hook_path, content).unwrap();
let mut perms = fs::metadata(&hook_path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&hook_path, perms).unwrap();
let hook = HooksParser::parse_hook(&hook_path).unwrap();
assert_eq!(hook.name, "pre-commit");
assert_eq!(hook.hook_type, HookType::PreCommit);
assert!(hook.is_executable);
assert!(hook.has_valid_shebang);
}
#[test]
fn test_missing_shebang_returns_validation_error() {
let content = "echo 'No shebang'";
let result = HooksParser::validate_shebang(content);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, HookError::InvalidShebang(_)));
}
#[test]
fn test_non_executable_hook_marked_correctly() {
let temp_dir = TempDir::new().unwrap();
let hook_path = temp_dir.path().join("pre-commit.sh");
let content = "#!/bin/bash\necho 'test'";
fs::write(&hook_path, content).unwrap();
let mut perms = fs::metadata(&hook_path).unwrap().permissions();
perms.set_mode(0o644); fs::set_permissions(&hook_path, perms).unwrap();
let hook = HooksParser::parse_hook(&hook_path).unwrap();
assert!(!hook.is_executable);
assert!(hook.has_valid_shebang);
}
#[test]
fn test_scan_directory_finds_multiple_hooks() {
let temp_dir = TempDir::new().unwrap();
for name in &["pre-commit.sh", "post-commit.sh", "custom-hook.sh"] {
let path = temp_dir.path().join(name);
fs::write(&path, "#!/bin/bash\necho 'test'").unwrap();
}
let hooks = HooksParser::scan_directory(temp_dir.path()).unwrap();
assert_eq!(hooks.len(), 3);
assert!(hooks.iter().any(|h| h.name == "pre-commit"));
assert!(hooks.iter().any(|h| h.name == "post-commit"));
assert!(hooks.iter().any(|h| h.name == "custom-hook"));
}
#[test]
fn test_invalid_shebang_returns_error() {
let content = "#!/usr/bin/env python\nprint('wrong')";
let result = HooksParser::validate_shebang(content);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), HookError::InvalidShebang(_)));
}
#[test]
fn test_empty_file_returns_missing_shebang_error() {
let result = HooksParser::validate_shebang("");
assert!(result.is_err());
}
#[test]
fn test_hook_type_parsing() {
assert_eq!(
HookType::from_filename("pre-commit.sh"),
HookType::PreCommit
);
assert_eq!(
HookType::from_filename("user-prompt-submit.sh"),
HookType::UserPromptSubmit
);
match HookType::from_filename("my-custom.sh") {
HookType::Custom(name) => assert_eq!(name, "my-custom"),
_ => panic!("Expected Custom variant"),
}
}
}