1package config
2
3import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "github.com/kujtimiihoxha/termai/internal/llm/models"
10 "github.com/spf13/viper"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13)
14
15func TestLoad(t *testing.T) {
16 setupTest(t)
17
18 t.Run("loads configuration successfully", func(t *testing.T) {
19 homeDir := t.TempDir()
20 t.Setenv("HOME", homeDir)
21 configPath := filepath.Join(homeDir, ".termai.json")
22
23 configContent := `{
24 "data": {
25 "directory": "custom-dir"
26 },
27 "log": {
28 "level": "debug"
29 },
30 "mcpServers": {
31 "test-server": {
32 "command": "test-command",
33 "env": ["TEST_ENV=value"],
34 "args": ["--arg1", "--arg2"],
35 "type": "stdio",
36 "url": "",
37 "headers": {}
38 },
39 "sse-server": {
40 "command": "",
41 "env": [],
42 "args": [],
43 "type": "sse",
44 "url": "https://api.example.com/events",
45 "headers": {
46 "Authorization": "Bearer token123",
47 "Content-Type": "application/json"
48 }
49 }
50 },
51 "providers": {
52 "anthropic": {
53 "apiKey": "test-api-key",
54 "enabled": true
55 }
56 },
57 "model": {
58 "coder": "claude-3-haiku",
59 "task": "claude-3-haiku"
60 }
61 }`
62 err := os.WriteFile(configPath, []byte(configContent), 0o644)
63 require.NoError(t, err)
64
65 cfg = nil
66 viper.Reset()
67
68 err = Load(false)
69 require.NoError(t, err)
70
71 config := Get()
72 assert.NotNil(t, config)
73 assert.Equal(t, "custom-dir", config.Data.Directory)
74 assert.Equal(t, "debug", config.Log.Level)
75
76 assert.Contains(t, config.MCPServers, "test-server")
77 stdioServer := config.MCPServers["test-server"]
78 assert.Equal(t, "test-command", stdioServer.Command)
79 assert.Equal(t, []string{"TEST_ENV=value"}, stdioServer.Env)
80 assert.Equal(t, []string{"--arg1", "--arg2"}, stdioServer.Args)
81 assert.Equal(t, MCPStdio, stdioServer.Type)
82 assert.Equal(t, "", stdioServer.URL)
83 assert.Empty(t, stdioServer.Headers)
84
85 assert.Contains(t, config.MCPServers, "sse-server")
86 sseServer := config.MCPServers["sse-server"]
87 assert.Equal(t, "", sseServer.Command)
88 assert.Empty(t, sseServer.Env)
89 assert.Empty(t, sseServer.Args)
90 assert.Equal(t, MCPSse, sseServer.Type)
91 assert.Equal(t, "https://api.example.com/events", sseServer.URL)
92 assert.Equal(t, map[string]string{
93 "authorization": "Bearer token123",
94 "content-type": "application/json",
95 }, sseServer.Headers)
96
97 assert.Contains(t, config.Providers, models.ModelProvider("anthropic"))
98 provider := config.Providers[models.ModelProvider("anthropic")]
99 assert.Equal(t, "test-api-key", provider.APIKey)
100 assert.True(t, provider.Enabled)
101
102 assert.NotNil(t, config.Model)
103 assert.Equal(t, models.Claude3Haiku, config.Model.Coder)
104 assert.Equal(t, models.Claude3Haiku, config.Model.Task)
105 assert.Equal(t, defaultMaxTokens, config.Model.CoderMaxTokens)
106 })
107
108 t.Run("loads configuration with environment variables", func(t *testing.T) {
109 homeDir := t.TempDir()
110 t.Setenv("HOME", homeDir)
111 configPath := filepath.Join(homeDir, ".termai.json")
112 err := os.WriteFile(configPath, []byte("{}"), 0o644)
113 require.NoError(t, err)
114
115 t.Setenv("ANTHROPIC_API_KEY", "env-anthropic-key")
116 t.Setenv("OPENAI_API_KEY", "env-openai-key")
117 t.Setenv("GEMINI_API_KEY", "env-gemini-key")
118
119 cfg = nil
120 viper.Reset()
121
122 err = Load(false)
123 require.NoError(t, err)
124
125 config := Get()
126 assert.NotNil(t, config)
127
128 assert.Equal(t, defaultDataDirectory, config.Data.Directory)
129 assert.Equal(t, defaultLogLevel, config.Log.Level)
130
131 assert.Contains(t, config.Providers, models.ModelProvider("anthropic"))
132 assert.Equal(t, "env-anthropic-key", config.Providers[models.ModelProvider("anthropic")].APIKey)
133 assert.True(t, config.Providers[models.ModelProvider("anthropic")].Enabled)
134
135 assert.Contains(t, config.Providers, models.ModelProvider("openai"))
136 assert.Equal(t, "env-openai-key", config.Providers[models.ModelProvider("openai")].APIKey)
137 assert.True(t, config.Providers[models.ModelProvider("openai")].Enabled)
138
139 assert.Contains(t, config.Providers, models.ModelProvider("gemini"))
140 assert.Equal(t, "env-gemini-key", config.Providers[models.ModelProvider("gemini")].APIKey)
141 assert.True(t, config.Providers[models.ModelProvider("gemini")].Enabled)
142
143 assert.Equal(t, models.Claude37Sonnet, config.Model.Coder)
144 })
145
146 t.Run("local config overrides global config", func(t *testing.T) {
147 homeDir := t.TempDir()
148 t.Setenv("HOME", homeDir)
149 globalConfigPath := filepath.Join(homeDir, ".termai.json")
150 globalConfig := `{
151 "data": {
152 "directory": "global-dir"
153 },
154 "log": {
155 "level": "info"
156 }
157 }`
158 err := os.WriteFile(globalConfigPath, []byte(globalConfig), 0o644)
159 require.NoError(t, err)
160
161 workDir := t.TempDir()
162 origDir, err := os.Getwd()
163 require.NoError(t, err)
164 defer os.Chdir(origDir)
165 err = os.Chdir(workDir)
166 require.NoError(t, err)
167
168 localConfigPath := filepath.Join(workDir, ".termai.json")
169 localConfig := `{
170 "data": {
171 "directory": "local-dir"
172 },
173 "log": {
174 "level": "debug"
175 }
176 }`
177 err = os.WriteFile(localConfigPath, []byte(localConfig), 0o644)
178 require.NoError(t, err)
179
180 cfg = nil
181 viper.Reset()
182
183 err = Load(false)
184 require.NoError(t, err)
185
186 config := Get()
187 assert.NotNil(t, config)
188
189 assert.Equal(t, "local-dir", config.Data.Directory)
190 assert.Equal(t, "debug", config.Log.Level)
191 })
192
193 t.Run("missing config file should not return error", func(t *testing.T) {
194 emptyDir := t.TempDir()
195 t.Setenv("HOME", emptyDir)
196
197 cfg = nil
198 viper.Reset()
199
200 err := Load(false)
201 assert.NoError(t, err)
202 })
203
204 t.Run("model priority and fallbacks", func(t *testing.T) {
205 testCases := []struct {
206 name string
207 anthropicKey string
208 openaiKey string
209 geminiKey string
210 expectedModel models.ModelID
211 explicitModel models.ModelID
212 useExplicitModel bool
213 }{
214 {
215 name: "anthropic has priority",
216 anthropicKey: "test-key",
217 openaiKey: "test-key",
218 geminiKey: "test-key",
219 expectedModel: models.Claude37Sonnet,
220 },
221 {
222 name: "fallback to openai when no anthropic",
223 anthropicKey: "",
224 openaiKey: "test-key",
225 geminiKey: "test-key",
226 expectedModel: models.GPT41,
227 },
228 {
229 name: "fallback to gemini when no others",
230 anthropicKey: "",
231 openaiKey: "",
232 geminiKey: "test-key",
233 expectedModel: models.GRMINI20Flash,
234 },
235 {
236 name: "explicit model overrides defaults",
237 anthropicKey: "test-key",
238 openaiKey: "test-key",
239 geminiKey: "test-key",
240 explicitModel: models.GPT41,
241 useExplicitModel: true,
242 expectedModel: models.GPT41,
243 },
244 }
245
246 for _, tc := range testCases {
247 t.Run(tc.name, func(t *testing.T) {
248 homeDir := t.TempDir()
249 t.Setenv("HOME", homeDir)
250 configPath := filepath.Join(homeDir, ".termai.json")
251
252 configContent := "{}"
253 if tc.useExplicitModel {
254 configContent = fmt.Sprintf(`{"model":{"coder":"%s"}}`, tc.explicitModel)
255 }
256
257 err := os.WriteFile(configPath, []byte(configContent), 0o644)
258 require.NoError(t, err)
259
260 if tc.anthropicKey != "" {
261 t.Setenv("ANTHROPIC_API_KEY", tc.anthropicKey)
262 } else {
263 t.Setenv("ANTHROPIC_API_KEY", "")
264 }
265
266 if tc.openaiKey != "" {
267 t.Setenv("OPENAI_API_KEY", tc.openaiKey)
268 } else {
269 t.Setenv("OPENAI_API_KEY", "")
270 }
271
272 if tc.geminiKey != "" {
273 t.Setenv("GEMINI_API_KEY", tc.geminiKey)
274 } else {
275 t.Setenv("GEMINI_API_KEY", "")
276 }
277
278 cfg = nil
279 viper.Reset()
280
281 err = Load(false)
282 require.NoError(t, err)
283
284 config := Get()
285 assert.NotNil(t, config)
286 assert.Equal(t, tc.expectedModel, config.Model.Coder)
287 })
288 }
289 })
290}
291
292func TestGet(t *testing.T) {
293 t.Run("get returns same config instance", func(t *testing.T) {
294 setupTest(t)
295 homeDir := t.TempDir()
296 t.Setenv("HOME", homeDir)
297 configPath := filepath.Join(homeDir, ".termai.json")
298 err := os.WriteFile(configPath, []byte("{}"), 0o644)
299 require.NoError(t, err)
300
301 cfg = nil
302 viper.Reset()
303
304 config1 := Get()
305 require.NotNil(t, config1)
306
307 config2 := Get()
308 require.NotNil(t, config2)
309
310 assert.Same(t, config1, config2)
311 })
312
313 t.Run("get loads config if not loaded", func(t *testing.T) {
314 setupTest(t)
315 homeDir := t.TempDir()
316 t.Setenv("HOME", homeDir)
317 configPath := filepath.Join(homeDir, ".termai.json")
318 configContent := `{"data":{"directory":"test-dir"}}`
319 err := os.WriteFile(configPath, []byte(configContent), 0o644)
320 require.NoError(t, err)
321
322 cfg = nil
323 viper.Reset()
324
325 config := Get()
326 require.NotNil(t, config)
327 assert.Equal(t, "test-dir", config.Data.Directory)
328 })
329}
330
331func TestWorkingDirectory(t *testing.T) {
332 t.Run("returns current working directory", func(t *testing.T) {
333 setupTest(t)
334 homeDir := t.TempDir()
335 t.Setenv("HOME", homeDir)
336 configPath := filepath.Join(homeDir, ".termai.json")
337 err := os.WriteFile(configPath, []byte("{}"), 0o644)
338 require.NoError(t, err)
339
340 cfg = nil
341 viper.Reset()
342
343 err = Load(false)
344 require.NoError(t, err)
345
346 wd := WorkingDirectory()
347 expectedWd, err := os.Getwd()
348 require.NoError(t, err)
349 assert.Equal(t, expectedWd, wd)
350 })
351}
352
353func TestWrite(t *testing.T) {
354 t.Run("writes config to file", func(t *testing.T) {
355 setupTest(t)
356 homeDir := t.TempDir()
357 t.Setenv("HOME", homeDir)
358 configPath := filepath.Join(homeDir, ".termai.json")
359 err := os.WriteFile(configPath, []byte("{}"), 0o644)
360 require.NoError(t, err)
361
362 cfg = nil
363 viper.Reset()
364
365 err = Load(false)
366 require.NoError(t, err)
367
368 viper.Set("data.directory", "modified-dir")
369
370 err = Write()
371 require.NoError(t, err)
372
373 content, err := os.ReadFile(configPath)
374 require.NoError(t, err)
375 assert.Contains(t, string(content), "modified-dir")
376 })
377}
378
379func TestMCPType(t *testing.T) {
380 t.Run("MCPType constants", func(t *testing.T) {
381 assert.Equal(t, MCPType("stdio"), MCPStdio)
382 assert.Equal(t, MCPType("sse"), MCPSse)
383 })
384
385 t.Run("MCPType JSON unmarshaling", func(t *testing.T) {
386 homeDir := t.TempDir()
387 t.Setenv("HOME", homeDir)
388 configPath := filepath.Join(homeDir, ".termai.json")
389
390 configContent := `{
391 "mcpServers": {
392 "stdio-server": {
393 "type": "stdio"
394 },
395 "sse-server": {
396 "type": "sse"
397 },
398 "invalid-server": {
399 "type": "invalid"
400 }
401 }
402 }`
403 err := os.WriteFile(configPath, []byte(configContent), 0o644)
404 require.NoError(t, err)
405
406 cfg = nil
407 viper.Reset()
408
409 err = Load(false)
410 require.NoError(t, err)
411
412 config := Get()
413 assert.NotNil(t, config)
414
415 assert.Equal(t, MCPStdio, config.MCPServers["stdio-server"].Type)
416 assert.Equal(t, MCPSse, config.MCPServers["sse-server"].Type)
417 assert.Equal(t, MCPType("invalid"), config.MCPServers["invalid-server"].Type)
418 })
419
420 t.Run("default MCPType", func(t *testing.T) {
421 homeDir := t.TempDir()
422 t.Setenv("HOME", homeDir)
423 configPath := filepath.Join(homeDir, ".termai.json")
424
425 configContent := `{
426 "mcpServers": {
427 "test-server": {
428 "command": "test-command"
429 }
430 }
431 }`
432 err := os.WriteFile(configPath, []byte(configContent), 0o644)
433 require.NoError(t, err)
434
435 cfg = nil
436 viper.Reset()
437
438 err = Load(false)
439 require.NoError(t, err)
440
441 config := Get()
442 assert.NotNil(t, config)
443
444 assert.Equal(t, MCPType(""), config.MCPServers["test-server"].Type)
445 })
446}
447
448func setupTest(t *testing.T) {
449 origHome := os.Getenv("HOME")
450 origXdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
451 origAnthropicKey := os.Getenv("ANTHROPIC_API_KEY")
452 origOpenAIKey := os.Getenv("OPENAI_API_KEY")
453 origGeminiKey := os.Getenv("GEMINI_API_KEY")
454
455 t.Cleanup(func() {
456 t.Setenv("HOME", origHome)
457 t.Setenv("XDG_CONFIG_HOME", origXdgConfigHome)
458 t.Setenv("ANTHROPIC_API_KEY", origAnthropicKey)
459 t.Setenv("OPENAI_API_KEY", origOpenAIKey)
460 t.Setenv("GEMINI_API_KEY", origGeminiKey)
461
462 cfg = nil
463 viper.Reset()
464 })
465}