crush_info_test.go

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