@@ -0,0 +1,462 @@
+package config
+
+import (
+ "testing"
+
+ "github.com/charmbracelet/crush/internal/fur/provider"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestConfig_Validate_ValidConfig(t *testing.T) {
+ cfg := &Config{
+ Models: PreferredModels{
+ Large: PreferredModel{
+ ModelID: "gpt-4",
+ Provider: provider.InferenceProviderOpenAI,
+ },
+ Small: PreferredModel{
+ ModelID: "gpt-3.5-turbo",
+ Provider: provider.InferenceProviderOpenAI,
+ },
+ },
+ Providers: map[provider.InferenceProvider]ProviderConfig{
+ provider.InferenceProviderOpenAI: {
+ ID: provider.InferenceProviderOpenAI,
+ APIKey: "test-key",
+ ProviderType: provider.TypeOpenAI,
+ DefaultLargeModel: "gpt-4",
+ DefaultSmallModel: "gpt-3.5-turbo",
+ Models: []Model{
+ {
+ ID: "gpt-4",
+ Name: "GPT-4",
+ ContextWindow: 8192,
+ DefaultMaxTokens: 4096,
+ CostPer1MIn: 30.0,
+ CostPer1MOut: 60.0,
+ },
+ {
+ ID: "gpt-3.5-turbo",
+ Name: "GPT-3.5 Turbo",
+ ContextWindow: 4096,
+ DefaultMaxTokens: 2048,
+ CostPer1MIn: 1.5,
+ CostPer1MOut: 2.0,
+ },
+ },
+ },
+ },
+ Agents: map[AgentID]Agent{
+ AgentCoder: {
+ ID: AgentCoder,
+ Name: "Coder",
+ Description: "An agent that helps with executing coding tasks.",
+ Model: LargeModel,
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ AgentTask: {
+ ID: AgentTask,
+ Name: "Task",
+ Description: "An agent that helps with searching for context and finding implementation details.",
+ Model: LargeModel,
+ ContextPaths: []string{"CRUSH.md"},
+ AllowedTools: []string{"glob", "grep", "ls", "sourcegraph", "view"},
+ AllowedMCP: map[string][]string{},
+ AllowedLSP: []string{},
+ },
+ },
+ MCP: map[string]MCP{},
+ LSP: map[string]LSPConfig{},
+ Options: Options{
+ DataDirectory: ".crush",
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ }
+
+ err := cfg.Validate()
+ assert.NoError(t, err)
+}
+
+func TestConfig_Validate_MissingAPIKey(t *testing.T) {
+ cfg := &Config{
+ Providers: map[provider.InferenceProvider]ProviderConfig{
+ provider.InferenceProviderOpenAI: {
+ ID: provider.InferenceProviderOpenAI,
+ ProviderType: provider.TypeOpenAI,
+ // Missing APIKey
+ },
+ },
+ Options: Options{
+ DataDirectory: ".crush",
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ }
+
+ err := cfg.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "API key is required")
+}
+
+func TestConfig_Validate_InvalidProviderType(t *testing.T) {
+ cfg := &Config{
+ Providers: map[provider.InferenceProvider]ProviderConfig{
+ provider.InferenceProviderOpenAI: {
+ ID: provider.InferenceProviderOpenAI,
+ APIKey: "test-key",
+ ProviderType: provider.Type("invalid"),
+ },
+ },
+ Options: Options{
+ DataDirectory: ".crush",
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ }
+
+ err := cfg.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid provider type")
+}
+
+func TestConfig_Validate_CustomProviderMissingBaseURL(t *testing.T) {
+ customProvider := provider.InferenceProvider("custom-provider")
+ cfg := &Config{
+ Providers: map[provider.InferenceProvider]ProviderConfig{
+ customProvider: {
+ ID: customProvider,
+ APIKey: "test-key",
+ ProviderType: provider.TypeOpenAI,
+ // Missing BaseURL for custom provider
+ },
+ },
+ Options: Options{
+ DataDirectory: ".crush",
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ }
+
+ err := cfg.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "BaseURL is required for custom providers")
+}
+
+func TestConfig_Validate_DuplicateModelIDs(t *testing.T) {
+ cfg := &Config{
+ Providers: map[provider.InferenceProvider]ProviderConfig{
+ provider.InferenceProviderOpenAI: {
+ ID: provider.InferenceProviderOpenAI,
+ APIKey: "test-key",
+ ProviderType: provider.TypeOpenAI,
+ Models: []Model{
+ {
+ ID: "gpt-4",
+ Name: "GPT-4",
+ ContextWindow: 8192,
+ DefaultMaxTokens: 4096,
+ },
+ {
+ ID: "gpt-4", // Duplicate ID
+ Name: "GPT-4 Duplicate",
+ ContextWindow: 8192,
+ DefaultMaxTokens: 4096,
+ },
+ },
+ },
+ },
+ Options: Options{
+ DataDirectory: ".crush",
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ }
+
+ err := cfg.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "duplicate model ID")
+}
+
+func TestConfig_Validate_InvalidModelFields(t *testing.T) {
+ cfg := &Config{
+ Providers: map[provider.InferenceProvider]ProviderConfig{
+ provider.InferenceProviderOpenAI: {
+ ID: provider.InferenceProviderOpenAI,
+ APIKey: "test-key",
+ ProviderType: provider.TypeOpenAI,
+ Models: []Model{
+ {
+ ID: "", // Empty ID
+ Name: "GPT-4",
+ ContextWindow: 0, // Invalid context window
+ DefaultMaxTokens: -1, // Invalid max tokens
+ CostPer1MIn: -5.0, // Negative cost
+ },
+ },
+ },
+ },
+ Options: Options{
+ DataDirectory: ".crush",
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ }
+
+ err := cfg.Validate()
+ require.Error(t, err)
+ validationErr := err.(ValidationErrors)
+ assert.True(t, len(validationErr) >= 4) // Should have multiple validation errors
+}
+
+func TestConfig_Validate_DefaultModelNotFound(t *testing.T) {
+ cfg := &Config{
+ Providers: map[provider.InferenceProvider]ProviderConfig{
+ provider.InferenceProviderOpenAI: {
+ ID: provider.InferenceProviderOpenAI,
+ APIKey: "test-key",
+ ProviderType: provider.TypeOpenAI,
+ DefaultLargeModel: "nonexistent-model",
+ Models: []Model{
+ {
+ ID: "gpt-4",
+ Name: "GPT-4",
+ ContextWindow: 8192,
+ DefaultMaxTokens: 4096,
+ },
+ },
+ },
+ },
+ Options: Options{
+ DataDirectory: ".crush",
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ }
+
+ err := cfg.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "default large model 'nonexistent-model' not found")
+}
+
+func TestConfig_Validate_AgentIDMismatch(t *testing.T) {
+ cfg := &Config{
+ Agents: map[AgentID]Agent{
+ AgentCoder: {
+ ID: AgentTask, // Wrong ID
+ Name: "Coder",
+ },
+ },
+ Options: Options{
+ DataDirectory: ".crush",
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ }
+
+ err := cfg.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "agent ID mismatch")
+}
+
+func TestConfig_Validate_InvalidAgentModelType(t *testing.T) {
+ cfg := &Config{
+ Agents: map[AgentID]Agent{
+ AgentCoder: {
+ ID: AgentCoder,
+ Name: "Coder",
+ Model: ModelType("invalid"),
+ },
+ },
+ Options: Options{
+ DataDirectory: ".crush",
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ }
+
+ err := cfg.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid model type")
+}
+
+func TestConfig_Validate_UnknownTool(t *testing.T) {
+ cfg := &Config{
+ Agents: map[AgentID]Agent{
+ AgentID("custom-agent"): {
+ ID: AgentID("custom-agent"),
+ Name: "Custom Agent",
+ Model: LargeModel,
+ AllowedTools: []string{"unknown-tool"},
+ },
+ },
+ Options: Options{
+ DataDirectory: ".crush",
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ }
+
+ err := cfg.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unknown tool")
+}
+
+func TestConfig_Validate_MCPReference(t *testing.T) {
+ cfg := &Config{
+ Agents: map[AgentID]Agent{
+ AgentID("custom-agent"): {
+ ID: AgentID("custom-agent"),
+ Name: "Custom Agent",
+ Model: LargeModel,
+ AllowedMCP: map[string][]string{"nonexistent-mcp": nil},
+ },
+ },
+ MCP: map[string]MCP{}, // Empty MCP map
+ Options: Options{
+ DataDirectory: ".crush",
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ }
+
+ err := cfg.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "referenced MCP 'nonexistent-mcp' not found")
+}
+
+func TestConfig_Validate_InvalidMCPType(t *testing.T) {
+ cfg := &Config{
+ MCP: map[string]MCP{
+ "test-mcp": {
+ Type: MCPType("invalid"),
+ },
+ },
+ Options: Options{
+ DataDirectory: ".crush",
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ }
+
+ err := cfg.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid MCP type")
+}
+
+func TestConfig_Validate_MCPMissingCommand(t *testing.T) {
+ cfg := &Config{
+ MCP: map[string]MCP{
+ "test-mcp": {
+ Type: MCPStdio,
+ // Missing Command
+ },
+ },
+ Options: Options{
+ DataDirectory: ".crush",
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ }
+
+ err := cfg.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "command is required for stdio MCP")
+}
+
+func TestConfig_Validate_LSPMissingCommand(t *testing.T) {
+ cfg := &Config{
+ LSP: map[string]LSPConfig{
+ "test-lsp": {
+ // Missing Command
+ },
+ },
+ Options: Options{
+ DataDirectory: ".crush",
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ }
+
+ err := cfg.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "command is required for LSP")
+}
+
+func TestConfig_Validate_NoValidProviders(t *testing.T) {
+ cfg := &Config{
+ Providers: map[provider.InferenceProvider]ProviderConfig{
+ provider.InferenceProviderOpenAI: {
+ ID: provider.InferenceProviderOpenAI,
+ APIKey: "test-key",
+ ProviderType: provider.TypeOpenAI,
+ Disabled: true, // Disabled
+ },
+ },
+ Options: Options{
+ DataDirectory: ".crush",
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ }
+
+ err := cfg.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "at least one non-disabled provider is required")
+}
+
+func TestConfig_Validate_MissingDefaultAgents(t *testing.T) {
+ cfg := &Config{
+ Providers: map[provider.InferenceProvider]ProviderConfig{
+ provider.InferenceProviderOpenAI: {
+ ID: provider.InferenceProviderOpenAI,
+ APIKey: "test-key",
+ ProviderType: provider.TypeOpenAI,
+ },
+ },
+ Agents: map[AgentID]Agent{}, // Missing default agents
+ Options: Options{
+ DataDirectory: ".crush",
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ }
+
+ err := cfg.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "coder agent is required")
+ assert.Contains(t, err.Error(), "task agent is required")
+}
+
+func TestConfig_Validate_KnownAgentProtection(t *testing.T) {
+ cfg := &Config{
+ Agents: map[AgentID]Agent{
+ AgentCoder: {
+ ID: AgentCoder,
+ Name: "Modified Coder", // Should not be allowed
+ Description: "Modified description", // Should not be allowed
+ Model: LargeModel,
+ },
+ },
+ Options: Options{
+ DataDirectory: ".crush",
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ }
+
+ err := cfg.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "coder agent name cannot be changed")
+ assert.Contains(t, err.Error(), "coder agent description cannot be changed")
+}
+
+func TestConfig_Validate_EmptyDataDirectory(t *testing.T) {
+ cfg := &Config{
+ Options: Options{
+ DataDirectory: "", // Empty
+ ContextPaths: []string{"CRUSH.md"},
+ },
+ }
+
+ err := cfg.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "data directory is required")
+}
+
+func TestConfig_Validate_EmptyContextPath(t *testing.T) {
+ cfg := &Config{
+ Options: Options{
+ DataDirectory: ".crush",
+ ContextPaths: []string{""}, // Empty context path
+ },
+ }
+
+ err := cfg.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "context path cannot be empty")
+}