use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::{debug, info, warn};
use crate::hook::Hook;
#[derive(Debug, Error)]
pub(crate) enum DiscoveryError {
#[error("failed to read directory {path}: {message}")]
DirectoryRead {
path: PathBuf,
message: String,
},
#[error("failed to read hook file {path}: {message}")]
FileRead {
path: PathBuf,
message: String,
},
#[error("failed to parse hook file {path}: {message}")]
Parse {
path: PathBuf,
message: String,
},
}
pub(crate) type DiscoveryResult<T> = Result<T, DiscoveryError>;
pub(crate) const HOOK_FILE_NAMES: &[&str] = &["HOOK.toml", "hook.toml", "hooks.toml"];
pub(crate) fn discover_hooks(extra_paths: Option<&[PathBuf]>) -> Vec<Hook> {
let mut hooks = Vec::new();
let local_hooks_dir = PathBuf::from(".astrid/hooks");
if local_hooks_dir.exists() {
info!(path = %local_hooks_dir.display(), "Discovering hooks from local directory");
match load_hooks_from_dir(&local_hooks_dir) {
Ok(found) => hooks.extend(found),
Err(e) => warn!(error = %e, "Failed to load hooks from local directory"),
}
}
if let Some(paths) = extra_paths {
for path in paths {
if path.exists() {
info!(path = %path.display(), "Discovering hooks from custom path");
match load_hooks_from_dir(path) {
Ok(found) => hooks.extend(found),
Err(e) => warn!(error = %e, "Failed to load hooks from custom path"),
}
}
}
}
info!(count = hooks.len(), "Discovered hooks");
hooks
}
pub(crate) fn load_hooks_from_dir(dir: &Path) -> DiscoveryResult<Vec<Hook>> {
let mut hooks = Vec::new();
let entries = std::fs::read_dir(dir).map_err(|e| DiscoveryError::DirectoryRead {
path: dir.to_path_buf(),
message: e.to_string(),
})?;
for entry in entries {
let entry = entry.map_err(|e| DiscoveryError::DirectoryRead {
path: dir.to_path_buf(),
message: e.to_string(),
})?;
let path = entry.path();
if path.is_dir() {
for hook_file in HOOK_FILE_NAMES {
let hook_path = path.join(hook_file);
if hook_path.exists() {
match load_hook(&hook_path) {
Ok(hook) => {
debug!(path = %hook_path.display(), "Loaded hook");
hooks.push(hook);
},
Err(e) => {
warn!(
path = %hook_path.display(),
error = %e,
"Failed to load hook"
);
},
}
break; }
}
} else if path.is_file()
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
&& HOOK_FILE_NAMES.contains(&name)
{
match load_hook(&path) {
Ok(hook) => {
debug!(path = %path.display(), "Loaded hook");
hooks.push(hook);
},
Err(e) => {
warn!(path = %path.display(), error = %e, "Failed to load hook");
},
}
}
}
Ok(hooks)
}
pub(crate) fn load_hook(path: &Path) -> DiscoveryResult<Hook> {
let content = std::fs::read_to_string(path).map_err(|e| DiscoveryError::FileRead {
path: path.to_path_buf(),
message: e.to_string(),
})?;
let hook: Hook = toml::from_str(&content).map_err(|e| DiscoveryError::Parse {
path: path.to_path_buf(),
message: e.to_string(),
})?;
Ok(hook)
}
pub(crate) fn save_hook(hook: &Hook, path: &Path) -> DiscoveryResult<()> {
let content = toml::to_string_pretty(hook).map_err(|e| DiscoveryError::Parse {
path: path.to_path_buf(),
message: e.to_string(),
})?;
std::fs::write(path, content).map_err(|e| DiscoveryError::FileRead {
path: path.to_path_buf(),
message: e.to_string(),
})?;
Ok(())
}
#[must_use]
pub(crate) fn workspace_hooks_dir(workspace_root: &Path) -> PathBuf {
workspace_root.join(".astrid").join("hooks")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hook::{HookEvent, HookHandler};
use tempfile::TempDir;
#[test]
fn test_load_hook_from_toml() {
let temp_dir = TempDir::new().unwrap();
let hook_path = temp_dir.path().join("HOOK.toml");
let hook = Hook::new(HookEvent::SessionStart)
.with_name("test-hook")
.with_handler(HookHandler::command("echo"));
save_hook(&hook, &hook_path).unwrap();
let loaded = load_hook(&hook_path).unwrap();
assert_eq!(loaded.name, Some("test-hook".to_string()));
assert_eq!(loaded.event, HookEvent::SessionStart);
}
#[test]
fn test_load_hooks_from_dir() {
let temp_dir = TempDir::new().unwrap();
let subdir = temp_dir.path().join("my-hook");
std::fs::create_dir(&subdir).unwrap();
let hook = Hook::new(HookEvent::PreToolCall)
.with_name("sub-hook")
.with_handler(HookHandler::command("echo"));
save_hook(&hook, &subdir.join("HOOK.toml")).unwrap();
let hooks = load_hooks_from_dir(temp_dir.path()).unwrap();
assert_eq!(hooks.len(), 1);
assert_eq!(hooks[0].name, Some("sub-hook".to_string()));
}
#[test]
fn test_discover_hooks_empty() {
let hooks = discover_hooks(None);
let _ = hooks;
}
}