1package hooks
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "runtime"
8 "testing"
9
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12)
13
14func TestManager(t *testing.T) {
15 t.Run("discovers hooks in order", func(t *testing.T) {
16 tempDir := t.TempDir()
17 dataDir := filepath.Join(tempDir, ".crush")
18 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
19 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
20
21 // Create hooks with numeric prefixes.
22 hooks := []string{"02-second.sh", "01-first.sh", "03-third.sh"}
23 for _, name := range hooks {
24 path := filepath.Join(hooksDir, name)
25 err := os.WriteFile(path, []byte("#!/bin/bash\necho test"), 0o755)
26 require.NoError(t, err)
27 }
28
29 mgr := NewManager(tempDir, dataDir, nil)
30 discovered := mgr.ListHooks(HookPreToolUse)
31
32 assert.Len(t, discovered, 3)
33 // Should be sorted alphabetically.
34 assert.Contains(t, discovered[0], "01-first.sh")
35 assert.Contains(t, discovered[1], "02-second.sh")
36 assert.Contains(t, discovered[2], "03-third.sh")
37 })
38
39 t.Run("skips non-executable files", func(t *testing.T) {
40 tempDir := t.TempDir()
41 dataDir := filepath.Join(tempDir, ".crush")
42 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
43 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
44
45 // Create non-executable file.
46 path := filepath.Join(hooksDir, "non-executable.sh")
47 err := os.WriteFile(path, []byte("#!/bin/bash\necho test"), 0o644)
48 require.NoError(t, err)
49
50 mgr := NewManager(tempDir, dataDir, nil)
51 discovered := mgr.ListHooks(HookPreToolUse)
52
53 // On Windows, .sh files are always considered executable
54 // On Unix, non-executable files (0o644) should be skipped
55 if runtime.GOOS == "windows" {
56 assert.Len(t, discovered, 1, "On Windows, .sh files are executable regardless of permissions")
57 } else {
58 assert.Len(t, discovered, 0, "On Unix, non-executable files should be skipped")
59 }
60 })
61
62 t.Run("discovers hooks by extension on all platforms", func(t *testing.T) {
63 tempDir := t.TempDir()
64 dataDir := filepath.Join(tempDir, ".crush")
65 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
66 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
67
68 // Only .sh files are recognized as hooks.
69 // On Unix, they need execute permission. On Windows, extension is enough.
70 validHook := filepath.Join(hooksDir, "valid-hook.sh")
71 err := os.WriteFile(validHook, []byte("#!/bin/bash\necho test"), 0o755)
72 require.NoError(t, err)
73
74 // These should NOT be discovered (wrong extensions).
75 invalidFiles := []string{"hook.bat", "hook.cmd", "hook.ps1", "hook.txt"}
76 for _, name := range invalidFiles {
77 path := filepath.Join(hooksDir, name)
78 err := os.WriteFile(path, []byte("echo test"), 0o755)
79 require.NoError(t, err)
80 }
81
82 mgr := NewManager(tempDir, dataDir, nil)
83 discovered := mgr.ListHooks(HookPreToolUse)
84
85 // Only the .sh file should be discovered.
86 assert.Len(t, discovered, 1)
87 assert.Contains(t, discovered[0], "valid-hook.sh")
88 })
89
90 t.Run("executes multiple hooks and merges results", func(t *testing.T) {
91 tempDir := t.TempDir()
92 dataDir := filepath.Join(tempDir, ".crush")
93 hooksDir := filepath.Join(dataDir, "hooks", "user-prompt-submit")
94 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
95
96 // Hook 1: Adds context.
97 hook1 := filepath.Join(hooksDir, "01-add-context.sh")
98 err := os.WriteFile(hook1, []byte(`#!/bin/bash
99crush_add_context "Context from hook 1"
100`), 0o755)
101 require.NoError(t, err)
102
103 // Hook 2: Adds more context.
104 hook2 := filepath.Join(hooksDir, "02-add-more.sh")
105 err = os.WriteFile(hook2, []byte(`#!/bin/bash
106crush_add_context "Context from hook 2"
107`), 0o755)
108 require.NoError(t, err)
109
110 mgr := NewManager(tempDir, dataDir, nil)
111 ctx := context.Background()
112 result, err := mgr.ExecuteUserPromptSubmit(ctx, "test", tempDir, UserPromptSubmitData{
113 Prompt: "test prompt",
114 })
115
116 require.NoError(t, err)
117 assert.True(t, result.Continue)
118 // Contexts should be merged with \n\n separator.
119 assert.Equal(t, "Context from hook 1\n\nContext from hook 2", result.ContextContent)
120 })
121
122 t.Run("stops on first hook that sets continue=false", func(t *testing.T) {
123 tempDir := t.TempDir()
124 dataDir := filepath.Join(tempDir, ".crush")
125 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
126 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
127
128 // Hook 1: Denies.
129 hook1 := filepath.Join(hooksDir, "01-deny.sh")
130 err := os.WriteFile(hook1, []byte(`#!/bin/bash
131crush_deny "blocked"
132`), 0o755)
133 require.NoError(t, err)
134
135 // Hook 2: Should not execute.
136 hook2 := filepath.Join(hooksDir, "02-never-runs.sh")
137 err = os.WriteFile(hook2, []byte(`#!/bin/bash
138export CRUSH_MESSAGE="should not see this"
139`), 0o755)
140 require.NoError(t, err)
141
142 mgr := NewManager(tempDir, dataDir, nil)
143 ctx := context.Background()
144 result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
145
146 require.NoError(t, err)
147 assert.False(t, result.Continue)
148 assert.Equal(t, "deny", result.Permission)
149 assert.Equal(t, "blocked", result.Message)
150 assert.NotContains(t, result.Message, "should not see this")
151 })
152
153 t.Run("merges permissions with deny winning", func(t *testing.T) {
154 tempDir := t.TempDir()
155 dataDir := filepath.Join(tempDir, ".crush")
156 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
157 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
158
159 // Hook 1: Approves.
160 hook1 := filepath.Join(hooksDir, "01-approve.sh")
161 err := os.WriteFile(hook1, []byte(`#!/bin/bash
162export CRUSH_PERMISSION=approve
163`), 0o755)
164 require.NoError(t, err)
165
166 // Hook 2: Denies (should win).
167 hook2 := filepath.Join(hooksDir, "02-deny.sh")
168 err = os.WriteFile(hook2, []byte(`#!/bin/bash
169export CRUSH_PERMISSION=deny
170`), 0o755)
171 require.NoError(t, err)
172
173 mgr := NewManager(tempDir, dataDir, nil)
174 ctx := context.Background()
175 result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
176
177 require.NoError(t, err)
178 assert.Equal(t, "deny", result.Permission)
179 })
180
181 t.Run("disabled hooks are skipped", func(t *testing.T) {
182 tempDir := t.TempDir()
183 dataDir := filepath.Join(tempDir, ".crush")
184 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
185 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
186
187 // Hook 1: Should run.
188 hook1 := filepath.Join(hooksDir, "01-enabled.sh")
189 err := os.WriteFile(hook1, []byte(`#!/bin/bash
190export CRUSH_MESSAGE="enabled"
191`), 0o755)
192 require.NoError(t, err)
193
194 // Hook 2: Disabled.
195 hook2 := filepath.Join(hooksDir, "02-disabled.sh")
196 err = os.WriteFile(hook2, []byte(`#!/bin/bash
197export CRUSH_MESSAGE="disabled"
198`), 0o755)
199 require.NoError(t, err)
200
201 cfg := &Config{
202 TimeoutSeconds: 30,
203 Directories: []string{filepath.Join(dataDir, "hooks")},
204 DisableHooks: []string{"pre-tool-use/02-disabled.sh"},
205 }
206
207 mgr := NewManager(tempDir, dataDir, cfg)
208 ctx := context.Background()
209 result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
210
211 require.NoError(t, err)
212 assert.Equal(t, "enabled", result.Message)
213 })
214
215 t.Run("inline hooks are executed", func(t *testing.T) {
216 tempDir := t.TempDir()
217 dataDir := filepath.Join(tempDir, ".crush")
218
219 cfg := &Config{
220 TimeoutSeconds: 30,
221 Directories: []string{filepath.Join(dataDir, "hooks")},
222 Inline: map[string][]InlineHook{
223 "user-prompt-submit": {
224 {
225 Name: "inline-test.sh",
226 Script: `#!/bin/bash
227export CRUSH_MESSAGE="inline hook executed"
228`,
229 },
230 },
231 },
232 }
233
234 mgr := NewManager(tempDir, dataDir, cfg)
235 ctx := context.Background()
236 result, err := mgr.ExecuteUserPromptSubmit(ctx, "test", tempDir, UserPromptSubmitData{})
237
238 require.NoError(t, err)
239 assert.Equal(t, "inline hook executed", result.Message)
240 })
241
242 t.Run("returns empty result when hooks disabled", func(t *testing.T) {
243 tempDir := t.TempDir()
244 dataDir := filepath.Join(tempDir, ".crush")
245
246 cfg := &Config{}
247
248 mgr := NewManager(tempDir, dataDir, cfg)
249 ctx := context.Background()
250 result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
251
252 require.NoError(t, err)
253 assert.True(t, result.Continue)
254 assert.Empty(t, result.Message)
255 })
256
257 t.Run("returns empty result when no hooks found", func(t *testing.T) {
258 tempDir := t.TempDir()
259 dataDir := filepath.Join(tempDir, ".crush")
260
261 mgr := NewManager(tempDir, dataDir, nil)
262 ctx := context.Background()
263 result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
264
265 require.NoError(t, err)
266 assert.True(t, result.Continue)
267 })
268
269 t.Run("handles hook failure on PreToolUse by denying", func(t *testing.T) {
270 tempDir := t.TempDir()
271 dataDir := filepath.Join(tempDir, ".crush")
272 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
273 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
274
275 // Hook that fails with exit 1.
276 hook := filepath.Join(hooksDir, "01-fail.sh")
277 err := os.WriteFile(hook, []byte(`#!/bin/bash
278exit 1
279`), 0o755)
280 require.NoError(t, err)
281
282 mgr := NewManager(tempDir, dataDir, nil)
283 ctx := context.Background()
284 result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
285
286 require.NoError(t, err)
287 assert.False(t, result.Continue)
288 assert.Equal(t, "deny", result.Permission)
289 assert.Contains(t, result.Message, "Hook failed")
290 })
291
292 t.Run("caches discovered hooks", func(t *testing.T) {
293 tempDir := t.TempDir()
294 dataDir := filepath.Join(tempDir, ".crush")
295 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
296 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
297
298 hook := filepath.Join(hooksDir, "01-test.sh")
299 err := os.WriteFile(hook, []byte("#!/bin/bash\necho test"), 0o755)
300 require.NoError(t, err)
301
302 mgr := NewManager(tempDir, dataDir, nil)
303
304 // First call - discovers.
305 hooks1 := mgr.ListHooks(HookPreToolUse)
306 assert.Len(t, hooks1, 1)
307
308 // Second call - should use cache.
309 hooks2 := mgr.ListHooks(HookPreToolUse)
310 assert.Equal(t, hooks1, hooks2)
311 })
312
313 t.Run("catch-all hooks at root execute for all types", func(t *testing.T) {
314 tempDir := t.TempDir()
315 dataDir := filepath.Join(tempDir, ".crush")
316 hooksDir := filepath.Join(dataDir, "hooks")
317 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
318
319 // Create catch-all hook at root level.
320 catchAllHook := filepath.Join(hooksDir, "00-catch-all.sh")
321 err := os.WriteFile(catchAllHook, []byte(`#!/bin/bash
322export CRUSH_MESSAGE="catch-all: $CRUSH_HOOK_TYPE"
323`), 0o755)
324 require.NoError(t, err)
325
326 // Create specific hook for pre-tool-use.
327 specificDir := filepath.Join(hooksDir, "pre-tool-use")
328 require.NoError(t, os.MkdirAll(specificDir, 0o755))
329 specificHook := filepath.Join(specificDir, "01-specific.sh")
330 err = os.WriteFile(specificHook, []byte(`#!/bin/bash
331export CRUSH_MESSAGE="$CRUSH_MESSAGE; specific hook"
332`), 0o755)
333 require.NoError(t, err)
334
335 mgr := NewManager(tempDir, dataDir, nil)
336
337 // Test PreToolUse - should execute both catch-all and specific.
338 ctx := context.Background()
339 result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
340
341 require.NoError(t, err)
342 assert.Contains(t, result.Message, "catch-all: pre-tool-use")
343 assert.Contains(t, result.Message, "specific hook")
344
345 // Test UserPromptSubmit - should only execute catch-all.
346 result2, err := mgr.ExecuteUserPromptSubmit(ctx, "test", tempDir, UserPromptSubmitData{})
347
348 require.NoError(t, err)
349 assert.Equal(t, "catch-all: user-prompt-submit", result2.Message)
350 assert.NotContains(t, result2.Message, "specific hook")
351 })
352
353 t.Run("passes environment variables from config to hooks", func(t *testing.T) {
354 tempDir := t.TempDir()
355 dataDir := filepath.Join(tempDir, ".crush")
356 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
357 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
358
359 // Hook that checks for custom environment variables.
360 hook := filepath.Join(hooksDir, "01-check-env.sh")
361 err := os.WriteFile(hook, []byte(`#!/bin/bash
362if [ "$CUSTOM_API_KEY" = "test-key-123" ] && [ "$CUSTOM_ENV" = "production" ]; then
363 export CRUSH_MESSAGE="config environment variables received"
364else
365 export CRUSH_MESSAGE="environment variables missing"
366fi
367`), 0o755)
368 require.NoError(t, err)
369
370 cfg := &Config{
371 TimeoutSeconds: 30,
372 Directories: []string{filepath.Join(dataDir, "hooks")},
373 Environment: map[string]string{
374 "CUSTOM_API_KEY": "test-key-123",
375 "CUSTOM_ENV": "production",
376 },
377 }
378
379 mgr := NewManager(tempDir, dataDir, cfg)
380 ctx := context.Background()
381 result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
382
383 require.NoError(t, err)
384 assert.Equal(t, "config environment variables received", result.Message)
385 })
386
387 t.Run("handles inline hook write failure gracefully", func(t *testing.T) {
388 tempDir := t.TempDir()
389 // Use a read-only directory as dataDir to force write failure.
390 readOnlyDir := filepath.Join(tempDir, "readonly")
391 require.NoError(t, os.MkdirAll(readOnlyDir, 0o555)) // Read-only
392
393 cfg := &Config{
394 TimeoutSeconds: 30,
395 Directories: []string{filepath.Join(readOnlyDir, "hooks")},
396 Inline: map[string][]InlineHook{
397 "pre-tool-use": {
398 {
399 Name: "inline-fail.sh",
400 Script: "#!/bin/bash\necho test",
401 },
402 },
403 },
404 }
405
406 mgr := NewManager(tempDir, readOnlyDir, cfg)
407 ctx := context.Background()
408
409 // Should not error even though inline hook write fails.
410 // The hook will be skipped and logged.
411 result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
412
413 require.NoError(t, err)
414 assert.True(t, result.Continue) // Should continue despite write failure
415 })
416
417 t.Run("handles hooks directory read failure gracefully", func(t *testing.T) {
418 tempDir := t.TempDir()
419 dataDir := filepath.Join(tempDir, ".crush")
420 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
421 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
422
423 // Create a hook file.
424 hook := filepath.Join(hooksDir, "01-test.sh")
425 require.NoError(t, os.WriteFile(hook, []byte("#!/bin/bash\necho test"), 0o755))
426
427 mgr := NewManager(tempDir, dataDir, nil)
428
429 // Make directory unreadable after discovery to test error path.
430 // Note: This is tricky to test reliably cross-platform.
431 // On some systems, we can't make a directory unreadable if we own it.
432 // We'll test that hooks are cached and re-discovery works.
433 hooks1 := mgr.ListHooks(HookPreToolUse)
434 assert.Len(t, hooks1, 1)
435
436 // Add another hook.
437 hook2 := filepath.Join(hooksDir, "02-test.sh")
438 require.NoError(t, os.WriteFile(hook2, []byte("#!/bin/bash\necho test2"), 0o755))
439
440 // Should still return cached hooks (won't see new one).
441 hooks2 := mgr.ListHooks(HookPreToolUse)
442 assert.Len(t, hooks2, 1, "hooks are cached, new hook not seen")
443 })
444
445 t.Run("approve permission is set when accumulated is empty", func(t *testing.T) {
446 tempDir := t.TempDir()
447 dataDir := filepath.Join(tempDir, ".crush")
448 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
449 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
450
451 // Single hook that approves.
452 hook := filepath.Join(hooksDir, "01-approve.sh")
453 require.NoError(t, os.WriteFile(hook, []byte(`#!/bin/bash
454export CRUSH_PERMISSION=approve
455export CRUSH_MESSAGE="auto-approved"
456`), 0o755))
457
458 mgr := NewManager(tempDir, dataDir, nil)
459 ctx := context.Background()
460 result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
461
462 require.NoError(t, err)
463 assert.Equal(t, "approve", result.Permission)
464 assert.Equal(t, "auto-approved", result.Message)
465 })
466}