validation_test.go

  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}