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