reload_hooks_test.go

  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}