use std::{
path::{Path, PathBuf},
time::Duration,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::context::ContextScope;
use super::{contract::HookEvent, wire::HOOK_SCHEMA_VERSION};
pub const DEFAULT_WORKSPACE_HOOKS_FILE: &str = ".basis/hooks.json";
pub const DEFAULT_GLOBAL_HOOKS_FILE: &str = "hooks.json";
pub const DEFAULT_HOOK_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnFailure {
#[default]
Deny,
Allow,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HookSpec {
pub name: String,
pub command: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<String>>,
#[serde(default)]
pub event: HookEvent,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
#[serde(default)]
pub on_failure: OnFailure,
}
impl HookSpec {
pub fn new(name: impl Into<String>, command: Vec<String>) -> Self {
Self {
name: name.into(),
command,
tools: None,
event: HookEvent::default(),
timeout_ms: None,
on_failure: OnFailure::default(),
}
}
pub fn with_tools(self, tools: Vec<String>) -> Self {
Self {
tools: Some(tools),
..self
}
}
pub fn with_timeout(self, timeout: Duration) -> Self {
Self {
timeout_ms: Some(timeout.as_millis() as u64),
..self
}
}
pub fn with_on_failure(self, on_failure: OnFailure) -> Self {
Self { on_failure, ..self }
}
pub fn timeout(&self) -> Duration {
self.timeout_ms
.map(Duration::from_millis)
.unwrap_or(DEFAULT_HOOK_TIMEOUT)
}
pub fn applies_to(&self, event: HookEvent, tool_name: &str) -> bool {
self.event == event
&& self
.tools
.as_ref()
.is_none_or(|tools| tools.iter().any(|name| name == tool_name))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HooksFile {
pub schema: u32,
#[serde(default)]
pub hooks: Vec<HookSpec>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HooksConfig {
pub workspace_file: PathBuf,
pub global_dir: Option<PathBuf>,
}
impl Default for HooksConfig {
fn default() -> Self {
Self {
workspace_file: PathBuf::from(DEFAULT_WORKSPACE_HOOKS_FILE),
global_dir: crate::context::ContextConfig::default().global_dir,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HooksSource {
pub path: PathBuf,
pub scope: ContextScope,
pub hooks: Vec<HookSpec>,
}
#[derive(Debug, Error)]
pub enum HookConfigError {
#[error("failed to read {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{path} is not a valid hooks file: {source}")]
Parse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error(
"{path} declares hooks schema {schema}, but this basis understands {HOOK_SCHEMA_VERSION}"
)]
UnsupportedSchema { path: PathBuf, schema: u32 },
#[error("hook '{name}' in {path} has no command to run")]
EmptyCommand { path: PathBuf, name: String },
}
pub fn discover(
workspace: &Path,
config: &HooksConfig,
) -> Result<Vec<HooksSource>, HookConfigError> {
let mut sources = Vec::new();
if let Some(global) = &config.global_dir {
let path = global.join(DEFAULT_GLOBAL_HOOKS_FILE);
if path.is_file() {
sources.push(read_source(&path, ContextScope::Global)?);
}
}
let workspace_path = workspace.join(&config.workspace_file);
if workspace_path.is_file()
&& !sources
.iter()
.any(|source| same_file(&source.path, &workspace_path))
{
sources.push(read_source(&workspace_path, ContextScope::Workspace)?);
}
Ok(sources)
}
pub fn load(workspace: &Path, config: &HooksConfig) -> Result<Vec<HookSpec>, HookConfigError> {
Ok(discover(workspace, config)?
.into_iter()
.flat_map(|source| source.hooks)
.collect())
}
fn read_source(path: &Path, scope: ContextScope) -> Result<HooksSource, HookConfigError> {
let text = std::fs::read_to_string(path).map_err(|source| HookConfigError::Read {
path: path.to_path_buf(),
source,
})?;
let file: HooksFile = serde_json::from_str(&text).map_err(|source| HookConfigError::Parse {
path: path.to_path_buf(),
source,
})?;
if file.schema != HOOK_SCHEMA_VERSION {
return Err(HookConfigError::UnsupportedSchema {
path: path.to_path_buf(),
schema: file.schema,
});
}
if let Some(broken) = file.hooks.iter().find(|hook| hook.command.is_empty()) {
return Err(HookConfigError::EmptyCommand {
path: path.to_path_buf(),
name: broken.name.clone(),
});
}
Ok(HooksSource {
path: path.to_path_buf(),
scope,
hooks: file.hooks,
})
}
fn same_file(left: &Path, right: &Path) -> bool {
match (left.canonicalize(), right.canonicalize()) {
(Ok(left), Ok(right)) => left == right,
_ => left == right,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config(global: Option<PathBuf>) -> HooksConfig {
HooksConfig {
workspace_file: PathBuf::from(DEFAULT_WORKSPACE_HOOKS_FILE),
global_dir: global,
}
}
fn write_hooks(dir: &Path, relative: &str, body: &str) -> PathBuf {
let path = dir.join(relative);
std::fs::create_dir_all(path.parent().expect("a parent")).expect("create dirs");
std::fs::write(&path, body).expect("write hooks file");
path
}
const ONE_HOOK: &str = r#"{
"schema": 1,
"hooks": [{"name": "guard", "command": ["/bin/true"]}]
}"#;
#[test]
fn nothing_on_disk_means_no_hooks() {
let tmp = tempfile::tempdir().expect("tempdir");
assert!(
load(tmp.path(), &config(None))
.expect("no file is not an error")
.is_empty()
);
}
#[test]
fn a_workspace_file_is_found() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = write_hooks(tmp.path(), DEFAULT_WORKSPACE_HOOKS_FILE, ONE_HOOK);
let found = discover(tmp.path(), &config(None)).expect("parses");
assert_eq!(found.len(), 1);
assert_eq!(found[0].path, path);
assert_eq!(found[0].scope, ContextScope::Workspace);
assert_eq!(found[0].hooks[0].name, "guard");
}
#[test]
fn the_operator_speaks_before_the_repository() {
let tmp = tempfile::tempdir().expect("tempdir");
let global = tmp.path().join("global");
write_hooks(tmp.path(), DEFAULT_WORKSPACE_HOOKS_FILE, ONE_HOOK);
write_hooks(
&global,
DEFAULT_GLOBAL_HOOKS_FILE,
r#"{"schema": 1, "hooks": [{"name": "personal", "command": ["/bin/true"]}]}"#,
);
let hooks = load(tmp.path(), &config(Some(global))).expect("parses");
assert_eq!(
hooks.iter().map(|hook| &hook.name).collect::<Vec<_>>(),
vec!["personal", "guard"],
"a global deny must be able to stop a workspace hook from ever spawning"
);
}
#[test]
fn one_file_reached_twice_is_read_once() {
let tmp = tempfile::tempdir().expect("tempdir");
write_hooks(tmp.path(), DEFAULT_GLOBAL_HOOKS_FILE, ONE_HOOK);
let found = discover(
tmp.path(),
&HooksConfig {
workspace_file: PathBuf::from(DEFAULT_GLOBAL_HOOKS_FILE),
global_dir: Some(tmp.path().to_path_buf()),
},
)
.expect("parses");
assert_eq!(
found.len(),
1,
"one guard must not deny the same call twice"
);
}
#[test]
fn a_broken_file_is_an_error_not_an_empty_list() {
let tmp = tempfile::tempdir().expect("tempdir");
write_hooks(tmp.path(), DEFAULT_WORKSPACE_HOOKS_FILE, "{ not json");
let error = load(tmp.path(), &config(None)).expect_err("rejected");
assert!(matches!(error, HookConfigError::Parse { .. }));
}
#[test]
fn a_future_schema_is_refused_by_name() {
let tmp = tempfile::tempdir().expect("tempdir");
write_hooks(
tmp.path(),
DEFAULT_WORKSPACE_HOOKS_FILE,
r#"{"schema": 99, "hooks": []}"#,
);
let error = load(tmp.path(), &config(None)).expect_err("rejected");
assert!(matches!(
error,
HookConfigError::UnsupportedSchema { schema: 99, .. }
));
assert!(error.to_string().contains("99"));
}
#[test]
fn a_hook_with_no_command_is_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
write_hooks(
tmp.path(),
DEFAULT_WORKSPACE_HOOKS_FILE,
r#"{"schema": 1, "hooks": [{"name": "empty", "command": []}]}"#,
);
let error = load(tmp.path(), &config(None)).expect_err("rejected");
assert!(matches!(error, HookConfigError::EmptyCommand { .. }));
}
#[test]
fn omitted_fields_take_the_safe_defaults() {
let spec: HookSpec =
serde_json::from_str(r#"{"name": "g", "command": ["/bin/true"]}"#).expect("parses");
assert_eq!(spec.event, HookEvent::PreToolUse);
assert_eq!(spec.on_failure, OnFailure::Deny);
assert_eq!(spec.timeout(), DEFAULT_HOOK_TIMEOUT);
assert!(spec.applies_to(HookEvent::PreToolUse, "anything"));
}
#[test]
fn listing_tools_narrows_the_hook() {
let spec =
HookSpec::new("g", vec!["/bin/true".to_string()]).with_tools(vec!["shell".to_string()]);
assert!(spec.applies_to(HookEvent::PreToolUse, "shell"));
assert!(!spec.applies_to(HookEvent::PreToolUse, "files"));
}
#[test]
fn builders_return_new_values() {
let base = HookSpec::new("g", vec!["/bin/true".to_string()]);
let derived = base
.clone()
.with_on_failure(OnFailure::Allow)
.with_timeout(Duration::from_millis(250));
assert_eq!(
base.on_failure,
OnFailure::Deny,
"the original is untouched"
);
assert_eq!(base.timeout(), DEFAULT_HOOK_TIMEOUT);
assert_eq!(derived.on_failure, OnFailure::Allow);
assert_eq!(derived.timeout(), Duration::from_millis(250));
}
}