1package hooks
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/charmbracelet/crush/internal/config"
12 "github.com/stretchr/testify/require"
13)
14
15func TestHookExecutor_Execute(t *testing.T) {
16 t.Parallel()
17
18 tempDir := t.TempDir()
19
20 tests := []struct {
21 name string
22 config config.HookConfig
23 hookCtx HookContext
24 wantErr bool
25 }{
26 {
27 name: "simple command hook",
28 config: config.HookConfig{
29 config.PreToolUse: []config.HookMatcher{
30 {
31 Matcher: "bash",
32 Hooks: []config.Hook{
33 {
34 Type: "command",
35 Command: "echo 'hook executed'",
36 },
37 },
38 },
39 },
40 },
41 hookCtx: HookContext{
42 EventType: config.PreToolUse,
43 ToolName: "bash",
44 },
45 },
46 {
47 name: "hook with jq processing",
48 config: config.HookConfig{
49 config.PreToolUse: []config.HookMatcher{
50 {
51 Matcher: "bash",
52 Hooks: []config.Hook{
53 {
54 Type: "command",
55 Command: `jq -r '.tool_name'`,
56 },
57 },
58 },
59 },
60 },
61 hookCtx: HookContext{
62 EventType: config.PreToolUse,
63 ToolName: "bash",
64 },
65 },
66 {
67 name: "hook that writes to file",
68 config: config.HookConfig{
69 config.PostToolUse: []config.HookMatcher{
70 {
71 Matcher: "*",
72 Hooks: []config.Hook{
73 {
74 Type: "command",
75 Command: `jq -r '"\(.tool_name): \(.tool_result)"' >> ` + filepath.Join(tempDir, "hook-log.txt"),
76 },
77 },
78 },
79 },
80 },
81 hookCtx: HookContext{
82 EventType: config.PostToolUse,
83 ToolName: "edit",
84 ToolResult: "file edited successfully",
85 },
86 },
87 {
88 name: "hook with timeout",
89 config: config.HookConfig{
90 config.Stop: []config.HookMatcher{
91 {
92 Hooks: []config.Hook{
93 {
94 Type: "command",
95 Command: "sleep 0.1 && echo 'done'",
96 Timeout: ptrInt(1),
97 },
98 },
99 },
100 },
101 },
102 hookCtx: HookContext{
103 EventType: config.Stop,
104 },
105 },
106 {
107 name: "failed hook command",
108 config: config.HookConfig{
109 config.PreToolUse: []config.HookMatcher{
110 {
111 Matcher: "bash",
112 Hooks: []config.Hook{
113 {
114 Type: "command",
115 Command: "exit 1",
116 },
117 },
118 },
119 },
120 },
121 hookCtx: HookContext{
122 EventType: config.PreToolUse,
123 ToolName: "bash",
124 },
125 wantErr: true,
126 },
127 {
128 name: "hook with single quote in JSON",
129 config: config.HookConfig{
130 config.PostToolUse: []config.HookMatcher{
131 {
132 Matcher: "edit",
133 Hooks: []config.Hook{
134 {
135 Type: "command",
136 Command: `jq -r '.tool_result'`,
137 },
138 },
139 },
140 },
141 },
142 hookCtx: HookContext{
143 EventType: config.PostToolUse,
144 ToolName: "edit",
145 ToolResult: "it's a test with 'quotes'",
146 },
147 },
148 }
149
150 for _, tt := range tests {
151 t.Run(tt.name, func(t *testing.T) {
152 t.Parallel()
153
154 executor := NewExecutor(tt.config, tempDir)
155 require.NotNil(t, executor)
156
157 ctx := context.Background()
158 err := executor.Execute(ctx, tt.hookCtx)
159
160 if tt.wantErr {
161 require.Error(t, err)
162 } else {
163 require.NoError(t, err)
164 }
165 })
166 }
167}
168
169func TestHookExecutor_MatcherApplies(t *testing.T) {
170 t.Parallel()
171
172 tempDir := t.TempDir()
173 executor := NewExecutor(config.HookConfig{}, tempDir)
174
175 tests := []struct {
176 name string
177 matcher config.HookMatcher
178 ctx HookContext
179 want bool
180 }{
181 {
182 name: "empty matcher matches all",
183 matcher: config.HookMatcher{
184 Matcher: "",
185 },
186 ctx: HookContext{
187 EventType: config.PreToolUse,
188 ToolName: "bash",
189 },
190 want: true,
191 },
192 {
193 name: "wildcard matcher matches all",
194 matcher: config.HookMatcher{
195 Matcher: "*",
196 },
197 ctx: HookContext{
198 EventType: config.PreToolUse,
199 ToolName: "edit",
200 },
201 want: true,
202 },
203 {
204 name: "specific tool matcher matches",
205 matcher: config.HookMatcher{
206 Matcher: "bash",
207 },
208 ctx: HookContext{
209 EventType: config.PreToolUse,
210 ToolName: "bash",
211 },
212 want: true,
213 },
214 {
215 name: "specific tool matcher doesn't match different tool",
216 matcher: config.HookMatcher{
217 Matcher: "bash",
218 },
219 ctx: HookContext{
220 EventType: config.PreToolUse,
221 ToolName: "edit",
222 },
223 want: false,
224 },
225 {
226 name: "pipe-separated matcher matches first tool",
227 matcher: config.HookMatcher{
228 Matcher: "edit|write|multiedit",
229 },
230 ctx: HookContext{
231 EventType: config.PreToolUse,
232 ToolName: "edit",
233 },
234 want: true,
235 },
236 {
237 name: "pipe-separated matcher matches middle tool",
238 matcher: config.HookMatcher{
239 Matcher: "edit|write|multiedit",
240 },
241 ctx: HookContext{
242 EventType: config.PreToolUse,
243 ToolName: "write",
244 },
245 want: true,
246 },
247 {
248 name: "pipe-separated matcher matches last tool",
249 matcher: config.HookMatcher{
250 Matcher: "edit|write|multiedit",
251 },
252 ctx: HookContext{
253 EventType: config.PreToolUse,
254 ToolName: "multiedit",
255 },
256 want: true,
257 },
258 {
259 name: "pipe-separated matcher doesn't match different tool",
260 matcher: config.HookMatcher{
261 Matcher: "edit|write|multiedit",
262 },
263 ctx: HookContext{
264 EventType: config.PreToolUse,
265 ToolName: "bash",
266 },
267 want: false,
268 },
269 {
270 name: "pipe-separated matcher with spaces",
271 matcher: config.HookMatcher{
272 Matcher: "edit | write | multiedit",
273 },
274 ctx: HookContext{
275 EventType: config.PreToolUse,
276 ToolName: "write",
277 },
278 want: true,
279 },
280 {
281 name: "non-tool event matches empty matcher",
282 matcher: config.HookMatcher{
283 Matcher: "",
284 },
285 ctx: HookContext{
286 EventType: config.Stop,
287 },
288 want: true,
289 },
290 }
291
292 for _, tt := range tests {
293 t.Run(tt.name, func(t *testing.T) {
294 t.Parallel()
295
296 got := executor.matcherApplies(tt.matcher, tt.ctx)
297 require.Equal(t, tt.want, got)
298 })
299 }
300}
301
302func TestHookExecutor_Timeout(t *testing.T) {
303 t.Parallel()
304
305 tempDir := t.TempDir()
306 shortTimeout := 1
307
308 hookConfig := config.HookConfig{
309 config.Stop: []config.HookMatcher{
310 {
311 Hooks: []config.Hook{
312 {
313 Type: "command",
314 Command: "sleep 10",
315 Timeout: &shortTimeout,
316 },
317 },
318 },
319 },
320 }
321
322 executor := NewExecutor(hookConfig, tempDir)
323 ctx := context.Background()
324
325 start := time.Now()
326 err := executor.Execute(ctx, HookContext{
327 EventType: config.Stop,
328 })
329 duration := time.Since(start)
330
331 require.Error(t, err)
332 require.Less(t, duration, 2*time.Second)
333}
334
335func TestHookExecutor_MultipleHooks(t *testing.T) {
336 t.Parallel()
337
338 tempDir := t.TempDir()
339 logFile := filepath.Join(tempDir, "multi-hook-log.txt")
340
341 hookConfig := config.HookConfig{
342 config.PreToolUse: []config.HookMatcher{
343 {
344 Matcher: "bash",
345 Hooks: []config.Hook{
346 {
347 Type: "command",
348 Command: "echo 'hook1' >> " + logFile,
349 },
350 {
351 Type: "command",
352 Command: "echo 'hook2' >> " + logFile,
353 },
354 {
355 Type: "command",
356 Command: "echo 'hook3' >> " + logFile,
357 },
358 },
359 },
360 },
361 }
362
363 executor := NewExecutor(hookConfig, tempDir)
364 ctx := context.Background()
365
366 err := executor.Execute(ctx, HookContext{
367 EventType: config.PreToolUse,
368 ToolName: "bash",
369 })
370
371 require.NoError(t, err)
372
373 content, err := os.ReadFile(logFile)
374 require.NoError(t, err)
375
376 lines := strings.Split(strings.TrimSpace(string(content)), "\n")
377 require.Len(t, lines, 3)
378 require.Equal(t, "hook1", lines[0])
379 require.Equal(t, "hook2", lines[1])
380 require.Equal(t, "hook3", lines[2])
381}
382
383func TestHookExecutor_PipeSeparatedMatcher(t *testing.T) {
384 t.Parallel()
385
386 tempDir := t.TempDir()
387 logFile := filepath.Join(tempDir, "pipe-matcher-log.txt")
388
389 hookConfig := config.HookConfig{
390 config.PostToolUse: []config.HookMatcher{
391 {
392 Matcher: "edit|write|multiedit",
393 Hooks: []config.Hook{
394 {
395 Type: "command",
396 Command: `jq -r '.tool_name' >> ` + logFile,
397 },
398 },
399 },
400 },
401 }
402
403 executor := NewExecutor(hookConfig, tempDir)
404 ctx := context.Background()
405
406 // Test that edit triggers the hook
407 err := executor.Execute(ctx, HookContext{
408 EventType: config.PostToolUse,
409 ToolName: "edit",
410 })
411 require.NoError(t, err)
412
413 // Test that write triggers the hook
414 err = executor.Execute(ctx, HookContext{
415 EventType: config.PostToolUse,
416 ToolName: "write",
417 })
418 require.NoError(t, err)
419
420 // Test that multiedit triggers the hook
421 err = executor.Execute(ctx, HookContext{
422 EventType: config.PostToolUse,
423 ToolName: "multiedit",
424 })
425 require.NoError(t, err)
426
427 // Test that bash does NOT trigger the hook
428 err = executor.Execute(ctx, HookContext{
429 EventType: config.PostToolUse,
430 ToolName: "bash",
431 })
432 require.NoError(t, err)
433
434 // Verify only the matching tools were logged
435 content, err := os.ReadFile(logFile)
436 require.NoError(t, err)
437
438 lines := strings.Split(strings.TrimSpace(string(content)), "\n")
439 require.Len(t, lines, 3)
440 require.Equal(t, "edit", lines[0])
441 require.Equal(t, "write", lines[1])
442 require.Equal(t, "multiedit", lines[2])
443}
444
445func TestHookExecutor_ContextCancellation(t *testing.T) {
446 t.Parallel()
447
448 tempDir := t.TempDir()
449 logFile := filepath.Join(tempDir, "cancel-log.txt")
450
451 hookConfig := config.HookConfig{
452 config.PreToolUse: []config.HookMatcher{
453 {
454 Matcher: "bash",
455 Hooks: []config.Hook{
456 {
457 Type: "command",
458 Command: "echo 'hook1' >> " + logFile,
459 },
460 {
461 Type: "command",
462 Command: "sleep 10 && echo 'hook2' >> " + logFile,
463 },
464 },
465 },
466 },
467 }
468
469 executor := NewExecutor(hookConfig, tempDir)
470 ctx, cancel := context.WithCancel(context.Background())
471
472 go func() {
473 time.Sleep(100 * time.Millisecond)
474 cancel()
475 }()
476
477 err := executor.Execute(ctx, HookContext{
478 EventType: config.PreToolUse,
479 ToolName: "bash",
480 })
481
482 require.Error(t, err)
483 require.ErrorIs(t, err, context.Canceled)
484}
485
486func ptrInt(i int) *int {
487 return &i
488}