use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use super::event::HookEvent;
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
pub enum ConfigOrigin {
#[default]
Local,
Remote,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct AiHooksConfig {
pub agents: Vec<AgentTarget>,
pub scope: HookScope,
pub allow_custom_commands: bool,
#[serde(rename = "hook", default)]
pub hooks: Vec<HookEntry>,
#[serde(default)]
pub library_sources: Vec<crate::library_registry::LibrarySource>,
#[serde(skip)]
pub origin: ConfigOrigin,
}
impl AiHooksConfig {
pub fn is_empty(&self) -> bool {
self.agents.is_empty() || self.hooks.is_empty()
}
pub fn unique_agents(&self) -> Vec<AgentTarget> {
let set: BTreeSet<_> = self.agents.iter().copied().collect();
set.into_iter().collect()
}
pub fn agents_bitset(&self) -> u8 {
self.agents
.iter()
.fold(0u8, |acc, a| acc | (1 << (*a as u8)))
}
}
pub use crate::agents::Agent as AgentTarget;
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HookScope {
#[default]
User,
Project,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct HookEntry {
#[serde(rename = "use")]
pub use_library: Option<String>,
pub name: Option<String>,
pub event: Option<HookEvent>,
pub matcher: Option<String>,
pub command: Option<String>,
pub command_windows: Option<String>,
pub timeout_ms: Option<u64>,
#[serde(default)]
pub agents: Vec<AgentTarget>,
#[serde(default)]
pub params: toml::Table,
}
impl HookEntry {
pub fn is_library(&self) -> bool {
self.use_library.is_some() && self.command.is_none()
}
pub fn is_custom_command(&self) -> bool {
self.command.is_some() && self.use_library.is_none()
}
pub fn identifier(&self) -> String {
if let Some(ref name) = self.name {
name.clone()
} else if let Some(ref lib) = self.use_library {
lib.clone()
} else {
"unnamed".to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal_library_entry() {
let toml = r#"
agents = ["claude-code"]
[[hook]]
use = "block-rm-rf"
"#;
let cfg: AiHooksConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.agents, vec![AgentTarget::ClaudeCode]);
assert!(cfg.hooks[0].is_library());
assert_eq!(cfg.hooks[0].identifier(), "block-rm-rf");
assert_eq!(cfg.origin, ConfigOrigin::Local);
}
#[test]
fn parses_inline_custom_entry() {
let toml = r#"
agents = ["cursor"]
allow_custom_commands = true
[[hook]]
name = "warn-on-write"
event = "pre_tool_use"
matcher = "Bash"
command = "echo hi"
"#;
let cfg: AiHooksConfig = toml::from_str(toml).unwrap();
assert!(cfg.allow_custom_commands);
assert!(cfg.hooks[0].is_custom_command());
}
#[test]
fn agent_slug_round_trip() {
for a in AgentTarget::ALL {
assert_eq!(AgentTarget::from_slug(a.slug()), Some(*a));
}
}
#[test]
fn unique_agents_dedups_and_sorts() {
let cfg = AiHooksConfig {
agents: vec![
AgentTarget::Cursor,
AgentTarget::ClaudeCode,
AgentTarget::Cursor,
],
..Default::default()
};
let unique = cfg.unique_agents();
assert_eq!(unique, vec![AgentTarget::ClaudeCode, AgentTarget::Cursor]);
}
#[test]
fn agents_bitset_membership() {
let cfg = AiHooksConfig {
agents: vec![AgentTarget::Cursor, AgentTarget::Cline],
..Default::default()
};
let bits = cfg.agents_bitset();
assert_ne!(bits & (1 << AgentTarget::Cursor as u8), 0);
assert_ne!(bits & (1 << AgentTarget::Cline as u8), 0);
assert_eq!(bits & (1 << AgentTarget::ClaudeCode as u8), 0);
}
#[test]
fn rejects_unknown_fields() {
let toml = r#"
agents = ["claude-code"]
mystery = true
"#;
let err = toml::from_str::<AiHooksConfig>(toml).unwrap_err();
assert!(err.to_string().contains("mystery"));
}
#[test]
fn rejects_merge_strategy_field_now_deleted() {
let toml = r#"
agents = ["claude-code"]
merge_strategy = "replace"
"#;
assert!(toml::from_str::<AiHooksConfig>(toml).is_err());
}
#[test]
fn library_entry_with_command_override_is_neither_library_nor_custom() {
let entry = HookEntry {
use_library: Some("block-rm-rf".to_string()),
command: Some("echo hi".to_string()),
..Default::default()
};
assert!(!entry.is_library());
assert!(!entry.is_custom_command());
}
}