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}