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}