use crate::error::{RegistryError, Result};
use crate::types::{HookEvent, RegistryEntry};
use chrono::Local;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Registry {
schema_version: u32,
agent_name: String,
hooks: Vec<RegistryEntry>,
}
pub fn registry_path() -> PathBuf {
let data_dir = dirs::data_dir().expect("Failed to determine XDG data directory");
data_dir.join("claude-hooks").join("registry.jsonc")
}
pub fn read_registry() -> Result<Vec<RegistryEntry>> {
let path = registry_path();
if !path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&path).map_err(RegistryError::Io)?;
let stripped = json_comments::StripComments::new(content.as_bytes());
let stripped_bytes: Vec<u8> = std::io::Read::bytes(stripped)
.collect::<std::io::Result<Vec<u8>>>()
.map_err(|e| RegistryError::Parse(e.to_string()))?;
let stripped_str = String::from_utf8(stripped_bytes)
.map_err(|e| RegistryError::Parse(format!("Invalid UTF-8: {}", e)))?;
let registry: Registry =
serde_json::from_str(&stripped_str).map_err(|e| RegistryError::Parse(e.to_string()))?;
Ok(registry.hooks)
}
pub fn write_registry(entries: Vec<RegistryEntry>) -> Result<()> {
let path = registry_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| RegistryError::Write(format!("Failed to create directory: {}", e)))?;
}
let registry = Registry {
schema_version: 1,
agent_name: "claude-code".to_string(),
hooks: entries,
};
let timestamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
let temp_path = path.with_file_name(format!("registry.jsonc.tmp.{}", timestamp));
let json = serde_json::to_string_pretty(®istry)
.map_err(|e| RegistryError::Write(format!("Failed to serialize: {}", e)))?;
let content = format!("// claude-hooks registry\n{}", json);
fs::write(&temp_path, content)
.map_err(|e| RegistryError::Write(format!("Failed to write temp file: {}", e)))?;
let file = fs::File::open(&temp_path)
.map_err(|e| RegistryError::Write(format!("Failed to open temp file for fsync: {}", e)))?;
file.sync_all()
.map_err(|e| RegistryError::Write(format!("Failed to fsync: {}", e)))?;
fs::rename(&temp_path, &path).map_err(|e| {
RegistryError::Write(format!(
"Failed to rename {} to {}: {}",
temp_path.display(),
path.display(),
e
))
})?;
Ok(())
}
pub fn add_entry(mut entries: Vec<RegistryEntry>, entry: RegistryEntry) -> Vec<RegistryEntry> {
entries.push(entry);
entries
}
pub fn remove_entry(
mut entries: Vec<RegistryEntry>,
event: HookEvent,
command: &str,
) -> Vec<RegistryEntry> {
entries.retain(|entry| !entry.matches(event, command));
entries
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::HookEvent;
use serial_test::serial;
use std::env;
use tempfile::tempdir;
fn setup_test_env() -> tempfile::TempDir {
let dir = tempdir().expect("failed to create temp dir");
env::set_var("HOME", dir.path());
dir
}
#[test]
#[serial(home)]
fn test_registry_path() {
let _dir = setup_test_env();
let path = registry_path();
assert!(
path.to_string_lossy().contains("claude-hooks"),
"Path should contain 'claude-hooks'"
);
assert!(
path.to_string_lossy().ends_with("registry.jsonc"),
"Path should end with 'registry.jsonc'"
);
}
#[test]
fn test_add_entry() {
let entries = Vec::new();
let entry = RegistryEntry {
event: HookEvent::Stop,
matcher: None,
r#type: "command".to_string(),
command: "/path/to/stop.sh".to_string(),
timeout: None,
r#async: None,
scope: "user".to_string(),
enabled: true,
added_at: "20260203-143022".to_string(),
installed_by: "acd".to_string(),
description: None,
reason: None,
optional: None,
};
let result = add_entry(entries, entry.clone());
assert_eq!(result.len(), 1);
assert_eq!(result[0].command, "/path/to/stop.sh");
}
#[test]
fn test_remove_entry() {
let entry1 = RegistryEntry {
event: HookEvent::Stop,
matcher: None,
r#type: "command".to_string(),
command: "/path/to/stop.sh".to_string(),
timeout: None,
r#async: None,
scope: "user".to_string(),
enabled: true,
added_at: "20260203-143022".to_string(),
installed_by: "acd".to_string(),
description: None,
reason: None,
optional: None,
};
let entry2 = RegistryEntry {
event: HookEvent::SessionStart,
command: "/path/to/start.sh".to_string(),
..entry1.clone()
};
let entries = vec![entry1, entry2];
let result = remove_entry(entries, HookEvent::Stop, "/path/to/stop.sh");
assert_eq!(result.len(), 1);
assert_eq!(result[0].event, HookEvent::SessionStart);
}
#[test]
fn test_remove_entry_multiple_matches() {
let entry1 = RegistryEntry {
event: HookEvent::Stop,
matcher: None,
r#type: "command".to_string(),
command: "/path/to/stop.sh".to_string(),
timeout: None,
r#async: None,
scope: "user".to_string(),
enabled: true,
added_at: "20260203-143022".to_string(),
installed_by: "acd".to_string(),
description: None,
reason: None,
optional: None,
};
let entry2 = entry1.clone();
let entry3 = RegistryEntry {
event: HookEvent::SessionStart,
command: "/path/to/start.sh".to_string(),
..entry1.clone()
};
let entries = vec![entry1, entry2, entry3];
let result = remove_entry(entries, HookEvent::Stop, "/path/to/stop.sh");
assert_eq!(result.len(), 1);
assert_eq!(result[0].event, HookEvent::SessionStart);
}
#[test]
fn test_jsonc_parsing_with_comments() {
let jsonc = r#"
{
// This is a comment
"schema_version": 1,
"agent_name": "claude-code",
"hooks": []
}
"#;
let stripped = json_comments::StripComments::new(jsonc.as_bytes());
let stripped_bytes: Vec<u8> = std::io::Read::bytes(stripped)
.collect::<std::io::Result<Vec<u8>>>()
.expect("Failed to read stripped content");
let stripped_str = String::from_utf8(stripped_bytes).expect("Invalid UTF-8");
let registry: Registry =
serde_json::from_str(&stripped_str).expect("Failed to parse JSON");
assert_eq!(registry.schema_version, 1);
assert_eq!(registry.agent_name, "claude-code");
}
#[test]
#[serial(home)]
fn test_write_and_read_registry() {
let _dir = setup_test_env();
let entries = vec![RegistryEntry {
event: HookEvent::Stop,
matcher: None,
r#type: "command".to_string(),
command: "/path/to/test-stop.sh".to_string(),
timeout: Some(600),
r#async: None,
scope: "user".to_string(),
enabled: true,
added_at: "20260203-143022".to_string(),
installed_by: "test".to_string(),
description: Some("Test hook".to_string()),
reason: Some("Testing".to_string()),
optional: Some(false),
}];
write_registry(entries.clone()).expect("write should succeed");
let read_entries = read_registry().expect("read should succeed");
assert_eq!(read_entries.len(), 1);
assert_eq!(read_entries[0].command, "/path/to/test-stop.sh");
assert_eq!(read_entries[0].event, HookEvent::Stop);
}
#[test]
#[serial(home)]
fn test_read_nonexistent_registry() {
let _dir = setup_test_env();
let result = read_registry();
assert!(result.is_ok());
assert_eq!(result.expect("should be ok").len(), 0, "should return empty vec");
}
#[test]
#[serial(home)]
fn test_registry_roundtrip_preserves_metadata() {
let _dir = setup_test_env();
let original_entries = vec![
RegistryEntry {
event: HookEvent::Stop,
matcher: None,
r#type: "command".to_string(),
command: "/path/to/test-stop-roundtrip.sh".to_string(),
timeout: Some(600),
r#async: Some(false),
scope: "user".to_string(),
enabled: true,
added_at: "20260203-143022".to_string(),
installed_by: "test".to_string(),
description: Some("Stop hook".to_string()),
reason: Some("For testing".to_string()),
optional: Some(false),
},
RegistryEntry {
event: HookEvent::SessionStart,
matcher: None,
r#type: "command".to_string(),
command: "/path/to/test-start-roundtrip.sh".to_string(),
timeout: None,
r#async: None,
scope: "user".to_string(),
enabled: true,
added_at: "20260203-143023".to_string(),
installed_by: "test".to_string(),
description: None,
reason: None,
optional: None,
},
];
write_registry(original_entries.clone()).expect("write failed");
let read_entries = read_registry().expect("read failed");
assert_eq!(read_entries.len(), 2);
assert_eq!(read_entries[0].event, HookEvent::Stop);
assert_eq!(read_entries[0].command, "/path/to/test-stop-roundtrip.sh");
assert_eq!(read_entries[0].timeout, Some(600));
assert_eq!(read_entries[0].description, Some("Stop hook".to_string()));
assert_eq!(read_entries[1].event, HookEvent::SessionStart);
assert_eq!(read_entries[1].command, "/path/to/test-start-roundtrip.sh");
assert_eq!(read_entries[1].timeout, None);
}
}