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(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)))
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[repr(u8)]
pub enum AgentTarget {
ClaudeCode = 0,
Cursor = 1,
Codex = 2,
Windsurf = 3,
Cline = 4,
Continue = 5,
}
impl AgentTarget {
pub const ALL: &'static [AgentTarget] = &[
AgentTarget::ClaudeCode,
AgentTarget::Cursor,
AgentTarget::Codex,
AgentTarget::Windsurf,
AgentTarget::Cline,
AgentTarget::Continue,
];
pub const COUNT: usize = 6;
pub fn slug(self) -> &'static str {
match self {
AgentTarget::ClaudeCode => "claude-code",
AgentTarget::Cursor => "cursor",
AgentTarget::Codex => "codex",
AgentTarget::Windsurf => "windsurf",
AgentTarget::Cline => "cline",
AgentTarget::Continue => "continue",
}
}
#[allow(dead_code)]
pub fn from_slug(slug: &str) -> Option<AgentTarget> {
AgentTarget::ALL
.iter()
.copied()
.find(|a| a.slug().eq_ignore_ascii_case(slug))
}
}
impl std::fmt::Display for AgentTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.slug())
}
}
#[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());
}
}