apm_core/wrapper/
hook_config.rs1use std::path::Path;
2use serde_json::Value;
3
4pub fn write_hook_config(worktree: &Path, apm_bin: &str) -> anyhow::Result<()> {
10 let claude_dir = worktree.join(".claude");
11 std::fs::create_dir_all(&claude_dir)?;
12
13 let settings_path = claude_dir.join("settings.json");
14 let content = std::fs::read_to_string(&settings_path).unwrap_or_else(|_| "{}".to_string());
15 let mut settings: Value = serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}));
16
17 if !settings.is_object() {
19 settings = serde_json::json!({});
20 }
21
22 let hooks = settings
24 .as_object_mut()
25 .unwrap()
26 .entry("hooks")
27 .or_insert_with(|| serde_json::json!({}));
28
29 if !hooks.is_object() {
30 *hooks = serde_json::json!({});
31 }
32
33 let pre_tool_use = hooks
34 .as_object_mut()
35 .unwrap()
36 .entry("PreToolUse")
37 .or_insert_with(|| serde_json::json!([]));
38
39 if !pre_tool_use.is_array() {
40 *pre_tool_use = serde_json::json!([]);
41 }
42
43 let hook_command = format!("{} path-guard", apm_bin);
44
45 let already_present = pre_tool_use.as_array().unwrap().iter().any(|entry| {
47 entry
48 .get("hooks")
49 .and_then(|h| h.as_array())
50 .map(|arr| {
51 arr.iter().any(|h| {
52 h.get("command")
53 .and_then(|c| c.as_str())
54 .map(|c| c.ends_with("apm path-guard"))
55 .unwrap_or(false)
56 })
57 })
58 .unwrap_or(false)
59 });
60
61 if !already_present {
62 pre_tool_use.as_array_mut().unwrap().push(serde_json::json!({
63 "matcher": "Edit|Write|Bash",
64 "hooks": [{"type": "command", "command": hook_command}]
65 }));
66 }
67
68 let json_str = serde_json::to_string_pretty(&settings)?;
69 std::fs::write(&settings_path, json_str)?;
70
71 Ok(())
72}
73
74pub fn remove_hook_config(worktree: &Path) -> anyhow::Result<()> {
79 let settings_path = worktree.join(".claude").join("settings.json");
80 if !settings_path.exists() {
81 return Ok(());
82 }
83
84 let content = std::fs::read_to_string(&settings_path)?;
85 let mut settings: Value = serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}));
86
87 if let Some(pre_tool_use) = settings
88 .get_mut("hooks")
89 .and_then(|h| h.get_mut("PreToolUse"))
90 .and_then(|p| p.as_array_mut())
91 {
92 pre_tool_use.retain(|entry| {
93 !entry
94 .get("hooks")
95 .and_then(|h| h.as_array())
96 .map(|arr| {
97 arr.iter().any(|h| {
98 h.get("command")
99 .and_then(|c| c.as_str())
100 .map(|c| c.ends_with("apm path-guard"))
101 .unwrap_or(false)
102 })
103 })
104 .unwrap_or(false)
105 });
106 }
107
108 let json_str = serde_json::to_string_pretty(&settings)?;
109 std::fs::write(&settings_path, json_str)?;
110
111 Ok(())
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn write_hook_creates_file() {
120 let tmp = tempfile::tempdir().unwrap();
121 write_hook_config(tmp.path(), "/usr/bin/apm").unwrap();
122 let settings_path = tmp.path().join(".claude").join("settings.json");
123 assert!(settings_path.exists());
124 let content = std::fs::read_to_string(&settings_path).unwrap();
125 assert!(content.contains("apm path-guard"));
126 assert!(content.contains("PreToolUse"));
127 assert!(content.contains("Edit|Write|Bash"));
128 }
129
130 #[test]
131 fn write_hook_idempotent() {
132 let tmp = tempfile::tempdir().unwrap();
133 write_hook_config(tmp.path(), "/usr/bin/apm").unwrap();
134 write_hook_config(tmp.path(), "/usr/bin/apm").unwrap();
135 let settings_path = tmp.path().join(".claude").join("settings.json");
136 let content = std::fs::read_to_string(&settings_path).unwrap();
137 let v: serde_json::Value = serde_json::from_str(&content).unwrap();
138 let arr = v["hooks"]["PreToolUse"].as_array().unwrap();
139 assert_eq!(arr.len(), 1, "hook entry should not be duplicated");
140 }
141
142 #[test]
143 fn write_hook_preserves_existing_entries() {
144 let tmp = tempfile::tempdir().unwrap();
145 let settings_path = tmp.path().join(".claude").join("settings.json");
146 std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
147 let existing = serde_json::json!({
148 "hooks": {
149 "PreToolUse": [
150 {"matcher": "Edit", "hooks": [{"type": "command", "command": "other-hook"}]}
151 ]
152 }
153 });
154 std::fs::write(&settings_path, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
155
156 write_hook_config(tmp.path(), "/usr/bin/apm").unwrap();
157
158 let content = std::fs::read_to_string(&settings_path).unwrap();
159 let v: serde_json::Value = serde_json::from_str(&content).unwrap();
160 let arr = v["hooks"]["PreToolUse"].as_array().unwrap();
161 assert_eq!(arr.len(), 2, "existing entry must be preserved");
162 assert!(content.contains("other-hook"));
163 assert!(content.contains("apm path-guard"));
164 }
165
166 #[test]
167 fn remove_hook_removes_entry() {
168 let tmp = tempfile::tempdir().unwrap();
169 write_hook_config(tmp.path(), "/usr/bin/apm").unwrap();
170 remove_hook_config(tmp.path()).unwrap();
171 let settings_path = tmp.path().join(".claude").join("settings.json");
172 let content = std::fs::read_to_string(&settings_path).unwrap();
173 let v: serde_json::Value = serde_json::from_str(&content).unwrap();
174 let arr = v["hooks"]["PreToolUse"].as_array().unwrap();
175 assert_eq!(arr.len(), 0, "hook entry should be removed");
176 }
177
178 #[test]
179 fn remove_hook_noop_when_no_file() {
180 let tmp = tempfile::tempdir().unwrap();
181 remove_hook_config(tmp.path()).unwrap();
183 }
184
185 #[test]
186 fn remove_hook_preserves_other_entries() {
187 let tmp = tempfile::tempdir().unwrap();
188 let settings_path = tmp.path().join(".claude").join("settings.json");
189 std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
190 let existing = serde_json::json!({
191 "hooks": {
192 "PreToolUse": [
193 {"matcher": "Edit", "hooks": [{"type": "command", "command": "other-hook"}]},
194 {"matcher": "Edit|Write|Bash", "hooks": [{"type": "command", "command": "/usr/bin/apm path-guard"}]}
195 ]
196 }
197 });
198 std::fs::write(&settings_path, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
199
200 remove_hook_config(tmp.path()).unwrap();
201
202 let content = std::fs::read_to_string(&settings_path).unwrap();
203 let v: serde_json::Value = serde_json::from_str(&content).unwrap();
204 let arr = v["hooks"]["PreToolUse"].as_array().unwrap();
205 assert_eq!(arr.len(), 1, "other entry must be preserved");
206 assert!(content.contains("other-hook"));
207 assert!(!content.contains("apm path-guard"));
208 }
209}