hooks_test.go

  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}