1package config
2
3import (
4 "testing"
5
6 "github.com/charmbracelet/crush/internal/fur/provider"
7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"
9)
10
11func TestConfig_Validate_ValidConfig(t *testing.T) {
12 cfg := &Config{
13 Models: PreferredModels{
14 Large: PreferredModel{
15 ModelID: "gpt-4",
16 Provider: provider.InferenceProviderOpenAI,
17 },
18 Small: PreferredModel{
19 ModelID: "gpt-3.5-turbo",
20 Provider: provider.InferenceProviderOpenAI,
21 },
22 },
23 Providers: map[provider.InferenceProvider]ProviderConfig{
24 provider.InferenceProviderOpenAI: {
25 ID: provider.InferenceProviderOpenAI,
26 APIKey: "test-key",
27 ProviderType: provider.TypeOpenAI,
28 DefaultLargeModel: "gpt-4",
29 DefaultSmallModel: "gpt-3.5-turbo",
30 Models: []Model{
31 {
32 ID: "gpt-4",
33 Name: "GPT-4",
34 ContextWindow: 8192,
35 DefaultMaxTokens: 4096,
36 CostPer1MIn: 30.0,
37 CostPer1MOut: 60.0,
38 },
39 {
40 ID: "gpt-3.5-turbo",
41 Name: "GPT-3.5 Turbo",
42 ContextWindow: 4096,
43 DefaultMaxTokens: 2048,
44 CostPer1MIn: 1.5,
45 CostPer1MOut: 2.0,
46 },
47 },
48 },
49 },
50 Agents: map[AgentID]Agent{
51 AgentCoder: {
52 ID: AgentCoder,
53 Name: "Coder",
54 Description: "An agent that helps with executing coding tasks.",
55 Model: LargeModel,
56 ContextPaths: []string{"CRUSH.md"},
57 },
58 AgentTask: {
59 ID: AgentTask,
60 Name: "Task",
61 Description: "An agent that helps with searching for context and finding implementation details.",
62 Model: LargeModel,
63 ContextPaths: []string{"CRUSH.md"},
64 AllowedTools: []string{"glob", "grep", "ls", "sourcegraph", "view"},
65 AllowedMCP: map[string][]string{},
66 AllowedLSP: []string{},
67 },
68 },
69 MCP: map[string]MCP{},
70 LSP: map[string]LSPConfig{},
71 Options: Options{
72 DataDirectory: ".crush",
73 ContextPaths: []string{"CRUSH.md"},
74 },
75 }
76
77 err := cfg.Validate()
78 assert.NoError(t, err)
79}
80
81func TestConfig_Validate_MissingAPIKey(t *testing.T) {
82 cfg := &Config{
83 Providers: map[provider.InferenceProvider]ProviderConfig{
84 provider.InferenceProviderOpenAI: {
85 ID: provider.InferenceProviderOpenAI,
86 ProviderType: provider.TypeOpenAI,
87 // Missing APIKey
88 },
89 },
90 Options: Options{
91 DataDirectory: ".crush",
92 ContextPaths: []string{"CRUSH.md"},
93 },
94 }
95
96 err := cfg.Validate()
97 require.Error(t, err)
98 assert.Contains(t, err.Error(), "API key is required")
99}
100
101func TestConfig_Validate_InvalidProviderType(t *testing.T) {
102 cfg := &Config{
103 Providers: map[provider.InferenceProvider]ProviderConfig{
104 provider.InferenceProviderOpenAI: {
105 ID: provider.InferenceProviderOpenAI,
106 APIKey: "test-key",
107 ProviderType: provider.Type("invalid"),
108 },
109 },
110 Options: Options{
111 DataDirectory: ".crush",
112 ContextPaths: []string{"CRUSH.md"},
113 },
114 }
115
116 err := cfg.Validate()
117 require.Error(t, err)
118 assert.Contains(t, err.Error(), "invalid provider type")
119}
120
121func TestConfig_Validate_CustomProviderMissingBaseURL(t *testing.T) {
122 customProvider := provider.InferenceProvider("custom-provider")
123 cfg := &Config{
124 Providers: map[provider.InferenceProvider]ProviderConfig{
125 customProvider: {
126 ID: customProvider,
127 APIKey: "test-key",
128 ProviderType: provider.TypeOpenAI,
129 // Missing BaseURL for custom provider
130 },
131 },
132 Options: Options{
133 DataDirectory: ".crush",
134 ContextPaths: []string{"CRUSH.md"},
135 },
136 }
137
138 err := cfg.Validate()
139 require.Error(t, err)
140 assert.Contains(t, err.Error(), "BaseURL is required for custom providers")
141}
142
143func TestConfig_Validate_DuplicateModelIDs(t *testing.T) {
144 cfg := &Config{
145 Providers: map[provider.InferenceProvider]ProviderConfig{
146 provider.InferenceProviderOpenAI: {
147 ID: provider.InferenceProviderOpenAI,
148 APIKey: "test-key",
149 ProviderType: provider.TypeOpenAI,
150 Models: []Model{
151 {
152 ID: "gpt-4",
153 Name: "GPT-4",
154 ContextWindow: 8192,
155 DefaultMaxTokens: 4096,
156 },
157 {
158 ID: "gpt-4", // Duplicate ID
159 Name: "GPT-4 Duplicate",
160 ContextWindow: 8192,
161 DefaultMaxTokens: 4096,
162 },
163 },
164 },
165 },
166 Options: Options{
167 DataDirectory: ".crush",
168 ContextPaths: []string{"CRUSH.md"},
169 },
170 }
171
172 err := cfg.Validate()
173 require.Error(t, err)
174 assert.Contains(t, err.Error(), "duplicate model ID")
175}
176
177func TestConfig_Validate_InvalidModelFields(t *testing.T) {
178 cfg := &Config{
179 Providers: map[provider.InferenceProvider]ProviderConfig{
180 provider.InferenceProviderOpenAI: {
181 ID: provider.InferenceProviderOpenAI,
182 APIKey: "test-key",
183 ProviderType: provider.TypeOpenAI,
184 Models: []Model{
185 {
186 ID: "", // Empty ID
187 Name: "GPT-4",
188 ContextWindow: 0, // Invalid context window
189 DefaultMaxTokens: -1, // Invalid max tokens
190 CostPer1MIn: -5.0, // Negative cost
191 },
192 },
193 },
194 },
195 Options: Options{
196 DataDirectory: ".crush",
197 ContextPaths: []string{"CRUSH.md"},
198 },
199 }
200
201 err := cfg.Validate()
202 require.Error(t, err)
203 validationErr := err.(ValidationErrors)
204 assert.True(t, len(validationErr) >= 4) // Should have multiple validation errors
205}
206
207func TestConfig_Validate_DefaultModelNotFound(t *testing.T) {
208 cfg := &Config{
209 Providers: map[provider.InferenceProvider]ProviderConfig{
210 provider.InferenceProviderOpenAI: {
211 ID: provider.InferenceProviderOpenAI,
212 APIKey: "test-key",
213 ProviderType: provider.TypeOpenAI,
214 DefaultLargeModel: "nonexistent-model",
215 Models: []Model{
216 {
217 ID: "gpt-4",
218 Name: "GPT-4",
219 ContextWindow: 8192,
220 DefaultMaxTokens: 4096,
221 },
222 },
223 },
224 },
225 Options: Options{
226 DataDirectory: ".crush",
227 ContextPaths: []string{"CRUSH.md"},
228 },
229 }
230
231 err := cfg.Validate()
232 require.Error(t, err)
233 assert.Contains(t, err.Error(), "default large model 'nonexistent-model' not found")
234}
235
236func TestConfig_Validate_AgentIDMismatch(t *testing.T) {
237 cfg := &Config{
238 Agents: map[AgentID]Agent{
239 AgentCoder: {
240 ID: AgentTask, // Wrong ID
241 Name: "Coder",
242 },
243 },
244 Options: Options{
245 DataDirectory: ".crush",
246 ContextPaths: []string{"CRUSH.md"},
247 },
248 }
249
250 err := cfg.Validate()
251 require.Error(t, err)
252 assert.Contains(t, err.Error(), "agent ID mismatch")
253}
254
255func TestConfig_Validate_InvalidAgentModelType(t *testing.T) {
256 cfg := &Config{
257 Agents: map[AgentID]Agent{
258 AgentCoder: {
259 ID: AgentCoder,
260 Name: "Coder",
261 Model: ModelType("invalid"),
262 },
263 },
264 Options: Options{
265 DataDirectory: ".crush",
266 ContextPaths: []string{"CRUSH.md"},
267 },
268 }
269
270 err := cfg.Validate()
271 require.Error(t, err)
272 assert.Contains(t, err.Error(), "invalid model type")
273}
274
275func TestConfig_Validate_UnknownTool(t *testing.T) {
276 cfg := &Config{
277 Agents: map[AgentID]Agent{
278 AgentID("custom-agent"): {
279 ID: AgentID("custom-agent"),
280 Name: "Custom Agent",
281 Model: LargeModel,
282 AllowedTools: []string{"unknown-tool"},
283 },
284 },
285 Options: Options{
286 DataDirectory: ".crush",
287 ContextPaths: []string{"CRUSH.md"},
288 },
289 }
290
291 err := cfg.Validate()
292 require.Error(t, err)
293 assert.Contains(t, err.Error(), "unknown tool")
294}
295
296func TestConfig_Validate_MCPReference(t *testing.T) {
297 cfg := &Config{
298 Agents: map[AgentID]Agent{
299 AgentID("custom-agent"): {
300 ID: AgentID("custom-agent"),
301 Name: "Custom Agent",
302 Model: LargeModel,
303 AllowedMCP: map[string][]string{"nonexistent-mcp": nil},
304 },
305 },
306 MCP: map[string]MCP{}, // Empty MCP map
307 Options: Options{
308 DataDirectory: ".crush",
309 ContextPaths: []string{"CRUSH.md"},
310 },
311 }
312
313 err := cfg.Validate()
314 require.Error(t, err)
315 assert.Contains(t, err.Error(), "referenced MCP 'nonexistent-mcp' not found")
316}
317
318func TestConfig_Validate_InvalidMCPType(t *testing.T) {
319 cfg := &Config{
320 MCP: map[string]MCP{
321 "test-mcp": {
322 Type: MCPType("invalid"),
323 },
324 },
325 Options: Options{
326 DataDirectory: ".crush",
327 ContextPaths: []string{"CRUSH.md"},
328 },
329 }
330
331 err := cfg.Validate()
332 require.Error(t, err)
333 assert.Contains(t, err.Error(), "invalid MCP type")
334}
335
336func TestConfig_Validate_MCPMissingCommand(t *testing.T) {
337 cfg := &Config{
338 MCP: map[string]MCP{
339 "test-mcp": {
340 Type: MCPStdio,
341 // Missing Command
342 },
343 },
344 Options: Options{
345 DataDirectory: ".crush",
346 ContextPaths: []string{"CRUSH.md"},
347 },
348 }
349
350 err := cfg.Validate()
351 require.Error(t, err)
352 assert.Contains(t, err.Error(), "command is required for stdio MCP")
353}
354
355func TestConfig_Validate_LSPMissingCommand(t *testing.T) {
356 cfg := &Config{
357 LSP: map[string]LSPConfig{
358 "test-lsp": {
359 // Missing Command
360 },
361 },
362 Options: Options{
363 DataDirectory: ".crush",
364 ContextPaths: []string{"CRUSH.md"},
365 },
366 }
367
368 err := cfg.Validate()
369 require.Error(t, err)
370 assert.Contains(t, err.Error(), "command is required for LSP")
371}
372
373func TestConfig_Validate_NoValidProviders(t *testing.T) {
374 cfg := &Config{
375 Providers: map[provider.InferenceProvider]ProviderConfig{
376 provider.InferenceProviderOpenAI: {
377 ID: provider.InferenceProviderOpenAI,
378 APIKey: "test-key",
379 ProviderType: provider.TypeOpenAI,
380 Disabled: true, // Disabled
381 },
382 },
383 Options: Options{
384 DataDirectory: ".crush",
385 ContextPaths: []string{"CRUSH.md"},
386 },
387 }
388
389 err := cfg.Validate()
390 require.Error(t, err)
391 assert.Contains(t, err.Error(), "at least one non-disabled provider is required")
392}
393
394func TestConfig_Validate_MissingDefaultAgents(t *testing.T) {
395 cfg := &Config{
396 Providers: map[provider.InferenceProvider]ProviderConfig{
397 provider.InferenceProviderOpenAI: {
398 ID: provider.InferenceProviderOpenAI,
399 APIKey: "test-key",
400 ProviderType: provider.TypeOpenAI,
401 },
402 },
403 Agents: map[AgentID]Agent{}, // Missing default agents
404 Options: Options{
405 DataDirectory: ".crush",
406 ContextPaths: []string{"CRUSH.md"},
407 },
408 }
409
410 err := cfg.Validate()
411 require.Error(t, err)
412 assert.Contains(t, err.Error(), "coder agent is required")
413 assert.Contains(t, err.Error(), "task agent is required")
414}
415
416func TestConfig_Validate_KnownAgentProtection(t *testing.T) {
417 cfg := &Config{
418 Agents: map[AgentID]Agent{
419 AgentCoder: {
420 ID: AgentCoder,
421 Name: "Modified Coder", // Should not be allowed
422 Description: "Modified description", // Should not be allowed
423 Model: LargeModel,
424 },
425 },
426 Options: Options{
427 DataDirectory: ".crush",
428 ContextPaths: []string{"CRUSH.md"},
429 },
430 }
431
432 err := cfg.Validate()
433 require.Error(t, err)
434 assert.Contains(t, err.Error(), "coder agent name cannot be changed")
435 assert.Contains(t, err.Error(), "coder agent description cannot be changed")
436}
437
438func TestConfig_Validate_EmptyDataDirectory(t *testing.T) {
439 cfg := &Config{
440 Options: Options{
441 DataDirectory: "", // Empty
442 ContextPaths: []string{"CRUSH.md"},
443 },
444 }
445
446 err := cfg.Validate()
447 require.Error(t, err)
448 assert.Contains(t, err.Error(), "data directory is required")
449}
450
451func TestConfig_Validate_EmptyContextPath(t *testing.T) {
452 cfg := &Config{
453 Options: Options{
454 DataDirectory: ".crush",
455 ContextPaths: []string{""}, // Empty context path
456 },
457 }
458
459 err := cfg.Validate()
460 require.Error(t, err)
461 assert.Contains(t, err.Error(), "context path cannot be empty")
462}