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/charmbracelet/crush/internal/skills"
 17	"github.com/stretchr/testify/require"
 18)
 19
 20func TestCrushInfo_MinimalConfig(t *testing.T) {
 21	t.Parallel()
 22
 23	cfg := config.NewTestStore(&config.Config{
 24		Providers: csync.NewMap[string, config.ProviderConfig](),
 25	})
 26	output := buildCrushInfo(cfg, nil, nil, nil, nil)
 27	require.NotContains(t, output, "[providers]")
 28	require.NotContains(t, output, "[lsp]")
 29	require.NotContains(t, output, "[mcp]")
 30	require.NotContains(t, output, "[permissions]")
 31	require.NotContains(t, output, "[tools]")
 32}
 33
 34func TestCrushInfo_ConfigFiles(t *testing.T) {
 35	t.Parallel()
 36
 37	cfg := config.NewTestStore(
 38		&config.Config{Providers: csync.NewMap[string, config.ProviderConfig]()},
 39		"/home/user/.config/crush/crush.json",
 40		"/project/.crush/crush.json",
 41	)
 42	output := buildCrushInfo(cfg, nil, nil, nil, nil)
 43	require.Contains(t, output, "[config_files]")
 44	require.Contains(t, output, "/home/user/.config/crush/crush.json")
 45	require.Contains(t, output, "/project/.crush/crush.json")
 46}
 47
 48func TestCrushInfo_Models(t *testing.T) {
 49	t.Parallel()
 50
 51	cfg := config.NewTestStore(&config.Config{
 52		Models: map[config.SelectedModelType]config.SelectedModel{
 53			config.SelectedModelTypeLarge: {Model: "claude-sonnet-4-20250514", Provider: "anthropic"},
 54			config.SelectedModelTypeSmall: {Model: "claude-haiku-3-20250307", Provider: "anthropic"},
 55		},
 56		Providers: csync.NewMap[string, config.ProviderConfig](),
 57	})
 58	output := buildCrushInfo(cfg, nil, nil, nil, nil)
 59	require.Contains(t, output, "[model]")
 60	require.Contains(t, output, "large = claude-sonnet-4-20250514 (anthropic)")
 61	require.Contains(t, output, "small = claude-haiku-3-20250307 (anthropic)")
 62}
 63
 64func TestCrushInfo_Providers(t *testing.T) {
 65	t.Parallel()
 66
 67	providers := csync.NewMap[string, config.ProviderConfig]()
 68	providers.Set("openai", config.ProviderConfig{Models: make([]catwalk.Model, 8)})
 69	providers.Set("anthropic", config.ProviderConfig{Models: make([]catwalk.Model, 12)})
 70
 71	cfg := config.NewTestStore(&config.Config{Providers: providers})
 72	output := buildCrushInfo(cfg, nil, nil, nil, nil)
 73	require.Contains(t, output, "[providers]")
 74	anthropicIdx := strings.Index(output, "anthropic = enabled")
 75	openaiIdx := strings.Index(output, "openai = enabled")
 76	require.Greater(t, anthropicIdx, -1)
 77	require.Greater(t, openaiIdx, -1)
 78	require.Less(t, anthropicIdx, openaiIdx, "anthropic should appear before openai")
 79	require.Contains(t, output, "anthropic = enabled (12 models)")
 80	require.Contains(t, output, "openai = enabled (8 models)")
 81}
 82
 83func TestCrushInfo_DisabledProvidersOmitted(t *testing.T) {
 84	t.Parallel()
 85
 86	providers := csync.NewMap[string, config.ProviderConfig]()
 87	providers.Set("openai", config.ProviderConfig{Disable: true, Models: make([]catwalk.Model, 8)})
 88	providers.Set("anthropic", config.ProviderConfig{Models: make([]catwalk.Model, 12)})
 89
 90	cfg := config.NewTestStore(&config.Config{Providers: providers})
 91	output := buildCrushInfo(cfg, nil, nil, nil, nil)
 92	require.Contains(t, output, "anthropic = enabled")
 93	require.NotContains(t, output, "openai")
 94}
 95
 96func TestCrushInfo_LSPStates(t *testing.T) {
 97	t.Parallel()
 98
 99	mgr := lsp.NewManager(config.NewTestStore(&config.Config{
100		Providers: csync.NewMap[string, config.ProviderConfig](),
101	}))
102	readyClient := &lsp.Client{}
103	readyClient.SetServerState(lsp.StateReady)
104	mgr.Clients().Set("gopls", readyClient)
105
106	errorClient := &lsp.Client{}
107	errorClient.SetServerState(lsp.StateError)
108	mgr.Clients().Set("pyright", errorClient)
109
110	cfg := config.NewTestStore(&config.Config{Providers: csync.NewMap[string, config.ProviderConfig]()})
111	output := buildCrushInfo(cfg, mgr, nil, nil, nil)
112	require.Contains(t, output, "[lsp]")
113	require.Contains(t, output, "gopls = ready")
114	require.Contains(t, output, "pyright = error")
115	goplsIdx := strings.Index(output, "gopls = ready")
116	pyrightIdx := strings.Index(output, "pyright = error")
117	require.Less(t, goplsIdx, pyrightIdx, "gopls should appear before pyright")
118}
119
120func TestCrushInfo_MCPStates(t *testing.T) {
121	t.Parallel()
122
123	connectedAt := time.Date(2025, 1, 15, 15, 4, 5, 0, time.UTC)
124	states := map[string]mcp.ClientInfo{
125		"github": {
126			Name:        "github",
127			State:       mcp.StateConnected,
128			Counts:      mcp.Counts{Tools: 42, Resources: 7},
129			ConnectedAt: connectedAt,
130		},
131		"filesystem": {
132			Name:  "filesystem",
133			State: mcp.StateError,
134			Error: errors.New("connection refused"),
135		},
136	}
137
138	cfg := config.NewTestStore(&config.Config{
139		Providers: csync.NewMap[string, config.ProviderConfig](),
140	})
141
142	var b strings.Builder
143	writeMCP(&b, states, cfg)
144	output := b.String()
145	require.Contains(t, output, "[mcp]")
146	require.Contains(t, output, "filesystem = error: connection refused")
147	require.Contains(t, output, "github = connected (42 tools, 7 resources) since 15:04:05")
148	filesystemIdx := strings.Index(output, "filesystem")
149	githubIdx := strings.Index(output, "github")
150	require.Less(t, filesystemIdx, githubIdx, "filesystem should appear before github")
151}
152
153func TestCrushInfo_YoloMode(t *testing.T) {
154	t.Parallel()
155
156	cfg := config.NewTestStore(&config.Config{
157		Providers:   csync.NewMap[string, config.ProviderConfig](),
158		Permissions: &config.Permissions{},
159	})
160	cfg.Overrides().SkipPermissionRequests = true
161
162	output := buildCrushInfo(cfg, nil, nil, nil, nil)
163	require.Contains(t, output, "[permissions]")
164	require.Contains(t, output, "mode = yolo")
165}
166
167func TestCrushInfo_AllowedTools(t *testing.T) {
168	t.Parallel()
169
170	cfg := config.NewTestStore(&config.Config{
171		Providers:   csync.NewMap[string, config.ProviderConfig](),
172		Permissions: &config.Permissions{AllowedTools: []string{"edit:write", "bash"}},
173	})
174
175	output := buildCrushInfo(cfg, nil, nil, nil, nil)
176	require.Contains(t, output, "[permissions]")
177	require.Contains(t, output, "allowed_tools = bash, edit:write")
178}
179
180func TestCrushInfo_DisabledTools(t *testing.T) {
181	t.Parallel()
182
183	cfg := config.NewTestStore(&config.Config{
184		Providers: csync.NewMap[string, config.ProviderConfig](),
185		Options:   &config.Options{DisabledTools: []string{"sourcegraph", "agentic_fetch"}},
186	})
187
188	output := buildCrushInfo(cfg, nil, nil, nil, nil)
189	require.Contains(t, output, "[tools]")
190	require.Contains(t, output, "disabled = agentic_fetch, sourcegraph")
191}
192
193func TestCrushInfo_Options(t *testing.T) {
194	t.Parallel()
195
196	cfg := config.NewTestStore(&config.Config{
197		Providers: csync.NewMap[string, config.ProviderConfig](),
198		Options: &config.Options{
199			DataDirectory:        "/Users/user/project/.crush",
200			Debug:                true,
201			DisableAutoSummarize: true,
202		},
203	})
204
205	output := buildCrushInfo(cfg, nil, nil, nil, nil)
206	require.Contains(t, output, "[options]")
207	require.Contains(t, output, "auto_lsp = true")
208	require.Contains(t, output, "auto_summarize = false")
209	require.Contains(t, output, "data_directory = /Users/user/project/.crush")
210	require.Contains(t, output, "debug = true")
211}
212
213func TestCrushInfo_AutoSummarizeInversion(t *testing.T) {
214	t.Parallel()
215
216	cfgFalse := config.NewTestStore(&config.Config{
217		Providers: csync.NewMap[string, config.ProviderConfig](),
218		Options:   &config.Options{DisableAutoSummarize: true},
219	})
220	outputFalse := buildCrushInfo(cfgFalse, nil, nil, nil, nil)
221	require.Contains(t, outputFalse, "auto_summarize = false")
222
223	cfgTrue := config.NewTestStore(&config.Config{
224		Providers: csync.NewMap[string, config.ProviderConfig](),
225		Options:   &config.Options{DisableAutoSummarize: false},
226	})
227	outputTrue := buildCrushInfo(cfgTrue, nil, nil, nil, nil)
228	require.Contains(t, outputTrue, "auto_summarize = true")
229}
230
231func TestCrushInfo_NoSecrets(t *testing.T) {
232	t.Parallel()
233
234	providers := csync.NewMap[string, config.ProviderConfig]()
235	providers.Set("openai", config.ProviderConfig{
236		APIKey: "sk-super-secret-key-12345",
237		Models: make([]catwalk.Model, 8),
238	})
239
240	cfg := config.NewTestStore(&config.Config{Providers: providers})
241	output := buildCrushInfo(cfg, nil, nil, nil, nil)
242	require.NotContains(t, output, "sk-super-secret-key-12345")
243	require.NotContains(t, output, "secret")
244	require.Contains(t, output, "openai = enabled (8 models)")
245}
246
247func TestCrushInfo_DeterministicOrdering(t *testing.T) {
248	t.Parallel()
249
250	providers := csync.NewMap[string, config.ProviderConfig]()
251	providers.Set("zebra", config.ProviderConfig{Models: make([]catwalk.Model, 1)})
252	providers.Set("alpha", config.ProviderConfig{Models: make([]catwalk.Model, 2)})
253	providers.Set("middle", config.ProviderConfig{Models: make([]catwalk.Model, 3)})
254
255	states := map[string]mcp.ClientInfo{
256		"z-mcp": {Name: "z-mcp", State: mcp.StateConnected, Counts: mcp.Counts{Tools: 1}},
257		"a-mcp": {Name: "a-mcp", State: mcp.StateConnected, Counts: mcp.Counts{Tools: 2}},
258	}
259
260	cfg := config.NewTestStore(&config.Config{
261		Providers: providers,
262		Options:   &config.Options{DisabledTools: []string{"z-tool", "a-tool"}},
263		Permissions: &config.Permissions{
264			AllowedTools: []string{"z-perm", "a-perm"},
265		},
266	})
267	cfg.Overrides().SkipPermissionRequests = true
268
269	// Test MCP ordering via writeMCP directly.
270	var mcpBuf strings.Builder
271	writeMCP(&mcpBuf, states, cfg)
272	mcpOutput := mcpBuf.String()
273	aMcpIdx := strings.Index(mcpOutput, "a-mcp = connected")
274	zMcpIdx := strings.Index(mcpOutput, "z-mcp = connected")
275	require.Less(t, aMcpIdx, zMcpIdx)
276
277	output := buildCrushInfo(cfg, nil, nil, nil, nil)
278
279	alphaIdx := strings.Index(output, "alpha = enabled")
280	middleIdx := strings.Index(output, "middle = enabled")
281	zebraIdx := strings.Index(output, "zebra = enabled")
282	require.Less(t, alphaIdx, middleIdx)
283	require.Less(t, middleIdx, zebraIdx)
284
285	require.Contains(t, output, "disabled = a-tool, z-tool")
286	require.Contains(t, output, "allowed_tools = a-perm, z-perm")
287}
288
289func TestCrushInfo_EmptySectionsOmitted(t *testing.T) {
290	t.Parallel()
291
292	cfg := config.NewTestStore(&config.Config{
293		Providers:   csync.NewMap[string, config.ProviderConfig](),
294		Permissions: &config.Permissions{},
295		Options:     &config.Options{},
296	})
297
298	output := buildCrushInfo(cfg, nil, nil, nil, nil)
299	require.NotContains(t, output, "[tools]")
300	require.NotContains(t, output, "[permissions]")
301	require.NotContains(t, output, "[lsp]")
302	require.NotContains(t, output, "[mcp]")
303	require.NotContains(t, output, "[skills]")
304}
305
306func TestCrushInfo_ConfigStaleness_Clean(t *testing.T) {
307	t.Parallel()
308
309	dir := t.TempDir()
310	configPath := filepath.Join(dir, "crush.json")
311	require.NoError(t, os.WriteFile(configPath, []byte(`{}`), 0o600))
312
313	store := config.NewTestStore(&config.Config{
314		Providers: csync.NewMap[string, config.ProviderConfig](),
315	}, configPath)
316
317	// Capture snapshot (normally done in Load)
318	store.CaptureStalenessSnapshot([]string{configPath})
319
320	output := buildCrushInfo(store, nil, nil, nil, nil)
321	require.Contains(t, output, "[config]")
322	require.Contains(t, output, "dirty = false")
323	require.NotContains(t, output, "changed_paths")
324	require.NotContains(t, output, "missing_paths")
325}
326
327func TestCrushInfo_ConfigStaleness_Dirty(t *testing.T) {
328	t.Parallel()
329
330	dir := t.TempDir()
331	configPath := filepath.Join(dir, "crush.json")
332	require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": false}`), 0o600))
333
334	store := config.NewTestStore(&config.Config{
335		Providers: csync.NewMap[string, config.ProviderConfig](),
336	}, configPath)
337
338	// Capture initial snapshot
339	store.CaptureStalenessSnapshot([]string{configPath})
340
341	// Modify file to trigger dirty state
342	time.Sleep(10 * time.Millisecond)
343	require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
344
345	output := buildCrushInfo(store, nil, nil, nil, nil)
346	require.Contains(t, output, "[config]")
347	require.Contains(t, output, "dirty = true")
348	require.Contains(t, output, "changed_paths")
349	require.Contains(t, output, configPath)
350}
351
352func TestCrushInfo_ConfigStaleness_MissingPath(t *testing.T) {
353	t.Parallel()
354
355	dir := t.TempDir()
356	configPath := filepath.Join(dir, "crush.json")
357	require.NoError(t, os.WriteFile(configPath, []byte(`{}`), 0o600))
358
359	store := config.NewTestStore(&config.Config{
360		Providers: csync.NewMap[string, config.ProviderConfig](),
361	}, configPath)
362
363	// Capture initial snapshot
364	store.CaptureStalenessSnapshot([]string{configPath})
365
366	// Delete file to trigger missing state
367	require.NoError(t, os.Remove(configPath))
368
369	output := buildCrushInfo(store, nil, nil, nil, nil)
370	require.Contains(t, output, "[config]")
371	require.Contains(t, output, "dirty = true")
372	require.Contains(t, output, "missing_paths")
373	require.Contains(t, output, configPath)
374}
375
376func TestCrushInfo_Skills_NoSkills(t *testing.T) {
377	t.Parallel()
378
379	cfg := config.NewTestStore(&config.Config{
380		Providers: csync.NewMap[string, config.ProviderConfig](),
381	})
382	output := buildCrushInfo(cfg, nil, nil, nil, nil)
383	require.NotContains(t, output, "[skills]")
384}
385
386func TestCrushInfo_Skills_MixedLoadedUnloaded(t *testing.T) {
387	t.Parallel()
388
389	allSkills := []*skills.Skill{
390		{Name: "go-doc", Builtin: false},
391		{Name: "bash", Builtin: false},
392		{Name: "crush-config", Builtin: true},
393	}
394	activeSkills := allSkills
395
396	tracker := skills.NewTracker(activeSkills)
397	tracker.MarkLoaded("bash")
398	tracker.MarkLoaded("crush-config")
399
400	cfg := config.NewTestStore(&config.Config{
401		Providers: csync.NewMap[string, config.ProviderConfig](),
402	})
403	output := buildCrushInfo(cfg, nil, allSkills, activeSkills, tracker)
404	require.Contains(t, output, "[skills]")
405	require.Contains(t, output, "bash = user, loaded")
406	require.Contains(t, output, "crush-config = builtin, loaded")
407	require.Contains(t, output, "go-doc = user, unloaded")
408}
409
410func TestCrushInfo_Skills_DisabledSkills(t *testing.T) {
411	t.Parallel()
412
413	allSkills := []*skills.Skill{
414		{Name: "bash", Builtin: false},
415		{Name: "crush-config", Builtin: true},
416		{Name: "image-convert", Builtin: false},
417	}
418	activeSkills := []*skills.Skill{
419		{Name: "bash", Builtin: false},
420		{Name: "crush-config", Builtin: true},
421	}
422
423	tracker := skills.NewTracker(activeSkills)
424
425	cfg := config.NewTestStore(&config.Config{
426		Providers: csync.NewMap[string, config.ProviderConfig](),
427		Options:   &config.Options{DisabledSkills: []string{"image-convert"}},
428	})
429	output := buildCrushInfo(cfg, nil, allSkills, activeSkills, tracker)
430	require.Contains(t, output, "[skills]")
431	require.Contains(t, output, "bash = user, unloaded")
432	require.Contains(t, output, "crush-config = builtin, unloaded")
433	require.Contains(t, output, "image-convert = user, disabled")
434}
435
436func TestCrushInfo_Skills_Ordering(t *testing.T) {
437	t.Parallel()
438
439	allSkills := []*skills.Skill{
440		{Name: "z-skill", Builtin: false},
441		{Name: "a-skill", Builtin: true},
442		{Name: "m-skill", Builtin: false},
443	}
444	activeSkills := allSkills
445	tracker := skills.NewTracker(activeSkills)
446
447	cfg := config.NewTestStore(&config.Config{
448		Providers: csync.NewMap[string, config.ProviderConfig](),
449	})
450	output := buildCrushInfo(cfg, nil, allSkills, activeSkills, tracker)
451
452	aIdx := strings.Index(output, "a-skill")
453	mIdx := strings.Index(output, "m-skill")
454	zIdx := strings.Index(output, "z-skill")
455	require.Less(t, aIdx, mIdx)
456	require.Less(t, mIdx, zIdx)
457}
458
459func TestCrushInfo_Skills_BuiltinOrigin(t *testing.T) {
460	t.Parallel()
461
462	allSkills := []*skills.Skill{
463		{Name: "crush-config", Builtin: true},
464		{Name: "my-skill", Builtin: false},
465	}
466	activeSkills := allSkills
467	tracker := skills.NewTracker(activeSkills)
468
469	cfg := config.NewTestStore(&config.Config{
470		Providers: csync.NewMap[string, config.ProviderConfig](),
471	})
472	output := buildCrushInfo(cfg, nil, allSkills, activeSkills, tracker)
473	require.Contains(t, output, "crush-config = builtin, unloaded")
474	require.Contains(t, output, "my-skill = user, unloaded")
475}
476
477func TestCrushInfo_Hooks(t *testing.T) {
478	t.Parallel()
479
480	cfg := config.NewTestStore(&config.Config{
481		Providers: csync.NewMap[string, config.ProviderConfig](),
482		Hooks: map[string][]config.HookConfig{
483			"PreToolUse": {
484				{Command: "check-privates.sh", Matcher: "edit|write"},
485				{Command: "audit.sh"},
486			},
487		},
488	})
489
490	output := buildCrushInfo(cfg, nil, nil, nil, nil)
491	require.Contains(t, output, "[hooks]")
492	require.Contains(t, output, "PreToolUse (matcher: edit|write) = check-privates.sh")
493	require.Contains(t, output, "PreToolUse = audit.sh")
494}
495
496func TestCrushInfo_Hooks_NoHooks(t *testing.T) {
497	t.Parallel()
498
499	cfg := config.NewTestStore(&config.Config{
500		Providers: csync.NewMap[string, config.ProviderConfig](),
501	})
502
503	output := buildCrushInfo(cfg, nil, nil, nil, nil)
504	require.NotContains(t, output, "[hooks]")
505}