Skip to main content

apm_core/wrapper/
hook_config.rs

1use std::path::Path;
2use serde_json::Value;
3
4/// Write a `PreToolUse` hook entry to `<worktree>/.claude/settings.json` that
5/// intercepts `Edit`, `Write`, and `Bash` tool calls by running `apm path-guard`.
6///
7/// The function is idempotent: if an entry with the same command already exists,
8/// it is not duplicated.
9pub 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    // Ensure settings is an object
18    if !settings.is_object() {
19        settings = serde_json::json!({});
20    }
21
22    // Navigate to hooks -> PreToolUse, creating as needed
23    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    // Check for an existing entry with this command (idempotent)
46    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
74/// Remove the `apm path-guard` hook entry from `<worktree>/.claude/settings.json`.
75///
76/// Called after the worker process exits to avoid leaving stale hooks in long-lived
77/// worktrees. If the file does not exist, this is a no-op.
78pub 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        // Should not error when settings.json doesn't exist
182        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}