use std::{collections::BTreeMap, path::Path};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
Error, Result,
common::parts,
fs,
harness::{MergeOp, Part},
hook::{Event, wire},
integration::{Integration, Item},
};
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct HooksFile {
#[serde(default)]
pub hooks: BTreeMap<String, Vec<Group>>,
}
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Group {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matcher: Option<String>,
pub hooks: Vec<Entry>,
}
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Entry {
#[serde(rename = "type", default = "command_type")]
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
}
fn command_type() -> String {
"command".into()
}
pub const DEFAULT_TIMEOUT: u64 = 60;
impl Entry {
pub fn command(command: impl Into<String>) -> Self {
Entry {
kind: command_type(),
command: Some(command.into()),
timeout: None,
tool: None,
}
}
pub fn dispatched(&self) -> bool {
self.kind == "command" && self.command.is_some() && self.tool.is_none()
}
}
impl HooksFile {
pub fn parse(text: &str, path: &str) -> Result<Self> {
let value: Value =
serde_json::from_str(text).map_err(|e| Error::file(path, e.to_string()))?;
let file: HooksFile = serde_json::from_value(value)
.map_err(|e| Error::file(path, format!("not a hooks file: {e}")))?;
for (event, groups) in &file.hooks {
for (g, group) in groups.iter().enumerate() {
for (i, entry) in group.hooks.iter().enumerate() {
if entry.kind == "command" && entry.command.is_none() {
return Err(Error::file(
path,
format!(
"hooks.{event}[{g}].hooks[{i}]: a command entry needs `command`"
),
));
}
}
}
}
Ok(file)
}
pub fn read(path: &Path) -> Result<Option<Self>> {
match fs::read_text(path)? {
Some(text) => Self::parse(&text, &path.display().to_string()).map(Some),
None => Ok(None),
}
}
pub fn events(&self) -> Vec<Event> {
Event::ALL
.into_iter()
.filter(|e| {
self.groups(*e)
.iter()
.any(|g| g.hooks.iter().any(Entry::dispatched))
})
.collect()
}
pub fn groups(&self, event: Event) -> &[Group] {
self.hooks
.get(wire::event_name(event))
.map_or(&[], Vec::as_slice)
}
pub fn other_events(&self) -> Vec<&str> {
self.hooks
.keys()
.map(String::as_str)
.filter(|n| wire::event_from_name(n).is_none())
.collect()
}
pub fn other_types(&self) -> usize {
self.hooks
.values()
.flatten()
.flat_map(|g| &g.hooks)
.filter(|e| e.kind != "command")
.count()
}
}
pub fn tool_part(
integration: &Integration,
tool: &str,
harness: &str,
location: &str,
) -> Result<Part> {
let hooks: Vec<_> = integration.hooks().iter().collect();
let owned = parts::owner(integration, &hooks)?;
let mut ops = Vec::new();
for event in Event::ALL {
let mut groups: Vec<(Option<&str>, Vec<Value>)> = Vec::new();
for h in hooks.iter().filter(|h| h.event() == event) {
let matcher = h.tool_kind().and_then(crate::claude::matcher);
let mut entry = serde_json::json!({ "type": "command", "command": h.command(harness), "tool": tool });
if let Some(t) = h.timeout_value() {
entry["timeout"] = parts::seconds(t);
}
match groups.iter_mut().find(|(m, _)| *m == matcher) {
Some((_, es)) => es.push(entry),
None => groups.push((matcher, vec![entry])),
}
}
let groups = groups
.into_iter()
.map(|(m, es)| match m {
Some(m) => serde_json::json!({ "matcher": m, "hooks": es }),
None => serde_json::json!({ "hooks": es }),
})
.collect();
ops.push(MergeOp::group_entries(
["hooks", wire::event_name(event)],
"hooks",
"command",
owned.clone(),
groups,
));
}
Ok(Part::merge(Item::Hooks.as_str(), location, ops))
}
#[cfg(feature = "schemars")]
pub fn schema() -> Value {
let mut schema =
serde_json::to_value(schemars::schema_for!(HooksFile)).expect("a schema serialises");
schema["title"] = "AHA hooks file".into();
schema
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use serde_json::json;
use super::*;
#[test]
fn reads_claude_codes_format() {
let f = HooksFile::parse(
&json!({"hooks": {
"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "guard.sh", "timeout": 5}]}],
"Stop": [{"hooks": [{"type": "command", "command": "t", "tool": "sokf"}]}],
"Notification": [{"hooks": [{"type": "command", "command": "n"}]}],
"PostToolUse": [{"hooks": [{"type": "prompt", "prompt": "check"}]}]
}})
.to_string(),
"h.json",
)
.unwrap();
assert_eq!(f.events(), vec![Event::PreTool]);
assert_eq!(f.groups(Event::PreTool)[0].matcher.as_deref(), Some("Bash"));
assert_eq!(f.groups(Event::PreTool)[0].hooks[0].timeout, Some(5));
assert!(f.groups(Event::SessionEnd).is_empty());
assert_eq!(f.other_events(), vec!["Notification"]);
assert_eq!(f.other_types(), 1);
for (bad, says) in [
("nope", "h.json"),
(r#"{"hooks": []}"#, "not a hooks file"),
(
r#"{"hooks": {"Stop": [{"matcher": "x"}]}}"#,
"not a hooks file",
),
(
r#"{"hooks": {"Stop": [{"hooks": [{"type": "command"}]}]}}"#,
"hooks.Stop[0].hooks[0]",
),
] {
let e = HooksFile::parse(bad, "h.json").unwrap_err().to_string();
assert!(e.contains(says), "{bad}: {e}");
}
assert_eq!(HooksFile::parse("{}", "h").unwrap(), HooksFile::default());
}
#[cfg(feature = "schemars")]
#[test]
fn the_schema_names_the_hooks_file() {
let s = schema();
assert_eq!(s["title"], "AHA hooks file");
assert!(s["properties"]["hooks"].is_object());
}
#[test]
fn a_missing_file_is_none() {
let dir = std::env::temp_dir().join(format!("aha-hooks-file-{}", std::process::id()));
assert_eq!(HooksFile::read(&dir.join("none.json")).unwrap(), None);
}
fn files() -> impl Strategy<Value = HooksFile> {
let entry = (
"[a-z ]{1,10}",
proptest::option::of(1u64..100),
proptest::option::of("[a-z]{1,5}"),
)
.prop_map(|(c, timeout, tool)| Entry {
timeout,
tool,
..Entry::command(c)
});
let group = (
proptest::option::of("[A-Za-z|]{0,8}"),
proptest::collection::vec(entry, 1..3),
)
.prop_map(|(matcher, hooks)| Group { matcher, hooks });
let event = proptest::sample::select(Event::ALL.map(wire::event_name).to_vec());
proptest::collection::btree_map(
event.prop_map(String::from),
proptest::collection::vec(group, 0..3),
0..4,
)
.prop_map(|hooks| HooksFile { hooks })
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn hooks_files_round_trip(f in files()) {
let text = serde_json::to_string(&f).unwrap();
prop_assert_eq!(&HooksFile::parse(&text, "h").unwrap(), &f);
let want: Vec<Event> = Event::ALL
.into_iter()
.filter(|e| f.groups(*e).iter().flat_map(|g| &g.hooks).any(|x| x.tool.is_none()))
.collect();
prop_assert_eq!(f.events(), want);
}
}
}