@@ -0,0 +1,300 @@
1+package tools
2+
3+import (
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+
17+func 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+
31+func 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+
45+func 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+
61+func 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+
80+func 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+
93+func 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+
117+func 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+
150+func 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+
164+func 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+
177+func 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+
190+func 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+
210+func 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+
228+func 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+
244+func 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+
286+func 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+}