config_test.go

   1package config
   2
   3import (
   4	"encoding/json"
   5	"os"
   6	"path/filepath"
   7	"sync"
   8	"testing"
   9
  10	"github.com/charmbracelet/crush/internal/fur/provider"
  11	"github.com/stretchr/testify/assert"
  12	"github.com/stretchr/testify/require"
  13)
  14
  15func reset() {
  16	// Clear all environment variables that could affect config
  17	envVarsToUnset := []string{
  18		// API Keys
  19		"ANTHROPIC_API_KEY",
  20		"OPENAI_API_KEY",
  21		"GEMINI_API_KEY",
  22		"XAI_API_KEY",
  23		"OPENROUTER_API_KEY",
  24
  25		// Google Cloud / VertexAI
  26		"GOOGLE_GENAI_USE_VERTEXAI",
  27		"GOOGLE_CLOUD_PROJECT",
  28		"GOOGLE_CLOUD_LOCATION",
  29
  30		// AWS Credentials
  31		"AWS_ACCESS_KEY_ID",
  32		"AWS_SECRET_ACCESS_KEY",
  33		"AWS_REGION",
  34		"AWS_DEFAULT_REGION",
  35		"AWS_PROFILE",
  36		"AWS_DEFAULT_PROFILE",
  37		"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
  38		"AWS_CONTAINER_CREDENTIALS_FULL_URI",
  39
  40		// Other
  41		"CRUSH_DEV_DEBUG",
  42	}
  43
  44	for _, envVar := range envVarsToUnset {
  45		os.Unsetenv(envVar)
  46	}
  47
  48	// Reset singleton
  49	once = sync.Once{}
  50	instance = nil
  51	cwd = ""
  52	testConfigDir = ""
  53
  54	// Enable mock providers for all tests to avoid API calls
  55	UseMockProviders = true
  56	ResetProviders()
  57}
  58
  59// Core Configuration Loading Tests
  60
  61func TestInit_ValidWorkingDirectory(t *testing.T) {
  62	reset()
  63	testConfigDir = t.TempDir()
  64	cwdDir := t.TempDir()
  65
  66	cfg, err := Init(cwdDir, false)
  67
  68	require.NoError(t, err)
  69	assert.NotNil(t, cfg)
  70	assert.Equal(t, cwdDir, WorkingDirectory())
  71	assert.Equal(t, defaultDataDirectory, cfg.Options.DataDirectory)
  72	assert.Equal(t, defaultContextPaths, cfg.Options.ContextPaths)
  73}
  74
  75func TestInit_WithDebugFlag(t *testing.T) {
  76	reset()
  77	testConfigDir = t.TempDir()
  78	cwdDir := t.TempDir()
  79
  80	cfg, err := Init(cwdDir, true)
  81
  82	require.NoError(t, err)
  83	assert.True(t, cfg.Options.Debug)
  84}
  85
  86func TestInit_SingletonBehavior(t *testing.T) {
  87	reset()
  88	testConfigDir = t.TempDir()
  89	cwdDir := t.TempDir()
  90
  91	cfg1, err1 := Init(cwdDir, false)
  92	cfg2, err2 := Init(cwdDir, false)
  93
  94	require.NoError(t, err1)
  95	require.NoError(t, err2)
  96	assert.Same(t, cfg1, cfg2)
  97}
  98
  99func TestGet_BeforeInitialization(t *testing.T) {
 100	reset()
 101
 102	assert.Panics(t, func() {
 103		Get()
 104	})
 105}
 106
 107func TestGet_AfterInitialization(t *testing.T) {
 108	reset()
 109	testConfigDir = t.TempDir()
 110	cwdDir := t.TempDir()
 111
 112	cfg1, err := Init(cwdDir, false)
 113	require.NoError(t, err)
 114
 115	cfg2 := Get()
 116	assert.Same(t, cfg1, cfg2)
 117}
 118
 119func TestLoadConfig_NoConfigFiles(t *testing.T) {
 120	reset()
 121	testConfigDir = t.TempDir()
 122	cwdDir := t.TempDir()
 123
 124	cfg, err := Init(cwdDir, false)
 125
 126	require.NoError(t, err)
 127	assert.Len(t, cfg.Providers, 0)
 128	assert.Equal(t, defaultContextPaths, cfg.Options.ContextPaths)
 129}
 130
 131func TestLoadConfig_OnlyGlobalConfig(t *testing.T) {
 132	reset()
 133	testConfigDir = t.TempDir()
 134	cwdDir := t.TempDir()
 135
 136	globalConfig := Config{
 137		Providers: map[provider.InferenceProvider]ProviderConfig{
 138			provider.InferenceProviderOpenAI: {
 139				ID:                provider.InferenceProviderOpenAI,
 140				APIKey:            "test-key",
 141				ProviderType:      provider.TypeOpenAI,
 142				DefaultLargeModel: "gpt-4",
 143				DefaultSmallModel: "gpt-3.5-turbo",
 144				Models: []Model{
 145					{
 146						ID:               "gpt-4",
 147						Name:             "GPT-4",
 148						CostPer1MIn:      30.0,
 149						CostPer1MOut:     60.0,
 150						ContextWindow:    8192,
 151						DefaultMaxTokens: 4096,
 152					},
 153					{
 154						ID:               "gpt-3.5-turbo",
 155						Name:             "GPT-3.5 Turbo",
 156						CostPer1MIn:      1.0,
 157						CostPer1MOut:     2.0,
 158						ContextWindow:    4096,
 159						DefaultMaxTokens: 4096,
 160					},
 161				},
 162			},
 163		},
 164		Options: Options{
 165			ContextPaths: []string{"custom-context.md"},
 166		},
 167	}
 168
 169	configPath := filepath.Join(testConfigDir, "crush.json")
 170	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
 171
 172	data, err := json.Marshal(globalConfig)
 173	require.NoError(t, err)
 174	require.NoError(t, os.WriteFile(configPath, data, 0o644))
 175
 176	cfg, err := Init(cwdDir, false)
 177
 178	require.NoError(t, err)
 179	assert.Len(t, cfg.Providers, 1)
 180	assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
 181	assert.Contains(t, cfg.Options.ContextPaths, "custom-context.md")
 182}
 183
 184func TestLoadConfig_OnlyLocalConfig(t *testing.T) {
 185	reset()
 186	testConfigDir = t.TempDir()
 187	cwdDir := t.TempDir()
 188
 189	localConfig := Config{
 190		Providers: map[provider.InferenceProvider]ProviderConfig{
 191			provider.InferenceProviderAnthropic: {
 192				ID:                provider.InferenceProviderAnthropic,
 193				APIKey:            "local-key",
 194				ProviderType:      provider.TypeAnthropic,
 195				DefaultLargeModel: "claude-3-opus",
 196				DefaultSmallModel: "claude-3-haiku",
 197				Models: []Model{
 198					{
 199						ID:               "claude-3-opus",
 200						Name:             "Claude 3 Opus",
 201						CostPer1MIn:      15.0,
 202						CostPer1MOut:     75.0,
 203						ContextWindow:    200000,
 204						DefaultMaxTokens: 4096,
 205					},
 206					{
 207						ID:               "claude-3-haiku",
 208						Name:             "Claude 3 Haiku",
 209						CostPer1MIn:      0.25,
 210						CostPer1MOut:     1.25,
 211						ContextWindow:    200000,
 212						DefaultMaxTokens: 4096,
 213					},
 214				},
 215			},
 216		},
 217		Options: Options{
 218			TUI: TUIOptions{CompactMode: true},
 219		},
 220	}
 221
 222	localConfigPath := filepath.Join(cwdDir, "crush.json")
 223	data, err := json.Marshal(localConfig)
 224	require.NoError(t, err)
 225	require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
 226
 227	cfg, err := Init(cwdDir, false)
 228
 229	require.NoError(t, err)
 230	assert.Len(t, cfg.Providers, 1)
 231	assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
 232	assert.True(t, cfg.Options.TUI.CompactMode)
 233}
 234
 235func TestLoadConfig_BothGlobalAndLocal(t *testing.T) {
 236	reset()
 237	testConfigDir = t.TempDir()
 238	cwdDir := t.TempDir()
 239
 240	globalConfig := Config{
 241		Providers: map[provider.InferenceProvider]ProviderConfig{
 242			provider.InferenceProviderOpenAI: {
 243				ID:                provider.InferenceProviderOpenAI,
 244				APIKey:            "global-key",
 245				ProviderType:      provider.TypeOpenAI,
 246				DefaultLargeModel: "gpt-4",
 247				DefaultSmallModel: "gpt-3.5-turbo",
 248				Models: []Model{
 249					{
 250						ID:               "gpt-4",
 251						Name:             "GPT-4",
 252						CostPer1MIn:      30.0,
 253						CostPer1MOut:     60.0,
 254						ContextWindow:    8192,
 255						DefaultMaxTokens: 4096,
 256					},
 257					{
 258						ID:               "gpt-3.5-turbo",
 259						Name:             "GPT-3.5 Turbo",
 260						CostPer1MIn:      1.0,
 261						CostPer1MOut:     2.0,
 262						ContextWindow:    4096,
 263						DefaultMaxTokens: 4096,
 264					},
 265				},
 266			},
 267		},
 268		Options: Options{
 269			ContextPaths: []string{"global-context.md"},
 270		},
 271	}
 272
 273	configPath := filepath.Join(testConfigDir, "crush.json")
 274	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
 275	data, err := json.Marshal(globalConfig)
 276	require.NoError(t, err)
 277	require.NoError(t, os.WriteFile(configPath, data, 0o644))
 278
 279	localConfig := Config{
 280		Providers: map[provider.InferenceProvider]ProviderConfig{
 281			provider.InferenceProviderOpenAI: {
 282				APIKey: "local-key", // Override global
 283			},
 284			provider.InferenceProviderAnthropic: {
 285				ID:                provider.InferenceProviderAnthropic,
 286				APIKey:            "anthropic-key",
 287				ProviderType:      provider.TypeAnthropic,
 288				DefaultLargeModel: "claude-3-opus",
 289				DefaultSmallModel: "claude-3-haiku",
 290				Models: []Model{
 291					{
 292						ID:               "claude-3-opus",
 293						Name:             "Claude 3 Opus",
 294						CostPer1MIn:      15.0,
 295						CostPer1MOut:     75.0,
 296						ContextWindow:    200000,
 297						DefaultMaxTokens: 4096,
 298					},
 299					{
 300						ID:               "claude-3-haiku",
 301						Name:             "Claude 3 Haiku",
 302						CostPer1MIn:      0.25,
 303						CostPer1MOut:     1.25,
 304						ContextWindow:    200000,
 305						DefaultMaxTokens: 4096,
 306					},
 307				},
 308			},
 309		},
 310		Options: Options{
 311			ContextPaths: []string{"local-context.md"},
 312			TUI:          TUIOptions{CompactMode: true},
 313		},
 314	}
 315
 316	localConfigPath := filepath.Join(cwdDir, "crush.json")
 317	data, err = json.Marshal(localConfig)
 318	require.NoError(t, err)
 319	require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
 320
 321	cfg, err := Init(cwdDir, false)
 322
 323	require.NoError(t, err)
 324	assert.Len(t, cfg.Providers, 2)
 325
 326	openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
 327	assert.Equal(t, "local-key", openaiProvider.APIKey)
 328
 329	assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
 330
 331	assert.Contains(t, cfg.Options.ContextPaths, "global-context.md")
 332	assert.Contains(t, cfg.Options.ContextPaths, "local-context.md")
 333	assert.True(t, cfg.Options.TUI.CompactMode)
 334}
 335
 336func TestLoadConfig_MalformedGlobalJSON(t *testing.T) {
 337	reset()
 338	testConfigDir = t.TempDir()
 339	cwdDir := t.TempDir()
 340
 341	configPath := filepath.Join(testConfigDir, "crush.json")
 342	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
 343	require.NoError(t, os.WriteFile(configPath, []byte(`{invalid json`), 0o644))
 344
 345	_, err := Init(cwdDir, false)
 346	assert.Error(t, err)
 347}
 348
 349func TestLoadConfig_MalformedLocalJSON(t *testing.T) {
 350	reset()
 351	testConfigDir = t.TempDir()
 352	cwdDir := t.TempDir()
 353
 354	localConfigPath := filepath.Join(cwdDir, "crush.json")
 355	require.NoError(t, os.WriteFile(localConfigPath, []byte(`{invalid json`), 0o644))
 356
 357	_, err := Init(cwdDir, false)
 358	assert.Error(t, err)
 359}
 360
 361func TestConfigWithoutEnv(t *testing.T) {
 362	reset()
 363	testConfigDir = t.TempDir()
 364	cwdDir := t.TempDir()
 365
 366	cfg, _ := Init(cwdDir, false)
 367	assert.Len(t, cfg.Providers, 0)
 368}
 369
 370func TestConfigWithEnv(t *testing.T) {
 371	reset()
 372	testConfigDir = t.TempDir()
 373	cwdDir := t.TempDir()
 374
 375	os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
 376	os.Setenv("OPENAI_API_KEY", "test-openai-key")
 377	os.Setenv("GEMINI_API_KEY", "test-gemini-key")
 378	os.Setenv("XAI_API_KEY", "test-xai-key")
 379	os.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
 380
 381	cfg, _ := Init(cwdDir, false)
 382	assert.Len(t, cfg.Providers, 5)
 383}
 384
 385// Environment Variable Tests
 386
 387func TestEnvVars_NoEnvironmentVariables(t *testing.T) {
 388	reset()
 389	testConfigDir = t.TempDir()
 390	cwdDir := t.TempDir()
 391
 392	cfg, err := Init(cwdDir, false)
 393
 394	require.NoError(t, err)
 395	assert.Len(t, cfg.Providers, 0)
 396}
 397
 398func TestEnvVars_AllSupportedAPIKeys(t *testing.T) {
 399	reset()
 400	testConfigDir = t.TempDir()
 401	cwdDir := t.TempDir()
 402
 403	os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
 404	os.Setenv("OPENAI_API_KEY", "test-openai-key")
 405	os.Setenv("GEMINI_API_KEY", "test-gemini-key")
 406	os.Setenv("XAI_API_KEY", "test-xai-key")
 407	os.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
 408
 409	cfg, err := Init(cwdDir, false)
 410
 411	require.NoError(t, err)
 412	assert.Len(t, cfg.Providers, 5)
 413
 414	anthropicProvider := cfg.Providers[provider.InferenceProviderAnthropic]
 415	assert.Equal(t, "test-anthropic-key", anthropicProvider.APIKey)
 416	assert.Equal(t, provider.TypeAnthropic, anthropicProvider.ProviderType)
 417
 418	openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
 419	assert.Equal(t, "test-openai-key", openaiProvider.APIKey)
 420	assert.Equal(t, provider.TypeOpenAI, openaiProvider.ProviderType)
 421
 422	geminiProvider := cfg.Providers[provider.InferenceProviderGemini]
 423	assert.Equal(t, "test-gemini-key", geminiProvider.APIKey)
 424	assert.Equal(t, provider.TypeGemini, geminiProvider.ProviderType)
 425
 426	xaiProvider := cfg.Providers[provider.InferenceProviderXAI]
 427	assert.Equal(t, "test-xai-key", xaiProvider.APIKey)
 428	assert.Equal(t, provider.TypeXAI, xaiProvider.ProviderType)
 429
 430	openrouterProvider := cfg.Providers[provider.InferenceProviderOpenRouter]
 431	assert.Equal(t, "test-openrouter-key", openrouterProvider.APIKey)
 432	assert.Equal(t, provider.TypeOpenAI, openrouterProvider.ProviderType)
 433	assert.Equal(t, "https://openrouter.ai/api/v1", openrouterProvider.BaseURL)
 434}
 435
 436func TestEnvVars_PartialEnvironmentVariables(t *testing.T) {
 437	reset()
 438	testConfigDir = t.TempDir()
 439	cwdDir := t.TempDir()
 440
 441	os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
 442	os.Setenv("OPENAI_API_KEY", "test-openai-key")
 443
 444	cfg, err := Init(cwdDir, false)
 445
 446	require.NoError(t, err)
 447	assert.Len(t, cfg.Providers, 2)
 448	assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
 449	assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
 450	assert.NotContains(t, cfg.Providers, provider.InferenceProviderGemini)
 451}
 452
 453func TestEnvVars_VertexAIConfiguration(t *testing.T) {
 454	reset()
 455	testConfigDir = t.TempDir()
 456	cwdDir := t.TempDir()
 457
 458	os.Setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
 459	os.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
 460	os.Setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
 461
 462	cfg, err := Init(cwdDir, false)
 463
 464	require.NoError(t, err)
 465	assert.Contains(t, cfg.Providers, provider.InferenceProviderVertexAI)
 466
 467	vertexProvider := cfg.Providers[provider.InferenceProviderVertexAI]
 468	assert.Equal(t, provider.TypeVertexAI, vertexProvider.ProviderType)
 469	assert.Equal(t, "test-project", vertexProvider.ExtraParams["project"])
 470	assert.Equal(t, "us-central1", vertexProvider.ExtraParams["location"])
 471}
 472
 473func TestEnvVars_VertexAIWithoutUseFlag(t *testing.T) {
 474	reset()
 475	testConfigDir = t.TempDir()
 476	cwdDir := t.TempDir()
 477
 478	os.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
 479	os.Setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
 480
 481	cfg, err := Init(cwdDir, false)
 482
 483	require.NoError(t, err)
 484	assert.NotContains(t, cfg.Providers, provider.InferenceProviderVertexAI)
 485}
 486
 487func TestEnvVars_AWSBedrockWithAccessKeys(t *testing.T) {
 488	reset()
 489	testConfigDir = t.TempDir()
 490	cwdDir := t.TempDir()
 491
 492	os.Setenv("AWS_ACCESS_KEY_ID", "test-access-key")
 493	os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret-key")
 494	os.Setenv("AWS_DEFAULT_REGION", "us-east-1")
 495
 496	cfg, err := Init(cwdDir, false)
 497
 498	require.NoError(t, err)
 499	assert.Contains(t, cfg.Providers, provider.InferenceProviderBedrock)
 500
 501	bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
 502	assert.Equal(t, provider.TypeBedrock, bedrockProvider.ProviderType)
 503	assert.Equal(t, "us-east-1", bedrockProvider.ExtraParams["region"])
 504}
 505
 506func TestEnvVars_AWSBedrockWithProfile(t *testing.T) {
 507	reset()
 508	testConfigDir = t.TempDir()
 509	cwdDir := t.TempDir()
 510
 511	os.Setenv("AWS_PROFILE", "test-profile")
 512	os.Setenv("AWS_REGION", "eu-west-1")
 513
 514	cfg, err := Init(cwdDir, false)
 515
 516	require.NoError(t, err)
 517	assert.Contains(t, cfg.Providers, provider.InferenceProviderBedrock)
 518
 519	bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
 520	assert.Equal(t, "eu-west-1", bedrockProvider.ExtraParams["region"])
 521}
 522
 523func TestEnvVars_AWSBedrockWithContainerCredentials(t *testing.T) {
 524	reset()
 525	testConfigDir = t.TempDir()
 526	cwdDir := t.TempDir()
 527
 528	os.Setenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/v2/credentials/test")
 529	os.Setenv("AWS_DEFAULT_REGION", "ap-southeast-1")
 530
 531	cfg, err := Init(cwdDir, false)
 532
 533	require.NoError(t, err)
 534	assert.Contains(t, cfg.Providers, provider.InferenceProviderBedrock)
 535}
 536
 537func TestEnvVars_AWSBedrockRegionPriority(t *testing.T) {
 538	reset()
 539	testConfigDir = t.TempDir()
 540	cwdDir := t.TempDir()
 541
 542	os.Setenv("AWS_ACCESS_KEY_ID", "test-key")
 543	os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
 544	os.Setenv("AWS_DEFAULT_REGION", "us-west-2")
 545	os.Setenv("AWS_REGION", "us-east-1")
 546
 547	cfg, err := Init(cwdDir, false)
 548
 549	require.NoError(t, err)
 550	bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
 551	assert.Equal(t, "us-west-2", bedrockProvider.ExtraParams["region"])
 552}
 553
 554func TestEnvVars_AWSBedrockFallbackRegion(t *testing.T) {
 555	reset()
 556	testConfigDir = t.TempDir()
 557	cwdDir := t.TempDir()
 558
 559	os.Setenv("AWS_ACCESS_KEY_ID", "test-key")
 560	os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
 561	os.Setenv("AWS_REGION", "us-east-1")
 562
 563	cfg, err := Init(cwdDir, false)
 564
 565	require.NoError(t, err)
 566	bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
 567	assert.Equal(t, "us-east-1", bedrockProvider.ExtraParams["region"])
 568}
 569
 570func TestEnvVars_NoAWSCredentials(t *testing.T) {
 571	reset()
 572	testConfigDir = t.TempDir()
 573	cwdDir := t.TempDir()
 574
 575	cfg, err := Init(cwdDir, false)
 576
 577	require.NoError(t, err)
 578	assert.NotContains(t, cfg.Providers, provider.InferenceProviderBedrock)
 579}
 580
 581func TestEnvVars_CustomEnvironmentVariables(t *testing.T) {
 582	reset()
 583	testConfigDir = t.TempDir()
 584	cwdDir := t.TempDir()
 585
 586	os.Setenv("ANTHROPIC_API_KEY", "resolved-anthropic-key")
 587
 588	cfg, err := Init(cwdDir, false)
 589
 590	require.NoError(t, err)
 591	if len(cfg.Providers) > 0 {
 592		if anthropicProvider, exists := cfg.Providers[provider.InferenceProviderAnthropic]; exists {
 593			assert.Equal(t, "resolved-anthropic-key", anthropicProvider.APIKey)
 594		}
 595	}
 596}
 597
 598func TestEnvVars_CombinedEnvironmentVariables(t *testing.T) {
 599	reset()
 600	testConfigDir = t.TempDir()
 601	cwdDir := t.TempDir()
 602
 603	os.Setenv("ANTHROPIC_API_KEY", "test-anthropic")
 604	os.Setenv("OPENAI_API_KEY", "test-openai")
 605	os.Setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
 606	os.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
 607	os.Setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
 608	os.Setenv("AWS_ACCESS_KEY_ID", "test-aws-key")
 609	os.Setenv("AWS_SECRET_ACCESS_KEY", "test-aws-secret")
 610	os.Setenv("AWS_DEFAULT_REGION", "us-west-1")
 611
 612	cfg, err := Init(cwdDir, false)
 613
 614	require.NoError(t, err)
 615
 616	expectedProviders := []provider.InferenceProvider{
 617		provider.InferenceProviderAnthropic,
 618		provider.InferenceProviderOpenAI,
 619		provider.InferenceProviderVertexAI,
 620		provider.InferenceProviderBedrock,
 621	}
 622
 623	for _, expectedProvider := range expectedProviders {
 624		assert.Contains(t, cfg.Providers, expectedProvider)
 625	}
 626}
 627
 628func TestHasAWSCredentials_AccessKeys(t *testing.T) {
 629	reset()
 630
 631	os.Setenv("AWS_ACCESS_KEY_ID", "test-key")
 632	os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
 633
 634	assert.True(t, hasAWSCredentials())
 635}
 636
 637func TestHasAWSCredentials_Profile(t *testing.T) {
 638	reset()
 639
 640	os.Setenv("AWS_PROFILE", "test-profile")
 641
 642	assert.True(t, hasAWSCredentials())
 643}
 644
 645func TestHasAWSCredentials_DefaultProfile(t *testing.T) {
 646	reset()
 647
 648	os.Setenv("AWS_DEFAULT_PROFILE", "default")
 649
 650	assert.True(t, hasAWSCredentials())
 651}
 652
 653func TestHasAWSCredentials_Region(t *testing.T) {
 654	reset()
 655
 656	os.Setenv("AWS_REGION", "us-east-1")
 657
 658	assert.True(t, hasAWSCredentials())
 659}
 660
 661func TestHasAWSCredentials_ContainerCredentials(t *testing.T) {
 662	reset()
 663
 664	os.Setenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/v2/credentials/test")
 665
 666	assert.True(t, hasAWSCredentials())
 667}
 668
 669func TestHasAWSCredentials_NoCredentials(t *testing.T) {
 670	reset()
 671
 672	assert.False(t, hasAWSCredentials())
 673}
 674
 675func TestProviderMerging_GlobalToBase(t *testing.T) {
 676	reset()
 677	testConfigDir = t.TempDir()
 678	cwdDir := t.TempDir()
 679
 680	globalConfig := Config{
 681		Providers: map[provider.InferenceProvider]ProviderConfig{
 682			provider.InferenceProviderOpenAI: {
 683				ID:                provider.InferenceProviderOpenAI,
 684				APIKey:            "global-openai-key",
 685				ProviderType:      provider.TypeOpenAI,
 686				DefaultLargeModel: "gpt-4",
 687				DefaultSmallModel: "gpt-3.5-turbo",
 688				Models: []Model{
 689					{
 690						ID:               "gpt-4",
 691						Name:             "GPT-4",
 692						ContextWindow:    8192,
 693						DefaultMaxTokens: 4096,
 694					},
 695					{
 696						ID:               "gpt-3.5-turbo",
 697						Name:             "GPT-3.5 Turbo",
 698						ContextWindow:    4096,
 699						DefaultMaxTokens: 2048,
 700					},
 701				},
 702			},
 703		},
 704	}
 705
 706	configPath := filepath.Join(testConfigDir, "crush.json")
 707	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
 708	data, err := json.Marshal(globalConfig)
 709	require.NoError(t, err)
 710	require.NoError(t, os.WriteFile(configPath, data, 0o644))
 711
 712	cfg, err := Init(cwdDir, false)
 713
 714	require.NoError(t, err)
 715	assert.Len(t, cfg.Providers, 1)
 716
 717	openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
 718	assert.Equal(t, "global-openai-key", openaiProvider.APIKey)
 719	assert.Equal(t, "gpt-4", openaiProvider.DefaultLargeModel)
 720	assert.Equal(t, "gpt-3.5-turbo", openaiProvider.DefaultSmallModel)
 721	assert.Len(t, openaiProvider.Models, 2)
 722}
 723
 724func TestProviderMerging_LocalToBase(t *testing.T) {
 725	reset()
 726	testConfigDir = t.TempDir()
 727	cwdDir := t.TempDir()
 728
 729	localConfig := Config{
 730		Providers: map[provider.InferenceProvider]ProviderConfig{
 731			provider.InferenceProviderAnthropic: {
 732				ID:                provider.InferenceProviderAnthropic,
 733				APIKey:            "local-anthropic-key",
 734				ProviderType:      provider.TypeAnthropic,
 735				DefaultLargeModel: "claude-3-opus",
 736				DefaultSmallModel: "claude-3-haiku",
 737				Models: []Model{
 738					{
 739						ID:               "claude-3-opus",
 740						Name:             "Claude 3 Opus",
 741						ContextWindow:    200000,
 742						DefaultMaxTokens: 4096,
 743						CostPer1MIn:      15.0,
 744						CostPer1MOut:     75.0,
 745					},
 746					{
 747						ID:               "claude-3-haiku",
 748						Name:             "Claude 3 Haiku",
 749						ContextWindow:    200000,
 750						DefaultMaxTokens: 4096,
 751						CostPer1MIn:      0.25,
 752						CostPer1MOut:     1.25,
 753					},
 754				},
 755			},
 756		},
 757	}
 758
 759	localConfigPath := filepath.Join(cwdDir, "crush.json")
 760	data, err := json.Marshal(localConfig)
 761	require.NoError(t, err)
 762	require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
 763
 764	cfg, err := Init(cwdDir, false)
 765
 766	require.NoError(t, err)
 767	assert.Len(t, cfg.Providers, 1)
 768
 769	anthropicProvider := cfg.Providers[provider.InferenceProviderAnthropic]
 770	assert.Equal(t, "local-anthropic-key", anthropicProvider.APIKey)
 771	assert.Equal(t, "claude-3-opus", anthropicProvider.DefaultLargeModel)
 772	assert.Equal(t, "claude-3-haiku", anthropicProvider.DefaultSmallModel)
 773	assert.Len(t, anthropicProvider.Models, 2)
 774}
 775
 776func TestProviderMerging_ConflictingSettings(t *testing.T) {
 777	reset()
 778	testConfigDir = t.TempDir()
 779	cwdDir := t.TempDir()
 780
 781	globalConfig := Config{
 782		Providers: map[provider.InferenceProvider]ProviderConfig{
 783			provider.InferenceProviderOpenAI: {
 784				ID:                provider.InferenceProviderOpenAI,
 785				APIKey:            "global-key",
 786				ProviderType:      provider.TypeOpenAI,
 787				DefaultLargeModel: "gpt-4",
 788				DefaultSmallModel: "gpt-3.5-turbo",
 789				Models: []Model{
 790					{
 791						ID:               "gpt-4",
 792						Name:             "GPT-4",
 793						ContextWindow:    8192,
 794						DefaultMaxTokens: 4096,
 795					},
 796					{
 797						ID:               "gpt-3.5-turbo",
 798						Name:             "GPT-3.5 Turbo",
 799						ContextWindow:    4096,
 800						DefaultMaxTokens: 2048,
 801					},
 802					{
 803						ID:               "gpt-4-turbo",
 804						Name:             "GPT-4 Turbo",
 805						ContextWindow:    128000,
 806						DefaultMaxTokens: 4096,
 807					},
 808				},
 809			},
 810		},
 811	}
 812
 813	configPath := filepath.Join(testConfigDir, "crush.json")
 814	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
 815	data, err := json.Marshal(globalConfig)
 816	require.NoError(t, err)
 817	require.NoError(t, os.WriteFile(configPath, data, 0o644))
 818
 819	// Create local config that overrides
 820	localConfig := Config{
 821		Providers: map[provider.InferenceProvider]ProviderConfig{
 822			provider.InferenceProviderOpenAI: {
 823				APIKey:            "local-key",
 824				DefaultLargeModel: "gpt-4-turbo",
 825			},
 826		},
 827	}
 828
 829	localConfigPath := filepath.Join(cwdDir, "crush.json")
 830	data, err = json.Marshal(localConfig)
 831	require.NoError(t, err)
 832	require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
 833
 834	cfg, err := Init(cwdDir, false)
 835
 836	require.NoError(t, err)
 837
 838	openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
 839	assert.Equal(t, "local-key", openaiProvider.APIKey)
 840	assert.Equal(t, "gpt-4-turbo", openaiProvider.DefaultLargeModel)
 841	assert.False(t, openaiProvider.Disabled)
 842	assert.Equal(t, "gpt-3.5-turbo", openaiProvider.DefaultSmallModel)
 843}
 844
 845func TestProviderMerging_CustomVsKnownProviders(t *testing.T) {
 846	reset()
 847	testConfigDir = t.TempDir()
 848	cwdDir := t.TempDir()
 849
 850	customProviderID := provider.InferenceProvider("custom-provider")
 851
 852	globalConfig := Config{
 853		Providers: map[provider.InferenceProvider]ProviderConfig{
 854			provider.InferenceProviderOpenAI: {
 855				ID:                provider.InferenceProviderOpenAI,
 856				APIKey:            "openai-key",
 857				BaseURL:           "should-not-override",
 858				ProviderType:      provider.TypeAnthropic,
 859				DefaultLargeModel: "gpt-4",
 860				DefaultSmallModel: "gpt-3.5-turbo",
 861				Models: []Model{
 862					{
 863						ID:               "gpt-4",
 864						Name:             "GPT-4",
 865						ContextWindow:    8192,
 866						DefaultMaxTokens: 4096,
 867					},
 868					{
 869						ID:               "gpt-3.5-turbo",
 870						Name:             "GPT-3.5 Turbo",
 871						ContextWindow:    4096,
 872						DefaultMaxTokens: 2048,
 873					},
 874				},
 875			},
 876			customProviderID: {
 877				ID:                customProviderID,
 878				APIKey:            "custom-key",
 879				BaseURL:           "https://custom.api.com",
 880				ProviderType:      provider.TypeOpenAI,
 881				DefaultLargeModel: "custom-large",
 882				DefaultSmallModel: "custom-small",
 883				Models: []Model{
 884					{
 885						ID:               "custom-large",
 886						Name:             "Custom Large",
 887						ContextWindow:    8192,
 888						DefaultMaxTokens: 4096,
 889					},
 890					{
 891						ID:               "custom-small",
 892						Name:             "Custom Small",
 893						ContextWindow:    4096,
 894						DefaultMaxTokens: 2048,
 895					},
 896				},
 897			},
 898		},
 899	}
 900
 901	localConfig := Config{
 902		Providers: map[provider.InferenceProvider]ProviderConfig{
 903			provider.InferenceProviderOpenAI: {
 904				BaseURL:      "https://should-not-change.com",
 905				ProviderType: provider.TypeGemini, // Should not change
 906			},
 907			customProviderID: {
 908				BaseURL:      "https://updated-custom.api.com",
 909				ProviderType: provider.TypeOpenAI,
 910			},
 911		},
 912	}
 913
 914	configPath := filepath.Join(testConfigDir, "crush.json")
 915	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
 916	data, err := json.Marshal(globalConfig)
 917	require.NoError(t, err)
 918	require.NoError(t, os.WriteFile(configPath, data, 0o644))
 919
 920	localConfigPath := filepath.Join(cwdDir, "crush.json")
 921	data, err = json.Marshal(localConfig)
 922	require.NoError(t, err)
 923	require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
 924
 925	cfg, err := Init(cwdDir, false)
 926
 927	require.NoError(t, err)
 928
 929	openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
 930	assert.NotEqual(t, "https://should-not-change.com", openaiProvider.BaseURL)
 931	assert.NotEqual(t, provider.TypeGemini, openaiProvider.ProviderType)
 932
 933	customProvider := cfg.Providers[customProviderID]
 934	assert.Equal(t, "custom-key", customProvider.APIKey)
 935	assert.Equal(t, "https://updated-custom.api.com", customProvider.BaseURL)
 936	assert.Equal(t, provider.TypeOpenAI, customProvider.ProviderType)
 937}
 938
 939func TestProviderValidation_CustomProviderMissingBaseURL(t *testing.T) {
 940	reset()
 941	testConfigDir = t.TempDir()
 942	cwdDir := t.TempDir()
 943
 944	customProviderID := provider.InferenceProvider("custom-provider")
 945
 946	globalConfig := Config{
 947		Providers: map[provider.InferenceProvider]ProviderConfig{
 948			customProviderID: {
 949				ID:           customProviderID,
 950				APIKey:       "custom-key",
 951				ProviderType: provider.TypeOpenAI,
 952			},
 953		},
 954	}
 955
 956	configPath := filepath.Join(testConfigDir, "crush.json")
 957	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
 958	data, err := json.Marshal(globalConfig)
 959	require.NoError(t, err)
 960	require.NoError(t, os.WriteFile(configPath, data, 0o644))
 961
 962	cfg, err := Init(cwdDir, false)
 963
 964	require.NoError(t, err)
 965	assert.NotContains(t, cfg.Providers, customProviderID)
 966}
 967
 968func TestProviderValidation_CustomProviderMissingAPIKey(t *testing.T) {
 969	reset()
 970	testConfigDir = t.TempDir()
 971	cwdDir := t.TempDir()
 972
 973	customProviderID := provider.InferenceProvider("custom-provider")
 974
 975	globalConfig := Config{
 976		Providers: map[provider.InferenceProvider]ProviderConfig{
 977			customProviderID: {
 978				ID:           customProviderID,
 979				BaseURL:      "https://custom.api.com",
 980				ProviderType: provider.TypeOpenAI,
 981			},
 982		},
 983	}
 984
 985	configPath := filepath.Join(testConfigDir, "crush.json")
 986	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
 987	data, err := json.Marshal(globalConfig)
 988	require.NoError(t, err)
 989	require.NoError(t, os.WriteFile(configPath, data, 0o644))
 990
 991	cfg, err := Init(cwdDir, false)
 992
 993	require.NoError(t, err)
 994	assert.NotContains(t, cfg.Providers, customProviderID)
 995}
 996
 997func TestProviderValidation_CustomProviderInvalidType(t *testing.T) {
 998	reset()
 999	testConfigDir = t.TempDir()
1000	cwdDir := t.TempDir()
1001
1002	customProviderID := provider.InferenceProvider("custom-provider")
1003
1004	globalConfig := Config{
1005		Providers: map[provider.InferenceProvider]ProviderConfig{
1006			customProviderID: {
1007				ID:           customProviderID,
1008				APIKey:       "custom-key",
1009				BaseURL:      "https://custom.api.com",
1010				ProviderType: provider.Type("invalid-type"),
1011			},
1012		},
1013	}
1014
1015	configPath := filepath.Join(testConfigDir, "crush.json")
1016	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1017	data, err := json.Marshal(globalConfig)
1018	require.NoError(t, err)
1019	require.NoError(t, os.WriteFile(configPath, data, 0o644))
1020
1021	cfg, err := Init(cwdDir, false)
1022
1023	require.NoError(t, err)
1024	assert.NotContains(t, cfg.Providers, customProviderID)
1025}
1026
1027func TestProviderValidation_KnownProviderValid(t *testing.T) {
1028	reset()
1029	testConfigDir = t.TempDir()
1030	cwdDir := t.TempDir()
1031
1032	globalConfig := Config{
1033		Providers: map[provider.InferenceProvider]ProviderConfig{
1034			provider.InferenceProviderOpenAI: {
1035				ID:                provider.InferenceProviderOpenAI,
1036				APIKey:            "openai-key",
1037				ProviderType:      provider.TypeOpenAI,
1038				DefaultLargeModel: "gpt-4",
1039				DefaultSmallModel: "gpt-3.5-turbo",
1040				Models: []Model{
1041					{
1042						ID:               "gpt-4",
1043						Name:             "GPT-4",
1044						ContextWindow:    8192,
1045						DefaultMaxTokens: 4096,
1046					},
1047					{
1048						ID:               "gpt-3.5-turbo",
1049						Name:             "GPT-3.5 Turbo",
1050						ContextWindow:    4096,
1051						DefaultMaxTokens: 2048,
1052					},
1053				},
1054
1055			},
1056		},
1057	}
1058
1059	configPath := filepath.Join(testConfigDir, "crush.json")
1060	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1061	data, err := json.Marshal(globalConfig)
1062	require.NoError(t, err)
1063	require.NoError(t, os.WriteFile(configPath, data, 0o644))
1064
1065	cfg, err := Init(cwdDir, false)
1066
1067	require.NoError(t, err)
1068	assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
1069}
1070
1071func TestProviderValidation_DisabledProvider(t *testing.T) {
1072	reset()
1073	testConfigDir = t.TempDir()
1074	cwdDir := t.TempDir()
1075
1076	globalConfig := Config{
1077		Providers: map[provider.InferenceProvider]ProviderConfig{
1078			provider.InferenceProviderOpenAI: {
1079				ID:                provider.InferenceProviderOpenAI,
1080				APIKey:            "openai-key",
1081				ProviderType:      provider.TypeOpenAI,
1082				Disabled:          true,
1083				DefaultLargeModel: "gpt-4",
1084				DefaultSmallModel: "gpt-3.5-turbo",
1085				Models: []Model{
1086					{
1087						ID:               "gpt-4",
1088						Name:             "GPT-4",
1089						ContextWindow:    8192,
1090						DefaultMaxTokens: 4096,
1091					},
1092					{
1093						ID:               "gpt-3.5-turbo",
1094						Name:             "GPT-3.5 Turbo",
1095						ContextWindow:    4096,
1096						DefaultMaxTokens: 2048,
1097					},
1098				},
1099			},
1100			provider.InferenceProviderAnthropic: {
1101				ID:                provider.InferenceProviderAnthropic,
1102				APIKey:            "anthropic-key",
1103				ProviderType:      provider.TypeAnthropic,
1104				Disabled:          false, // This one is enabled
1105				DefaultLargeModel: "claude-3-opus",
1106				DefaultSmallModel: "claude-3-haiku",
1107				Models: []Model{
1108					{
1109						ID:               "claude-3-opus",
1110						Name:             "Claude 3 Opus",
1111						ContextWindow:    200000,
1112						DefaultMaxTokens: 4096,
1113					},
1114					{
1115						ID:               "claude-3-haiku",
1116						Name:             "Claude 3 Haiku",
1117						ContextWindow:    200000,
1118						DefaultMaxTokens: 4096,
1119					},
1120				},
1121			},
1122		},
1123	}
1124
1125	configPath := filepath.Join(testConfigDir, "crush.json")
1126	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1127	data, err := json.Marshal(globalConfig)
1128	require.NoError(t, err)
1129	require.NoError(t, os.WriteFile(configPath, data, 0o644))
1130
1131	cfg, err := Init(cwdDir, false)
1132
1133	require.NoError(t, err)
1134	assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
1135	assert.True(t, cfg.Providers[provider.InferenceProviderOpenAI].Disabled)
1136	assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
1137	assert.False(t, cfg.Providers[provider.InferenceProviderAnthropic].Disabled)
1138}
1139
1140func TestProviderModels_AddingNewModels(t *testing.T) {
1141	reset()
1142	testConfigDir = t.TempDir()
1143	cwdDir := t.TempDir()
1144
1145	globalConfig := Config{
1146		Providers: map[provider.InferenceProvider]ProviderConfig{
1147			provider.InferenceProviderOpenAI: {
1148				ID:                provider.InferenceProviderOpenAI,
1149				APIKey:            "openai-key",
1150				ProviderType:      provider.TypeOpenAI,
1151				DefaultLargeModel: "gpt-4",
1152				DefaultSmallModel: "gpt-4-turbo",
1153				Models: []Model{
1154					{
1155						ID:               "gpt-4",
1156						Name:             "GPT-4",
1157						ContextWindow:    8192,
1158						DefaultMaxTokens: 4096,
1159					},
1160				},
1161			},
1162		},
1163	}
1164
1165	localConfig := Config{
1166		Providers: map[provider.InferenceProvider]ProviderConfig{
1167			provider.InferenceProviderOpenAI: {
1168				Models: []Model{
1169					{
1170						ID:               "gpt-4-turbo",
1171						Name:             "GPT-4 Turbo",
1172						ContextWindow:    128000,
1173						DefaultMaxTokens: 4096,
1174					},
1175				},
1176			},
1177		},
1178	}
1179
1180	configPath := filepath.Join(testConfigDir, "crush.json")
1181	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1182	data, err := json.Marshal(globalConfig)
1183	require.NoError(t, err)
1184	require.NoError(t, os.WriteFile(configPath, data, 0o644))
1185
1186	localConfigPath := filepath.Join(cwdDir, "crush.json")
1187	data, err = json.Marshal(localConfig)
1188	require.NoError(t, err)
1189	require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1190
1191	cfg, err := Init(cwdDir, false)
1192
1193	require.NoError(t, err)
1194
1195	openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
1196	assert.Len(t, openaiProvider.Models, 2)
1197
1198	modelIDs := make([]string, len(openaiProvider.Models))
1199	for i, model := range openaiProvider.Models {
1200		modelIDs[i] = model.ID
1201	}
1202	assert.Contains(t, modelIDs, "gpt-4")
1203	assert.Contains(t, modelIDs, "gpt-4-turbo")
1204}
1205
1206func TestProviderModels_DuplicateModelHandling(t *testing.T) {
1207	reset()
1208	testConfigDir = t.TempDir()
1209	cwdDir := t.TempDir()
1210
1211	globalConfig := Config{
1212		Providers: map[provider.InferenceProvider]ProviderConfig{
1213			provider.InferenceProviderOpenAI: {
1214				ID:                provider.InferenceProviderOpenAI,
1215				APIKey:            "openai-key",
1216				ProviderType:      provider.TypeOpenAI,
1217				DefaultLargeModel: "gpt-4",
1218				DefaultSmallModel: "gpt-4",
1219				Models: []Model{
1220					{
1221						ID:               "gpt-4",
1222						Name:             "GPT-4",
1223						ContextWindow:    8192,
1224						DefaultMaxTokens: 4096,
1225					},
1226				},
1227			},
1228		},
1229	}
1230
1231	localConfig := Config{
1232		Providers: map[provider.InferenceProvider]ProviderConfig{
1233			provider.InferenceProviderOpenAI: {
1234				Models: []Model{
1235					{
1236						ID:               "gpt-4",
1237						Name:             "GPT-4 Updated",
1238						ContextWindow:    16384,
1239						DefaultMaxTokens: 8192,
1240					},
1241				},
1242			},
1243		},
1244	}
1245
1246	configPath := filepath.Join(testConfigDir, "crush.json")
1247	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1248	data, err := json.Marshal(globalConfig)
1249	require.NoError(t, err)
1250	require.NoError(t, os.WriteFile(configPath, data, 0o644))
1251
1252	localConfigPath := filepath.Join(cwdDir, "crush.json")
1253	data, err = json.Marshal(localConfig)
1254	require.NoError(t, err)
1255	require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1256
1257	cfg, err := Init(cwdDir, false)
1258
1259	require.NoError(t, err)
1260
1261	openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
1262	assert.Len(t, openaiProvider.Models, 1)
1263
1264	model := openaiProvider.Models[0]
1265	assert.Equal(t, "gpt-4", model.ID)
1266	assert.Equal(t, "GPT-4", model.Name)
1267	assert.Equal(t, int64(8192), model.ContextWindow)
1268}
1269
1270func TestProviderModels_ModelCostAndCapabilities(t *testing.T) {
1271	reset()
1272	testConfigDir = t.TempDir()
1273	cwdDir := t.TempDir()
1274
1275	globalConfig := Config{
1276		Providers: map[provider.InferenceProvider]ProviderConfig{
1277			provider.InferenceProviderOpenAI: {
1278				ID:                provider.InferenceProviderOpenAI,
1279				APIKey:            "openai-key",
1280				ProviderType:      provider.TypeOpenAI,
1281				DefaultLargeModel: "gpt-4",
1282				DefaultSmallModel: "gpt-4",
1283				Models: []Model{
1284					{
1285						ID:                 "gpt-4",
1286						Name:               "GPT-4",
1287						CostPer1MIn:        30.0,
1288						CostPer1MOut:       60.0,
1289						CostPer1MInCached:  15.0,
1290						CostPer1MOutCached: 30.0,
1291						ContextWindow:      8192,
1292						DefaultMaxTokens:   4096,
1293						CanReason:          true,
1294						ReasoningEffort:    "medium",
1295						SupportsImages:     true,
1296					},
1297				},
1298			},
1299		},
1300	}
1301
1302	configPath := filepath.Join(testConfigDir, "crush.json")
1303	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1304	data, err := json.Marshal(globalConfig)
1305	require.NoError(t, err)
1306	require.NoError(t, os.WriteFile(configPath, data, 0o644))
1307
1308	cfg, err := Init(cwdDir, false)
1309
1310	require.NoError(t, err)
1311
1312	openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
1313	require.Len(t, openaiProvider.Models, 1)
1314
1315	model := openaiProvider.Models[0]
1316	assert.Equal(t, 30.0, model.CostPer1MIn)
1317	assert.Equal(t, 60.0, model.CostPer1MOut)
1318	assert.Equal(t, 15.0, model.CostPer1MInCached)
1319	assert.Equal(t, 30.0, model.CostPer1MOutCached)
1320	assert.True(t, model.CanReason)
1321	assert.Equal(t, "medium", model.ReasoningEffort)
1322	assert.True(t, model.SupportsImages)
1323}
1324
1325func TestDefaultAgents_CoderAgent(t *testing.T) {
1326	reset()
1327	testConfigDir = t.TempDir()
1328	cwdDir := t.TempDir()
1329
1330	os.Setenv("ANTHROPIC_API_KEY", "test-key")
1331
1332	cfg, err := Init(cwdDir, false)
1333
1334	require.NoError(t, err)
1335	assert.Contains(t, cfg.Agents, AgentCoder)
1336
1337	coderAgent := cfg.Agents[AgentCoder]
1338	assert.Equal(t, AgentCoder, coderAgent.ID)
1339	assert.Equal(t, "Coder", coderAgent.Name)
1340	assert.Equal(t, "An agent that helps with executing coding tasks.", coderAgent.Description)
1341	assert.Equal(t, LargeModel, coderAgent.Model)
1342	assert.False(t, coderAgent.Disabled)
1343	assert.Equal(t, cfg.Options.ContextPaths, coderAgent.ContextPaths)
1344	assert.Nil(t, coderAgent.AllowedTools)
1345}
1346
1347func TestDefaultAgents_TaskAgent(t *testing.T) {
1348	reset()
1349	testConfigDir = t.TempDir()
1350	cwdDir := t.TempDir()
1351
1352	os.Setenv("ANTHROPIC_API_KEY", "test-key")
1353
1354	cfg, err := Init(cwdDir, false)
1355
1356	require.NoError(t, err)
1357	assert.Contains(t, cfg.Agents, AgentTask)
1358
1359	taskAgent := cfg.Agents[AgentTask]
1360	assert.Equal(t, AgentTask, taskAgent.ID)
1361	assert.Equal(t, "Task", taskAgent.Name)
1362	assert.Equal(t, "An agent that helps with searching for context and finding implementation details.", taskAgent.Description)
1363	assert.Equal(t, LargeModel, taskAgent.Model)
1364	assert.False(t, taskAgent.Disabled)
1365	assert.Equal(t, cfg.Options.ContextPaths, taskAgent.ContextPaths)
1366
1367	expectedTools := []string{"glob", "grep", "ls", "sourcegraph", "view"}
1368	assert.Equal(t, expectedTools, taskAgent.AllowedTools)
1369
1370	assert.Equal(t, map[string][]string{}, taskAgent.AllowedMCP)
1371	assert.Equal(t, []string{}, taskAgent.AllowedLSP)
1372}
1373
1374func TestAgentMerging_CustomAgent(t *testing.T) {
1375	reset()
1376	testConfigDir = t.TempDir()
1377	cwdDir := t.TempDir()
1378
1379	os.Setenv("ANTHROPIC_API_KEY", "test-key")
1380
1381	globalConfig := Config{
1382		Agents: map[AgentID]Agent{
1383			AgentID("custom-agent"): {
1384				ID:           AgentID("custom-agent"),
1385				Name:         "Custom Agent",
1386				Description:  "A custom agent for testing",
1387				Model:        SmallModel,
1388				AllowedTools: []string{"glob", "grep"},
1389				AllowedMCP:   map[string][]string{"mcp1": {"tool1", "tool2"}},
1390				AllowedLSP:   []string{"typescript", "go"},
1391				ContextPaths: []string{"custom-context.md"},
1392			},
1393		},
1394		MCP: map[string]MCP{
1395			"mcp1": {
1396				Type:    MCPStdio,
1397				Command: "test-mcp-command",
1398				Args:    []string{"--test"},
1399			},
1400		},
1401		LSP: map[string]LSPConfig{
1402			"typescript": {
1403				Command: "typescript-language-server",
1404				Args:    []string{"--stdio"},
1405			},
1406			"go": {
1407				Command: "gopls",
1408				Args:    []string{},
1409			},
1410		},
1411	}
1412
1413	configPath := filepath.Join(testConfigDir, "crush.json")
1414	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1415	data, err := json.Marshal(globalConfig)
1416	require.NoError(t, err)
1417	require.NoError(t, os.WriteFile(configPath, data, 0o644))
1418
1419	cfg, err := Init(cwdDir, false)
1420
1421	require.NoError(t, err)
1422
1423	assert.Contains(t, cfg.Agents, AgentCoder)
1424	assert.Contains(t, cfg.Agents, AgentTask)
1425	assert.Contains(t, cfg.Agents, AgentID("custom-agent"))
1426
1427	customAgent := cfg.Agents[AgentID("custom-agent")]
1428	assert.Equal(t, "Custom Agent", customAgent.Name)
1429	assert.Equal(t, "A custom agent for testing", customAgent.Description)
1430	assert.Equal(t, SmallModel, customAgent.Model)
1431	assert.Equal(t, []string{"glob", "grep"}, customAgent.AllowedTools)
1432	assert.Equal(t, map[string][]string{"mcp1": {"tool1", "tool2"}}, customAgent.AllowedMCP)
1433	assert.Equal(t, []string{"typescript", "go"}, customAgent.AllowedLSP)
1434	expectedContextPaths := append(defaultContextPaths, "custom-context.md")
1435	assert.Equal(t, expectedContextPaths, customAgent.ContextPaths)
1436}
1437
1438func TestAgentMerging_ModifyDefaultCoderAgent(t *testing.T) {
1439	reset()
1440	testConfigDir = t.TempDir()
1441	cwdDir := t.TempDir()
1442
1443	os.Setenv("ANTHROPIC_API_KEY", "test-key")
1444
1445	globalConfig := Config{
1446		Agents: map[AgentID]Agent{
1447			AgentCoder: {
1448				Model:        SmallModel,
1449				AllowedMCP:   map[string][]string{"mcp1": {"tool1"}},
1450				AllowedLSP:   []string{"typescript"},
1451				ContextPaths: []string{"coder-specific.md"},
1452			},
1453		},
1454		MCP: map[string]MCP{
1455			"mcp1": {
1456				Type:    MCPStdio,
1457				Command: "test-mcp-command",
1458				Args:    []string{"--test"},
1459			},
1460		},
1461		LSP: map[string]LSPConfig{
1462			"typescript": {
1463				Command: "typescript-language-server",
1464				Args:    []string{"--stdio"},
1465			},
1466		},
1467	}
1468
1469	configPath := filepath.Join(testConfigDir, "crush.json")
1470	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1471	data, err := json.Marshal(globalConfig)
1472	require.NoError(t, err)
1473	require.NoError(t, os.WriteFile(configPath, data, 0o644))
1474
1475	cfg, err := Init(cwdDir, false)
1476
1477	require.NoError(t, err)
1478
1479	coderAgent := cfg.Agents[AgentCoder]
1480	assert.Equal(t, AgentCoder, coderAgent.ID)
1481	assert.Equal(t, "Coder", coderAgent.Name)
1482	assert.Equal(t, "An agent that helps with executing coding tasks.", coderAgent.Description)
1483
1484	expectedContextPaths := append(cfg.Options.ContextPaths, "coder-specific.md")
1485	assert.Equal(t, expectedContextPaths, coderAgent.ContextPaths)
1486
1487	assert.Equal(t, SmallModel, coderAgent.Model)
1488	assert.Equal(t, map[string][]string{"mcp1": {"tool1"}}, coderAgent.AllowedMCP)
1489	assert.Equal(t, []string{"typescript"}, coderAgent.AllowedLSP)
1490}
1491
1492func TestAgentMerging_ModifyDefaultTaskAgent(t *testing.T) {
1493	reset()
1494	testConfigDir = t.TempDir()
1495	cwdDir := t.TempDir()
1496
1497	os.Setenv("ANTHROPIC_API_KEY", "test-key")
1498
1499	globalConfig := Config{
1500		Agents: map[AgentID]Agent{
1501			AgentTask: {
1502				Model:        SmallModel,
1503				AllowedMCP:   map[string][]string{"search-mcp": nil},
1504				AllowedLSP:   []string{"python"},
1505				Name:         "Search Agent",
1506				Description:  "Custom search agent",
1507				Disabled:     true,
1508				AllowedTools: []string{"glob", "grep", "view"},
1509			},
1510		},
1511		MCP: map[string]MCP{
1512			"search-mcp": {
1513				Type:    MCPStdio,
1514				Command: "search-mcp-command",
1515				Args:    []string{"--search"},
1516			},
1517		},
1518		LSP: map[string]LSPConfig{
1519			"python": {
1520				Command: "pylsp",
1521				Args:    []string{},
1522			},
1523		},
1524	}
1525
1526	configPath := filepath.Join(testConfigDir, "crush.json")
1527	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1528	data, err := json.Marshal(globalConfig)
1529	require.NoError(t, err)
1530	require.NoError(t, os.WriteFile(configPath, data, 0o644))
1531
1532	cfg, err := Init(cwdDir, false)
1533
1534	require.NoError(t, err)
1535
1536	taskAgent := cfg.Agents[AgentTask]
1537	assert.Equal(t, "Task", taskAgent.Name)
1538	assert.Equal(t, "An agent that helps with searching for context and finding implementation details.", taskAgent.Description)
1539	assert.False(t, taskAgent.Disabled)
1540	assert.Equal(t, []string{"glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
1541
1542	assert.Equal(t, SmallModel, taskAgent.Model)
1543	assert.Equal(t, map[string][]string{"search-mcp": nil}, taskAgent.AllowedMCP)
1544	assert.Equal(t, []string{"python"}, taskAgent.AllowedLSP)
1545}
1546
1547func TestAgentMerging_LocalOverridesGlobal(t *testing.T) {
1548	reset()
1549	testConfigDir = t.TempDir()
1550	cwdDir := t.TempDir()
1551
1552	os.Setenv("ANTHROPIC_API_KEY", "test-key")
1553
1554	globalConfig := Config{
1555		Agents: map[AgentID]Agent{
1556			AgentID("test-agent"): {
1557				ID:           AgentID("test-agent"),
1558				Name:         "Global Agent",
1559				Description:  "Global description",
1560				Model:        LargeModel,
1561				AllowedTools: []string{"glob"},
1562			},
1563		},
1564	}
1565
1566	configPath := filepath.Join(testConfigDir, "crush.json")
1567	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1568	data, err := json.Marshal(globalConfig)
1569	require.NoError(t, err)
1570	require.NoError(t, os.WriteFile(configPath, data, 0o644))
1571
1572	// Create local config that overrides
1573	localConfig := Config{
1574		Agents: map[AgentID]Agent{
1575			AgentID("test-agent"): {
1576				Name:         "Local Agent",
1577				Description:  "Local description",
1578				Model:        SmallModel,
1579				Disabled:     true,
1580				AllowedTools: []string{"grep", "view"},
1581				AllowedMCP:   map[string][]string{"local-mcp": {"tool1"}},
1582			},
1583		},
1584		MCP: map[string]MCP{
1585			"local-mcp": {
1586				Type:    MCPStdio,
1587				Command: "local-mcp-command",
1588				Args:    []string{"--local"},
1589			},
1590		},
1591	}
1592
1593	localConfigPath := filepath.Join(cwdDir, "crush.json")
1594	data, err = json.Marshal(localConfig)
1595	require.NoError(t, err)
1596	require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1597
1598	cfg, err := Init(cwdDir, false)
1599
1600	require.NoError(t, err)
1601
1602	testAgent := cfg.Agents[AgentID("test-agent")]
1603	assert.Equal(t, "Local Agent", testAgent.Name)
1604	assert.Equal(t, "Local description", testAgent.Description)
1605	assert.Equal(t, SmallModel, testAgent.Model)
1606	assert.True(t, testAgent.Disabled)
1607	assert.Equal(t, []string{"grep", "view"}, testAgent.AllowedTools)
1608	assert.Equal(t, map[string][]string{"local-mcp": {"tool1"}}, testAgent.AllowedMCP)
1609}
1610
1611func TestAgentModelTypeAssignment(t *testing.T) {
1612	reset()
1613	testConfigDir = t.TempDir()
1614	cwdDir := t.TempDir()
1615
1616	os.Setenv("ANTHROPIC_API_KEY", "test-key")
1617
1618	globalConfig := Config{
1619		Agents: map[AgentID]Agent{
1620			AgentID("large-agent"): {
1621				ID:    AgentID("large-agent"),
1622				Name:  "Large Model Agent",
1623				Model: LargeModel,
1624			},
1625			AgentID("small-agent"): {
1626				ID:    AgentID("small-agent"),
1627				Name:  "Small Model Agent",
1628				Model: SmallModel,
1629			},
1630			AgentID("default-agent"): {
1631				ID:   AgentID("default-agent"),
1632				Name: "Default Model Agent",
1633			},
1634		},
1635	}
1636
1637	configPath := filepath.Join(testConfigDir, "crush.json")
1638	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1639	data, err := json.Marshal(globalConfig)
1640	require.NoError(t, err)
1641	require.NoError(t, os.WriteFile(configPath, data, 0o644))
1642
1643	cfg, err := Init(cwdDir, false)
1644
1645	require.NoError(t, err)
1646
1647	assert.Equal(t, LargeModel, cfg.Agents[AgentID("large-agent")].Model)
1648	assert.Equal(t, SmallModel, cfg.Agents[AgentID("small-agent")].Model)
1649	assert.Equal(t, LargeModel, cfg.Agents[AgentID("default-agent")].Model)
1650}
1651
1652func TestAgentContextPathOverrides(t *testing.T) {
1653	reset()
1654	testConfigDir = t.TempDir()
1655	cwdDir := t.TempDir()
1656
1657	os.Setenv("ANTHROPIC_API_KEY", "test-key")
1658
1659	globalConfig := Config{
1660		Options: Options{
1661			ContextPaths: []string{"global-context.md", "shared-context.md"},
1662		},
1663		Agents: map[AgentID]Agent{
1664			AgentID("custom-context-agent"): {
1665				ID:           AgentID("custom-context-agent"),
1666				Name:         "Custom Context Agent",
1667				ContextPaths: []string{"agent-specific.md", "custom.md"},
1668			},
1669			AgentID("default-context-agent"): {
1670				ID:   AgentID("default-context-agent"),
1671				Name: "Default Context Agent",
1672			},
1673		},
1674	}
1675
1676	configPath := filepath.Join(testConfigDir, "crush.json")
1677	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1678	data, err := json.Marshal(globalConfig)
1679	require.NoError(t, err)
1680	require.NoError(t, os.WriteFile(configPath, data, 0o644))
1681
1682	cfg, err := Init(cwdDir, false)
1683
1684	require.NoError(t, err)
1685
1686	customAgent := cfg.Agents[AgentID("custom-context-agent")]
1687	expectedCustomPaths := append(defaultContextPaths, "global-context.md", "shared-context.md", "agent-specific.md", "custom.md")
1688	assert.Equal(t, expectedCustomPaths, customAgent.ContextPaths)
1689
1690	defaultAgent := cfg.Agents[AgentID("default-context-agent")]
1691	expectedContextPaths := append(defaultContextPaths, "global-context.md", "shared-context.md")
1692	assert.Equal(t, expectedContextPaths, defaultAgent.ContextPaths)
1693
1694	coderAgent := cfg.Agents[AgentCoder]
1695	assert.Equal(t, expectedContextPaths, coderAgent.ContextPaths)
1696}
1697
1698func TestOptionsMerging_ContextPaths(t *testing.T) {
1699	reset()
1700	testConfigDir = t.TempDir()
1701	cwdDir := t.TempDir()
1702
1703	os.Setenv("ANTHROPIC_API_KEY", "test-key")
1704
1705	globalConfig := Config{
1706		Options: Options{
1707			ContextPaths: []string{"global1.md", "global2.md"},
1708		},
1709	}
1710
1711	configPath := filepath.Join(testConfigDir, "crush.json")
1712	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1713	data, err := json.Marshal(globalConfig)
1714	require.NoError(t, err)
1715	require.NoError(t, os.WriteFile(configPath, data, 0o644))
1716
1717	localConfig := Config{
1718		Options: Options{
1719			ContextPaths: []string{"local1.md", "local2.md"},
1720		},
1721	}
1722
1723	localConfigPath := filepath.Join(cwdDir, "crush.json")
1724	data, err = json.Marshal(localConfig)
1725	require.NoError(t, err)
1726	require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1727
1728	cfg, err := Init(cwdDir, false)
1729
1730	require.NoError(t, err)
1731
1732	expectedContextPaths := append(defaultContextPaths, "global1.md", "global2.md", "local1.md", "local2.md")
1733	assert.Equal(t, expectedContextPaths, cfg.Options.ContextPaths)
1734}
1735
1736func TestOptionsMerging_TUIOptions(t *testing.T) {
1737	reset()
1738	testConfigDir = t.TempDir()
1739	cwdDir := t.TempDir()
1740
1741	os.Setenv("ANTHROPIC_API_KEY", "test-key")
1742
1743	globalConfig := Config{
1744		Options: Options{
1745			TUI: TUIOptions{
1746				CompactMode: false,
1747			},
1748		},
1749	}
1750
1751	configPath := filepath.Join(testConfigDir, "crush.json")
1752	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1753	data, err := json.Marshal(globalConfig)
1754	require.NoError(t, err)
1755	require.NoError(t, os.WriteFile(configPath, data, 0o644))
1756
1757	localConfig := Config{
1758		Options: Options{
1759			TUI: TUIOptions{
1760				CompactMode: true,
1761			},
1762		},
1763	}
1764
1765	localConfigPath := filepath.Join(cwdDir, "crush.json")
1766	data, err = json.Marshal(localConfig)
1767	require.NoError(t, err)
1768	require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1769
1770	cfg, err := Init(cwdDir, false)
1771
1772	require.NoError(t, err)
1773
1774	assert.True(t, cfg.Options.TUI.CompactMode)
1775}
1776
1777func TestOptionsMerging_DebugFlags(t *testing.T) {
1778	reset()
1779	testConfigDir = t.TempDir()
1780	cwdDir := t.TempDir()
1781
1782	os.Setenv("ANTHROPIC_API_KEY", "test-key")
1783
1784	globalConfig := Config{
1785		Options: Options{
1786			Debug:                false,
1787			DebugLSP:             false,
1788			DisableAutoSummarize: false,
1789		},
1790	}
1791
1792	configPath := filepath.Join(testConfigDir, "crush.json")
1793	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1794	data, err := json.Marshal(globalConfig)
1795	require.NoError(t, err)
1796	require.NoError(t, os.WriteFile(configPath, data, 0o644))
1797
1798	localConfig := Config{
1799		Options: Options{
1800			DebugLSP:             true,
1801			DisableAutoSummarize: true,
1802		},
1803	}
1804
1805	localConfigPath := filepath.Join(cwdDir, "crush.json")
1806	data, err = json.Marshal(localConfig)
1807	require.NoError(t, err)
1808	require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1809
1810	cfg, err := Init(cwdDir, false)
1811
1812	require.NoError(t, err)
1813
1814	assert.False(t, cfg.Options.Debug)
1815	assert.True(t, cfg.Options.DebugLSP)
1816	assert.True(t, cfg.Options.DisableAutoSummarize)
1817}
1818
1819func TestOptionsMerging_DataDirectory(t *testing.T) {
1820	reset()
1821	testConfigDir = t.TempDir()
1822	cwdDir := t.TempDir()
1823
1824	os.Setenv("ANTHROPIC_API_KEY", "test-key")
1825
1826	globalConfig := Config{
1827		Options: Options{
1828			DataDirectory: "global-data",
1829		},
1830	}
1831
1832	configPath := filepath.Join(testConfigDir, "crush.json")
1833	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1834	data, err := json.Marshal(globalConfig)
1835	require.NoError(t, err)
1836	require.NoError(t, os.WriteFile(configPath, data, 0o644))
1837
1838	localConfig := Config{
1839		Options: Options{
1840			DataDirectory: "local-data",
1841		},
1842	}
1843
1844	localConfigPath := filepath.Join(cwdDir, "crush.json")
1845	data, err = json.Marshal(localConfig)
1846	require.NoError(t, err)
1847	require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1848
1849	cfg, err := Init(cwdDir, false)
1850
1851	require.NoError(t, err)
1852
1853	assert.Equal(t, "local-data", cfg.Options.DataDirectory)
1854}
1855
1856func TestOptionsMerging_DefaultValues(t *testing.T) {
1857	reset()
1858	testConfigDir = t.TempDir()
1859	cwdDir := t.TempDir()
1860
1861	os.Setenv("ANTHROPIC_API_KEY", "test-key")
1862
1863	cfg, err := Init(cwdDir, false)
1864
1865	require.NoError(t, err)
1866
1867	assert.Equal(t, defaultDataDirectory, cfg.Options.DataDirectory)
1868	assert.Equal(t, defaultContextPaths, cfg.Options.ContextPaths)
1869	assert.False(t, cfg.Options.TUI.CompactMode)
1870	assert.False(t, cfg.Options.Debug)
1871	assert.False(t, cfg.Options.DebugLSP)
1872	assert.False(t, cfg.Options.DisableAutoSummarize)
1873}
1874
1875func TestOptionsMerging_DebugFlagFromInit(t *testing.T) {
1876	reset()
1877	testConfigDir = t.TempDir()
1878	cwdDir := t.TempDir()
1879
1880	os.Setenv("ANTHROPIC_API_KEY", "test-key")
1881
1882	globalConfig := Config{
1883		Options: Options{
1884			Debug: false,
1885		},
1886	}
1887
1888	configPath := filepath.Join(testConfigDir, "crush.json")
1889	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1890	data, err := json.Marshal(globalConfig)
1891	require.NoError(t, err)
1892	require.NoError(t, os.WriteFile(configPath, data, 0o644))
1893
1894	cfg, err := Init(cwdDir, true)
1895
1896	require.NoError(t, err)
1897
1898	// Debug flag from Init should take precedence
1899	assert.True(t, cfg.Options.Debug)
1900}
1901
1902func TestOptionsMerging_ComplexScenario(t *testing.T) {
1903	reset()
1904	testConfigDir = t.TempDir()
1905	cwdDir := t.TempDir()
1906
1907	// Set up a provider
1908	os.Setenv("ANTHROPIC_API_KEY", "test-key")
1909
1910	// Create global config with various options
1911	globalConfig := Config{
1912		Options: Options{
1913			ContextPaths:         []string{"global-context.md"},
1914			DataDirectory:        "global-data",
1915			Debug:                false,
1916			DebugLSP:             false,
1917			DisableAutoSummarize: false,
1918			TUI: TUIOptions{
1919				CompactMode: false,
1920			},
1921		},
1922	}
1923
1924	configPath := filepath.Join(testConfigDir, "crush.json")
1925	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1926	data, err := json.Marshal(globalConfig)
1927	require.NoError(t, err)
1928	require.NoError(t, os.WriteFile(configPath, data, 0o644))
1929
1930	// Create local config that partially overrides
1931	localConfig := Config{
1932		Options: Options{
1933			ContextPaths:         []string{"local-context.md"},
1934			DebugLSP:             true, // Override
1935			DisableAutoSummarize: true, // Override
1936			TUI: TUIOptions{
1937				CompactMode: true, // Override
1938			},
1939			// DataDirectory and Debug not specified - should keep global values
1940		},
1941	}
1942
1943	localConfigPath := filepath.Join(cwdDir, "crush.json")
1944	data, err = json.Marshal(localConfig)
1945	require.NoError(t, err)
1946	require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1947
1948	cfg, err := Init(cwdDir, false)
1949
1950	require.NoError(t, err)
1951
1952	// Check merged results
1953	expectedContextPaths := append(defaultContextPaths, "global-context.md", "local-context.md")
1954	assert.Equal(t, expectedContextPaths, cfg.Options.ContextPaths)
1955	assert.Equal(t, "global-data", cfg.Options.DataDirectory) // From global
1956	assert.False(t, cfg.Options.Debug)                        // From global
1957	assert.True(t, cfg.Options.DebugLSP)                      // From local
1958	assert.True(t, cfg.Options.DisableAutoSummarize)          // From local
1959	assert.True(t, cfg.Options.TUI.CompactMode)               // From local
1960}
1961
1962// Model Selection Tests
1963
1964func TestModelSelection_PreferredModelSelection(t *testing.T) {
1965	reset()
1966	testConfigDir = t.TempDir()
1967	cwdDir := t.TempDir()
1968
1969	// Set up multiple providers to test selection logic
1970	os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
1971	os.Setenv("OPENAI_API_KEY", "test-openai-key")
1972
1973	cfg, err := Init(cwdDir, false)
1974
1975	require.NoError(t, err)
1976	require.Len(t, cfg.Providers, 2)
1977
1978	// Should have preferred models set
1979	assert.NotEmpty(t, cfg.Models.Large.ModelID)
1980	assert.NotEmpty(t, cfg.Models.Large.Provider)
1981	assert.NotEmpty(t, cfg.Models.Small.ModelID)
1982	assert.NotEmpty(t, cfg.Models.Small.Provider)
1983
1984	// Both should use the same provider (first available)
1985	assert.Equal(t, cfg.Models.Large.Provider, cfg.Models.Small.Provider)
1986}
1987
1988func TestValidation_InvalidModelReference(t *testing.T) {
1989	reset()
1990	testConfigDir = t.TempDir()
1991	cwdDir := t.TempDir()
1992
1993	globalConfig := Config{
1994		Providers: map[provider.InferenceProvider]ProviderConfig{
1995			provider.InferenceProviderOpenAI: {
1996				ID:                provider.InferenceProviderOpenAI,
1997				APIKey:            "test-key",
1998				ProviderType:      provider.TypeOpenAI,
1999				DefaultLargeModel: "non-existent-model",
2000				DefaultSmallModel: "gpt-3.5-turbo",
2001				Models: []Model{
2002					{
2003						ID:               "gpt-3.5-turbo",
2004						Name:             "GPT-3.5 Turbo",
2005						ContextWindow:    4096,
2006						DefaultMaxTokens: 2048,
2007					},
2008				},
2009			},
2010		},
2011	}
2012
2013	configPath := filepath.Join(testConfigDir, "crush.json")
2014	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
2015	data, err := json.Marshal(globalConfig)
2016	require.NoError(t, err)
2017	require.NoError(t, os.WriteFile(configPath, data, 0o644))
2018
2019	_, err = Init(cwdDir, false)
2020	assert.Error(t, err)
2021}
2022
2023func TestValidation_EmptyAPIKey(t *testing.T) {
2024	reset()
2025	testConfigDir = t.TempDir()
2026	cwdDir := t.TempDir()
2027
2028	globalConfig := Config{
2029		Providers: map[provider.InferenceProvider]ProviderConfig{
2030			provider.InferenceProviderOpenAI: {
2031				ID:           provider.InferenceProviderOpenAI,
2032				ProviderType: provider.TypeOpenAI,
2033				Models: []Model{
2034					{
2035						ID:               "gpt-4",
2036						Name:             "GPT-4",
2037						ContextWindow:    8192,
2038						DefaultMaxTokens: 4096,
2039					},
2040				},
2041			},
2042		},
2043	}
2044
2045	configPath := filepath.Join(testConfigDir, "crush.json")
2046	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
2047	data, err := json.Marshal(globalConfig)
2048	require.NoError(t, err)
2049	require.NoError(t, os.WriteFile(configPath, data, 0o644))
2050
2051	_, err = Init(cwdDir, false)
2052	assert.Error(t, err)
2053}
2054
2055func TestValidation_InvalidAgentModelType(t *testing.T) {
2056	reset()
2057	testConfigDir = t.TempDir()
2058	cwdDir := t.TempDir()
2059
2060	os.Setenv("ANTHROPIC_API_KEY", "test-key")
2061
2062	globalConfig := Config{
2063		Agents: map[AgentID]Agent{
2064			AgentID("invalid-agent"): {
2065				ID:    AgentID("invalid-agent"),
2066				Name:  "Invalid Agent",
2067				Model: ModelType("invalid"),
2068			},
2069		},
2070	}
2071
2072	configPath := filepath.Join(testConfigDir, "crush.json")
2073	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
2074	data, err := json.Marshal(globalConfig)
2075	require.NoError(t, err)
2076	require.NoError(t, os.WriteFile(configPath, data, 0o644))
2077
2078	_, err = Init(cwdDir, false)
2079	assert.Error(t, err)
2080}