crush_info_test.go

  1package tools
  2
  3import (
  4	"errors"
  5	"os"
  6	"path/filepath"
  7	"strings"
  8	"testing"
  9	"time"
 10
 11	"charm.land/catwalk/pkg/catwalk"
 12	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 13	"github.com/charmbracelet/crush/internal/config"
 14	"github.com/charmbracelet/crush/internal/csync"
 15	"github.com/charmbracelet/crush/internal/lsp"
 16	"github.com/stretchr/testify/require"
 17)
 18
 19func TestCrushInfo_MinimalConfig(t *testing.T) {
 20	t.Parallel()
 21
 22	cfg := config.NewTestStore(&config.Config{
 23		Providers: csync.NewMap[string, config.ProviderConfig](),
 24	})
 25	output := buildCrushInfo(cfg, nil)
 26	require.NotContains(t, output, "[providers]")
 27	require.NotContains(t, output, "[lsp]")
 28	require.NotContains(t, output, "[mcp]")
 29	require.NotContains(t, output, "[permissions]")
 30	require.NotContains(t, output, "[tools]")
 31}
 32
 33func TestCrushInfo_ConfigFiles(t *testing.T) {
 34	t.Parallel()
 35
 36	cfg := config.NewTestStore(
 37		&config.Config{Providers: csync.NewMap[string, config.ProviderConfig]()},
 38		"/home/user/.config/crush/crush.json",
 39		"/project/.crush/crush.json",
 40	)
 41	output := buildCrushInfo(cfg, nil)
 42	require.Contains(t, output, "[config_files]")
 43	require.Contains(t, output, "/home/user/.config/crush/crush.json")
 44	require.Contains(t, output, "/project/.crush/crush.json")
 45}
 46
 47func TestCrushInfo_Models(t *testing.T) {
 48	t.Parallel()
 49
 50	cfg := config.NewTestStore(&config.Config{
 51		Models: map[config.SelectedModelType]config.SelectedModel{
 52			config.SelectedModelTypeLarge: {Model: "claude-sonnet-4-20250514", Provider: "anthropic"},
 53			config.SelectedModelTypeSmall: {Model: "claude-haiku-3-20250307", Provider: "anthropic"},
 54		},
 55		Providers: csync.NewMap[string, config.ProviderConfig](),
 56	})
 57	output := buildCrushInfo(cfg, nil)
 58	require.Contains(t, output, "[model]")
 59	require.Contains(t, output, "large = claude-sonnet-4-20250514 (anthropic)")
 60	require.Contains(t, output, "small = claude-haiku-3-20250307 (anthropic)")
 61}
 62
 63func TestCrushInfo_Providers(t *testing.T) {
 64	t.Parallel()
 65
 66	providers := csync.NewMap[string, config.ProviderConfig]()
 67	providers.Set("openai", config.ProviderConfig{Models: make([]catwalk.Model, 8)})
 68	providers.Set("anthropic", config.ProviderConfig{Models: make([]catwalk.Model, 12)})
 69
 70	cfg := config.NewTestStore(&config.Config{Providers: providers})
 71	output := buildCrushInfo(cfg, nil)
 72	require.Contains(t, output, "[providers]")
 73	anthropicIdx := strings.Index(output, "anthropic = enabled")
 74	openaiIdx := strings.Index(output, "openai = enabled")
 75	require.Greater(t, anthropicIdx, -1)
 76	require.Greater(t, openaiIdx, -1)
 77	require.Less(t, anthropicIdx, openaiIdx, "anthropic should appear before openai")
 78	require.Contains(t, output, "anthropic = enabled (12 models)")
 79	require.Contains(t, output, "openai = enabled (8 models)")
 80}
 81
 82func TestCrushInfo_DisabledProvidersOmitted(t *testing.T) {
 83	t.Parallel()
 84
 85	providers := csync.NewMap[string, config.ProviderConfig]()
 86	providers.Set("openai", config.ProviderConfig{Disable: true, Models: make([]catwalk.Model, 8)})
 87	providers.Set("anthropic", config.ProviderConfig{Models: make([]catwalk.Model, 12)})
 88
 89	cfg := config.NewTestStore(&config.Config{Providers: providers})
 90	output := buildCrushInfo(cfg, nil)
 91	require.Contains(t, output, "anthropic = enabled")
 92	require.NotContains(t, output, "openai")
 93}
 94
 95func TestCrushInfo_LSPStates(t *testing.T) {
 96	t.Parallel()
 97
 98	mgr := lsp.NewManager(config.NewTestStore(&config.Config{
 99		Providers: csync.NewMap[string, config.ProviderConfig](),
100	}))
101	readyClient := &lsp.Client{}
102	readyClient.SetServerState(lsp.StateReady)
103	mgr.Clients().Set("gopls", readyClient)
104
105	errorClient := &lsp.Client{}
106	errorClient.SetServerState(lsp.StateError)
107	mgr.Clients().Set("pyright", errorClient)
108
109	cfg := config.NewTestStore(&config.Config{Providers: csync.NewMap[string, config.ProviderConfig]()})
110	output := buildCrushInfo(cfg, mgr)
111	require.Contains(t, output, "[lsp]")
112	require.Contains(t, output, "gopls = ready")
113	require.Contains(t, output, "pyright = error")
114	goplsIdx := strings.Index(output, "gopls = ready")
115	pyrightIdx := strings.Index(output, "pyright = error")
116	require.Less(t, goplsIdx, pyrightIdx, "gopls should appear before pyright")
117}
118
119func TestCrushInfo_MCPStates(t *testing.T) {
120	t.Parallel()
121
122	connectedAt := time.Date(2025, 1, 15, 15, 4, 5, 0, time.UTC)
123	states := map[string]mcp.ClientInfo{
124		"github": {
125			Name:        "github",
126			State:       mcp.StateConnected,
127			Counts:      mcp.Counts{Tools: 42, Resources: 7},
128			ConnectedAt: connectedAt,
129		},
130		"filesystem": {
131			Name:  "filesystem",
132			State: mcp.StateError,
133			Error: errors.New("connection refused"),
134		},
135	}
136
137	cfg := config.NewTestStore(&config.Config{
138		Providers: csync.NewMap[string, config.ProviderConfig](),
139	})
140
141	var b strings.Builder
142	writeMCP(&b, states, cfg)
143	output := b.String()
144	require.Contains(t, output, "[mcp]")
145	require.Contains(t, output, "filesystem = error: connection refused")
146	require.Contains(t, output, "github = connected (42 tools, 7 resources) since 15:04:05")
147	filesystemIdx := strings.Index(output, "filesystem")
148	githubIdx := strings.Index(output, "github")
149	require.Less(t, filesystemIdx, githubIdx, "filesystem should appear before github")
150}
151
152func TestCrushInfo_YoloMode(t *testing.T) {
153	t.Parallel()
154
155	cfg := config.NewTestStore(&config.Config{
156		Providers:   csync.NewMap[string, config.ProviderConfig](),
157		Permissions: &config.Permissions{},
158	})
159	cfg.Overrides().SkipPermissionRequests = true
160
161	output := buildCrushInfo(cfg, nil)
162	require.Contains(t, output, "[permissions]")
163	require.Contains(t, output, "mode = yolo")
164}
165
166func TestCrushInfo_AllowedTools(t *testing.T) {
167	t.Parallel()
168
169	cfg := config.NewTestStore(&config.Config{
170		Providers:   csync.NewMap[string, config.ProviderConfig](),
171		Permissions: &config.Permissions{AllowedTools: []string{"edit:write", "bash"}},
172	})
173
174	output := buildCrushInfo(cfg, nil)
175	require.Contains(t, output, "[permissions]")
176	require.Contains(t, output, "allowed_tools = bash, edit:write")
177}
178
179func TestCrushInfo_DisabledTools(t *testing.T) {
180	t.Parallel()
181
182	cfg := config.NewTestStore(&config.Config{
183		Providers: csync.NewMap[string, config.ProviderConfig](),
184		Options:   &config.Options{DisabledTools: []string{"sourcegraph", "agentic_fetch"}},
185	})
186
187	output := buildCrushInfo(cfg, nil)
188	require.Contains(t, output, "[tools]")
189	require.Contains(t, output, "disabled = agentic_fetch, sourcegraph")
190}
191
192func TestCrushInfo_Options(t *testing.T) {
193	t.Parallel()
194
195	cfg := config.NewTestStore(&config.Config{
196		Providers: csync.NewMap[string, config.ProviderConfig](),
197		Options: &config.Options{
198			DataDirectory:        "/Users/user/project/.crush",
199			Debug:                true,
200			DisableAutoSummarize: true,
201		},
202	})
203
204	output := buildCrushInfo(cfg, nil)
205	require.Contains(t, output, "[options]")
206	require.Contains(t, output, "auto_lsp = true")
207	require.Contains(t, output, "auto_summarize = false")
208	require.Contains(t, output, "data_directory = /Users/user/project/.crush")
209	require.Contains(t, output, "debug = true")
210}
211
212func TestCrushInfo_AutoSummarizeInversion(t *testing.T) {
213	t.Parallel()
214
215	cfgFalse := config.NewTestStore(&config.Config{
216		Providers: csync.NewMap[string, config.ProviderConfig](),
217		Options:   &config.Options{DisableAutoSummarize: true},
218	})
219	outputFalse := buildCrushInfo(cfgFalse, nil)
220	require.Contains(t, outputFalse, "auto_summarize = false")
221
222	cfgTrue := config.NewTestStore(&config.Config{
223		Providers: csync.NewMap[string, config.ProviderConfig](),
224		Options:   &config.Options{DisableAutoSummarize: false},
225	})
226	outputTrue := buildCrushInfo(cfgTrue, nil)
227	require.Contains(t, outputTrue, "auto_summarize = true")
228}
229
230func TestCrushInfo_NoSecrets(t *testing.T) {
231	t.Parallel()
232
233	providers := csync.NewMap[string, config.ProviderConfig]()
234	providers.Set("openai", config.ProviderConfig{
235		APIKey: "sk-super-secret-key-12345",
236		Models: make([]catwalk.Model, 8),
237	})
238
239	cfg := config.NewTestStore(&config.Config{Providers: providers})
240	output := buildCrushInfo(cfg, nil)
241	require.NotContains(t, output, "sk-super-secret-key-12345")
242	require.NotContains(t, output, "secret")
243	require.Contains(t, output, "openai = enabled (8 models)")
244}
245
246func TestCrushInfo_DeterministicOrdering(t *testing.T) {
247	t.Parallel()
248
249	providers := csync.NewMap[string, config.ProviderConfig]()
250	providers.Set("zebra", config.ProviderConfig{Models: make([]catwalk.Model, 1)})
251	providers.Set("alpha", config.ProviderConfig{Models: make([]catwalk.Model, 2)})
252	providers.Set("middle", config.ProviderConfig{Models: make([]catwalk.Model, 3)})
253
254	states := map[string]mcp.ClientInfo{
255		"z-mcp": {Name: "z-mcp", State: mcp.StateConnected, Counts: mcp.Counts{Tools: 1}},
256		"a-mcp": {Name: "a-mcp", State: mcp.StateConnected, Counts: mcp.Counts{Tools: 2}},
257	}
258
259	cfg := config.NewTestStore(&config.Config{
260		Providers: providers,
261		Options:   &config.Options{DisabledTools: []string{"z-tool", "a-tool"}},
262		Permissions: &config.Permissions{
263			AllowedTools: []string{"z-perm", "a-perm"},
264		},
265	})
266	cfg.Overrides().SkipPermissionRequests = true
267
268	// Test MCP ordering via writeMCP directly.
269	var mcpBuf strings.Builder
270	writeMCP(&mcpBuf, states, cfg)
271	mcpOutput := mcpBuf.String()
272	aMcpIdx := strings.Index(mcpOutput, "a-mcp = connected")
273	zMcpIdx := strings.Index(mcpOutput, "z-mcp = connected")
274	require.Less(t, aMcpIdx, zMcpIdx)
275
276	output := buildCrushInfo(cfg, nil)
277
278	alphaIdx := strings.Index(output, "alpha = enabled")
279	middleIdx := strings.Index(output, "middle = enabled")
280	zebraIdx := strings.Index(output, "zebra = enabled")
281	require.Less(t, alphaIdx, middleIdx)
282	require.Less(t, middleIdx, zebraIdx)
283
284	require.Contains(t, output, "disabled = a-tool, z-tool")
285	require.Contains(t, output, "allowed_tools = a-perm, z-perm")
286}
287
288func TestCrushInfo_EmptySectionsOmitted(t *testing.T) {
289	t.Parallel()
290
291	cfg := config.NewTestStore(&config.Config{
292		Providers:   csync.NewMap[string, config.ProviderConfig](),
293		Permissions: &config.Permissions{},
294		Options:     &config.Options{},
295	})
296
297	output := buildCrushInfo(cfg, nil)
298	require.NotContains(t, output, "[tools]")
299	require.NotContains(t, output, "[permissions]")
300	require.NotContains(t, output, "[lsp]")
301	require.NotContains(t, output, "[mcp]")
302}
303
304func TestCrushInfo_ConfigStaleness_Clean(t *testing.T) {
305	t.Parallel()
306
307	dir := t.TempDir()
308	configPath := filepath.Join(dir, "crush.json")
309	require.NoError(t, os.WriteFile(configPath, []byte(`{}`), 0o600))
310
311	store := config.NewTestStore(&config.Config{
312		Providers: csync.NewMap[string, config.ProviderConfig](),
313	}, configPath)
314
315	// Capture snapshot (normally done in Load)
316	store.CaptureStalenessSnapshot([]string{configPath})
317
318	output := buildCrushInfo(store, nil)
319	require.Contains(t, output, "[config]")
320	require.Contains(t, output, "dirty = false")
321	require.NotContains(t, output, "changed_paths")
322	require.NotContains(t, output, "missing_paths")
323}
324
325func TestCrushInfo_ConfigStaleness_Dirty(t *testing.T) {
326	t.Parallel()
327
328	dir := t.TempDir()
329	configPath := filepath.Join(dir, "crush.json")
330	require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": false}`), 0o600))
331
332	store := config.NewTestStore(&config.Config{
333		Providers: csync.NewMap[string, config.ProviderConfig](),
334	}, configPath)
335
336	// Capture initial snapshot
337	store.CaptureStalenessSnapshot([]string{configPath})
338
339	// Modify file to trigger dirty state
340	time.Sleep(10 * time.Millisecond)
341	require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
342
343	output := buildCrushInfo(store, nil)
344	require.Contains(t, output, "[config]")
345	require.Contains(t, output, "dirty = true")
346	require.Contains(t, output, "changed_paths")
347	require.Contains(t, output, configPath)
348}
349
350func TestCrushInfo_ConfigStaleness_MissingPath(t *testing.T) {
351	t.Parallel()
352
353	dir := t.TempDir()
354	configPath := filepath.Join(dir, "crush.json")
355	require.NoError(t, os.WriteFile(configPath, []byte(`{}`), 0o600))
356
357	store := config.NewTestStore(&config.Config{
358		Providers: csync.NewMap[string, config.ProviderConfig](),
359	}, configPath)
360
361	// Capture initial snapshot
362	store.CaptureStalenessSnapshot([]string{configPath})
363
364	// Delete file to trigger missing state
365	require.NoError(t, os.Remove(configPath))
366
367	output := buildCrushInfo(store, nil)
368	require.Contains(t, output, "[config]")
369	require.Contains(t, output, "dirty = true")
370	require.Contains(t, output, "missing_paths")
371	require.Contains(t, output, configPath)
372}