use std::path::Path;
use serde_json::Value;
pub fn write_hook_config(worktree: &Path, apm_bin: &str) -> anyhow::Result<()> {
let claude_dir = worktree.join(".claude");
std::fs::create_dir_all(&claude_dir)?;
let settings_path = claude_dir.join("settings.json");
let content = std::fs::read_to_string(&settings_path).unwrap_or_else(|_| "{}".to_string());
let mut settings: Value = serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}));
if !settings.is_object() {
settings = serde_json::json!({});
}
let hooks = settings
.as_object_mut()
.unwrap()
.entry("hooks")
.or_insert_with(|| serde_json::json!({}));
if !hooks.is_object() {
*hooks = serde_json::json!({});
}
let pre_tool_use = hooks
.as_object_mut()
.unwrap()
.entry("PreToolUse")
.or_insert_with(|| serde_json::json!([]));
if !pre_tool_use.is_array() {
*pre_tool_use = serde_json::json!([]);
}
let hook_command = format!("{} path-guard", apm_bin);
let already_present = pre_tool_use.as_array().unwrap().iter().any(|entry| {
entry
.get("hooks")
.and_then(|h| h.as_array())
.map(|arr| {
arr.iter().any(|h| {
h.get("command")
.and_then(|c| c.as_str())
.map(|c| c.ends_with("apm path-guard"))
.unwrap_or(false)
})
})
.unwrap_or(false)
});
if !already_present {
pre_tool_use.as_array_mut().unwrap().push(serde_json::json!({
"matcher": "Edit|Write|Bash",
"hooks": [{"type": "command", "command": hook_command}]
}));
}
let json_str = serde_json::to_string_pretty(&settings)?;
std::fs::write(&settings_path, json_str)?;
Ok(())
}
pub fn remove_hook_config(worktree: &Path) -> anyhow::Result<()> {
let settings_path = worktree.join(".claude").join("settings.json");
if !settings_path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(&settings_path)?;
let mut settings: Value = serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}));
if let Some(pre_tool_use) = settings
.get_mut("hooks")
.and_then(|h| h.get_mut("PreToolUse"))
.and_then(|p| p.as_array_mut())
{
pre_tool_use.retain(|entry| {
!entry
.get("hooks")
.and_then(|h| h.as_array())
.map(|arr| {
arr.iter().any(|h| {
h.get("command")
.and_then(|c| c.as_str())
.map(|c| c.ends_with("apm path-guard"))
.unwrap_or(false)
})
})
.unwrap_or(false)
});
}
let json_str = serde_json::to_string_pretty(&settings)?;
std::fs::write(&settings_path, json_str)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_hook_creates_file() {
let tmp = tempfile::tempdir().unwrap();
write_hook_config(tmp.path(), "/usr/bin/apm").unwrap();
let settings_path = tmp.path().join(".claude").join("settings.json");
assert!(settings_path.exists());
let content = std::fs::read_to_string(&settings_path).unwrap();
assert!(content.contains("apm path-guard"));
assert!(content.contains("PreToolUse"));
assert!(content.contains("Edit|Write|Bash"));
}
#[test]
fn write_hook_idempotent() {
let tmp = tempfile::tempdir().unwrap();
write_hook_config(tmp.path(), "/usr/bin/apm").unwrap();
write_hook_config(tmp.path(), "/usr/bin/apm").unwrap();
let settings_path = tmp.path().join(".claude").join("settings.json");
let content = std::fs::read_to_string(&settings_path).unwrap();
let v: serde_json::Value = serde_json::from_str(&content).unwrap();
let arr = v["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(arr.len(), 1, "hook entry should not be duplicated");
}
#[test]
fn write_hook_preserves_existing_entries() {
let tmp = tempfile::tempdir().unwrap();
let settings_path = tmp.path().join(".claude").join("settings.json");
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
let existing = serde_json::json!({
"hooks": {
"PreToolUse": [
{"matcher": "Edit", "hooks": [{"type": "command", "command": "other-hook"}]}
]
}
});
std::fs::write(&settings_path, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
write_hook_config(tmp.path(), "/usr/bin/apm").unwrap();
let content = std::fs::read_to_string(&settings_path).unwrap();
let v: serde_json::Value = serde_json::from_str(&content).unwrap();
let arr = v["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(arr.len(), 2, "existing entry must be preserved");
assert!(content.contains("other-hook"));
assert!(content.contains("apm path-guard"));
}
#[test]
fn remove_hook_removes_entry() {
let tmp = tempfile::tempdir().unwrap();
write_hook_config(tmp.path(), "/usr/bin/apm").unwrap();
remove_hook_config(tmp.path()).unwrap();
let settings_path = tmp.path().join(".claude").join("settings.json");
let content = std::fs::read_to_string(&settings_path).unwrap();
let v: serde_json::Value = serde_json::from_str(&content).unwrap();
let arr = v["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(arr.len(), 0, "hook entry should be removed");
}
#[test]
fn remove_hook_noop_when_no_file() {
let tmp = tempfile::tempdir().unwrap();
remove_hook_config(tmp.path()).unwrap();
}
#[test]
fn remove_hook_preserves_other_entries() {
let tmp = tempfile::tempdir().unwrap();
let settings_path = tmp.path().join(".claude").join("settings.json");
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
let existing = serde_json::json!({
"hooks": {
"PreToolUse": [
{"matcher": "Edit", "hooks": [{"type": "command", "command": "other-hook"}]},
{"matcher": "Edit|Write|Bash", "hooks": [{"type": "command", "command": "/usr/bin/apm path-guard"}]}
]
}
});
std::fs::write(&settings_path, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
remove_hook_config(tmp.path()).unwrap();
let content = std::fs::read_to_string(&settings_path).unwrap();
let v: serde_json::Value = serde_json::from_str(&content).unwrap();
let arr = v["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(arr.len(), 1, "other entry must be preserved");
assert!(content.contains("other-hook"));
assert!(!content.contains("apm path-guard"));
}
}