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}