1package config_test
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "github.com/charmbracelet/crush/internal/config"
10 "github.com/charmbracelet/crush/internal/hooks"
11 "github.com/stretchr/testify/require"
12)
13
14// TestReloadFromDisk_CompilesHookMatchers is a regression test for a bug
15// where ReloadFromDisk dropped the compiled matcher regex on every hook,
16// causing a matcher like "^bash$" to match every tool call after any
17// SetConfigField-triggered reload.
18//
19// The assertion is phrased in terms of observable Runner behavior (not
20// internal field presence) so it stays valid if the Runner later owns
21// matcher compilation itself.
22func TestReloadFromDisk_CompilesHookMatchers(t *testing.T) {
23 // No t.Parallel(): we Setenv HOME/XDG_CONFIG_HOME to isolate from the
24 // developer's real global config, which may define its own hooks.
25 isolated := t.TempDir()
26 t.Setenv("HOME", isolated)
27 t.Setenv("XDG_CONFIG_HOME", filepath.Join(isolated, ".config"))
28 t.Setenv("XDG_DATA_HOME", filepath.Join(isolated, ".local", "share"))
29
30 workDir := t.TempDir()
31 dataDir := t.TempDir()
32 configPath := filepath.Join(workDir, "crush.json")
33 cfgJSON := `{
34 "hooks": {
35 "PreToolUse": [
36 {"matcher": "^bash$", "command": "exit 0"}
37 ]
38 }
39 }`
40 require.NoError(t, os.WriteFile(configPath, []byte(cfgJSON), 0o600))
41
42 store, err := config.Load(workDir, dataDir, false)
43 require.NoError(t, err)
44
45 // Sanity: hook filtering works immediately after Load.
46 assertHookFilters(t, store)
47
48 require.NoError(t, store.ReloadFromDisk(context.Background()))
49
50 // The actual regression check: filtering must still work after a
51 // reload, not silently collapse to match-everything.
52 assertHookFilters(t, store)
53}
54
55// assertHookFilters builds a Runner from the store's current hooks and
56// verifies the "^bash$" matcher rejects a non-bash tool while accepting
57// bash.
58func assertHookFilters(t *testing.T, store *config.ConfigStore) {
59 t.Helper()
60 preHooks := store.Config().Hooks[hooks.EventPreToolUse]
61 require.Len(t, preHooks, 1)
62
63 runner := hooks.NewRunner(preHooks, t.TempDir(), t.TempDir())
64
65 nonMatch, err := runner.Run(context.Background(), hooks.EventPreToolUse, "sess", "view", `{}`)
66 require.NoError(t, err)
67 require.Equal(t, 0, nonMatch.HookCount, "view must not match ^bash$ matcher")
68
69 match, err := runner.Run(context.Background(), hooks.EventPreToolUse, "sess", "bash", `{}`)
70 require.NoError(t, err)
71 require.Equal(t, 1, match.HookCount, "bash must match ^bash$ matcher")
72}
73
74// TestSetConfigField_AutoReload_PreservesHookMatcherFiltering verifies the
75// dominant real-world trigger path: config writes call autoReload,
76// autoReload calls ReloadFromDisk, and hook matching must remain correct.
77func TestSetConfigField_AutoReload_PreservesHookMatcherFiltering(t *testing.T) {
78 isolated := t.TempDir()
79 t.Setenv("HOME", isolated)
80 t.Setenv("XDG_CONFIG_HOME", filepath.Join(isolated, ".config"))
81 t.Setenv("XDG_DATA_HOME", filepath.Join(isolated, ".local", "share"))
82
83 workDir := t.TempDir()
84 dataDir := t.TempDir()
85 configPath := filepath.Join(workDir, "crush.json")
86 cfgJSON := `{
87 "hooks": {
88 "PreToolUse": [
89 {"matcher": "^bash$", "command": "exit 0"}
90 ]
91 }
92 }`
93 require.NoError(t, os.WriteFile(configPath, []byte(cfgJSON), 0o600))
94
95 store, err := config.Load(workDir, dataDir, false)
96 require.NoError(t, err)
97 assertHookFilters(t, store)
98
99 require.NoError(t, store.SetConfigField(config.ScopeGlobal, "options.debug", true))
100
101 assertHookFilters(t, store)
102}