use astrid_core::dirs::WorkspaceLayout;
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> {
discover_hooks_with_layout(extra_paths, &WorkspaceLayout::default())
}
pub(crate) fn discover_hooks_with_layout(
extra_paths: Option<&[PathBuf]>,
workspace_layout: &WorkspaceLayout,
) -> Vec<Hook> {
let workspace_root = std::env::current_dir().ok();
discover_hooks_in_workspace(extra_paths, workspace_root.as_deref(), workspace_layout)
}
pub(crate) fn discover_hooks_in_workspace(
extra_paths: Option<&[PathBuf]>,
workspace_root: Option<&Path>,
workspace_layout: &WorkspaceLayout,
) -> Vec<Hook> {
let mut hooks = Vec::new();
if let Some(workspace_root) = workspace_root {
let checked = workspace_layout
.resolve(workspace_root)
.and_then(|selection| selection.verify_tree("hooks").map(|dir| (selection, dir)));
if let Ok((selection, local_hooks_dir)) = checked {
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 Err(error) = selection.verify_tree("hooks") {
warn!(%error, "Workspace changed during hook discovery; discarding workspace hooks");
hooks.clear();
}
} else if let Err(error) = checked {
warn!(%error, "Unsafe workspace hook path; skipping workspace hooks");
}
}
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_hooks_dir_with_layout(workspace_root, &WorkspaceLayout::default())
}
#[must_use]
pub(crate) fn workspace_hooks_dir_with_layout(
workspace_root: &Path,
workspace_layout: &WorkspaceLayout,
) -> PathBuf {
workspace_layout.hooks_dir(workspace_root)
}
#[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;
}
#[test]
fn workspace_hooks_use_injected_layout() {
let layout = WorkspaceLayout::new(".alternate-runtime").unwrap();
assert_eq!(
workspace_hooks_dir_with_layout(Path::new("/workspace"), &layout),
PathBuf::from("/workspace/.alternate-runtime/hooks")
);
}
#[test]
fn discovery_uses_explicit_workspace_root() {
let workspace = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(".alternate-runtime").unwrap();
let hooks_dir = layout.hooks_dir(workspace.path());
std::fs::create_dir_all(&hooks_dir).unwrap();
save_hook(
&Hook::new(HookEvent::SessionStart).with_name("selected"),
&hooks_dir.join("HOOK.toml"),
)
.unwrap();
let hooks = discover_hooks_in_workspace(None, Some(workspace.path()), &layout);
assert_eq!(hooks.len(), 1);
assert_eq!(hooks[0].name.as_deref(), Some("selected"));
}
#[cfg(unix)]
#[test]
fn discovery_skips_workspace_with_symlinked_hook_file() {
use std::os::unix::fs::symlink;
let workspace = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let hooks = workspace.path().join(".astrid/hooks");
std::fs::create_dir_all(&hooks).unwrap();
let target = outside.path().join("HOOK.toml");
std::fs::write(&target, "event = 'session-start'\n").unwrap();
symlink(target, hooks.join("HOOK.toml")).unwrap();
assert!(
discover_hooks_in_workspace(None, Some(workspace.path()), &WorkspaceLayout::default())
.is_empty()
);
}
}