all repos — llm_aggregator @ 658b2cfd3cd0eca5835b921100e08569a426e199

A CLI tool to aggregate RSS feeds and summarise them with LLMs

internal/config/config_test.go (view raw)

  1package config
  2
  3import (
  4	"os"
  5	"path/filepath"
  6	"strings"
  7	"testing"
  8
  9	"github.com/spf13/viper"
 10
 11	"llm_aggregator/internal/cli"
 12	"llm_aggregator/internal/defaults"
 13)
 14
 15func TestDefaultConfig(t *testing.T) {
 16	cfg := DefaultConfig()
 17	
 18	if cfg.MaxArticlesPerFeed != 10 {
 19		t.Errorf("Expected MaxArticlesPerFeed = 10, got %d", cfg.MaxArticlesPerFeed)
 20	}
 21	
 22	if cfg.MaxDaysOld != 7 {
 23		t.Errorf("Expected MaxDaysOld = 7, got %d", cfg.MaxDaysOld)
 24	}
 25	
 26	if cfg.MaxTotalArticles != 20 {
 27		t.Errorf("Expected MaxTotalArticles = 20, got %d", cfg.MaxTotalArticles)
 28	}
 29	
 30	if cfg.Model != "deepseek-chat" {
 31		t.Errorf("Expected Model = 'deepseek-chat', got %s", cfg.Model)
 32	}
 33	
 34	if cfg.MaxTokens != 4000 {
 35		t.Errorf("Expected MaxTokens = 4000, got %d", cfg.MaxTokens)
 36	}
 37	
 38	if cfg.Temperature != 0.7 {
 39		t.Errorf("Expected Temperature = 0.7, got %f", cfg.Temperature)
 40	}
 41	
 42	expectedPrompt := `You are an expert analyst and summariser.
 43You analyse content from multiple sources and provide
 44concise, insightful summaries based on user requests.
 45Focus on key points, trends, and important information.`
 46	
 47	if cfg.SystemPrompt != expectedPrompt {
 48		t.Errorf("SystemPrompt doesn't match expected default")
 49	}
 50	
 51	if cfg.Output != "text" {
 52		t.Errorf("Expected Output = 'text', got %s", cfg.Output)
 53	}
 54}
 55
 56func TestConfigPath(t *testing.T) {
 57	path, err := GetConfigPath()
 58	if err != nil {
 59		t.Fatalf("Failed to get config path: %v", err)
 60	}
 61	
 62	// Should end with .config/llm_aggregator/config.toml
 63	expectedSuffix := filepath.Join(".config", "llm_aggregator", "config.toml")
 64	if !hasSuffix(path, expectedSuffix) {
 65		t.Errorf("Config path %s doesn't end with %s", path, expectedSuffix)
 66	}
 67}
 68
 69func TestConfigSaveAndLoad(t *testing.T) {
 70	// Create temporary directory for test
 71	tempDir := t.TempDir()
 72	oldEnv := os.Getenv("XDG_CONFIG_HOME")
 73	os.Setenv("XDG_CONFIG_HOME", tempDir)
 74	defer os.Setenv("XDG_CONFIG_HOME", oldEnv)
 75	
 76	cfg := DefaultConfig()
 77	cfg.SystemPrompt = "Test system prompt"
 78	cfg.Model = "test-model"
 79	cfg.MaxTokens = 1234
 80	
 81	// Save config
 82	if err := cfg.Save(); err != nil {
 83		t.Fatalf("Failed to save config: %v", err)
 84	}
 85	
 86	// Verify file exists
 87	configPath, err := GetConfigPath()
 88	if err != nil {
 89		t.Fatalf("Failed to get config path: %v", err)
 90	}
 91	
 92	if _, err := os.Stat(configPath); os.IsNotExist(err) {
 93		t.Fatalf("Config file not created at %s", configPath)
 94	}
 95	
 96	// Load config
 97	loadedCfg, err := Load()
 98	if err != nil {
 99		t.Fatalf("Failed to load config: %v", err)
100	}
101	
102	if !loadedCfg.IsLoaded() {
103		t.Error("Config should be marked as loaded")
104	}
105	
106	if loadedCfg.SystemPrompt != cfg.SystemPrompt {
107		t.Errorf("SystemPrompt mismatch: expected %q, got %q", cfg.SystemPrompt, loadedCfg.SystemPrompt)
108	}
109	
110	if loadedCfg.Model != cfg.Model {
111		t.Errorf("Model mismatch: expected %s, got %s", cfg.Model, loadedCfg.Model)
112	}
113	
114	if loadedCfg.MaxTokens != cfg.MaxTokens {
115		t.Errorf("MaxTokens mismatch: expected %d, got %d", cfg.MaxTokens, loadedCfg.MaxTokens)
116	}
117}
118
119func TestEnvironmentVariables(t *testing.T) {
120	// Create temporary directory for test
121	tempDir := t.TempDir()
122	oldEnv := os.Getenv("XDG_CONFIG_HOME")
123	os.Setenv("XDG_CONFIG_HOME", tempDir)
124	defer os.Setenv("XDG_CONFIG_HOME", oldEnv)
125	
126	// Set environment variables
127	os.Setenv("LLM_AGGREGATOR_SYSTEM_PROMPT", "Env system prompt")
128	os.Setenv("LLM_AGGREGATOR_MODEL", "env-model")
129	os.Setenv("LLM_AGGREGATOR_MAX_TOKENS", "9999")
130	defer func() {
131		os.Unsetenv("LLM_AGGREGATOR_SYSTEM_PROMPT")
132		os.Unsetenv("LLM_AGGREGATOR_MODEL")
133		os.Unsetenv("LLM_AGGREGATOR_MAX_TOKENS")
134	}()
135	
136	// Load config (should pick up env vars)
137	cfg, err := Load()
138	if err != nil {
139		t.Fatalf("Failed to load config: %v", err)
140	}
141	
142	if cfg.SystemPrompt != "Env system prompt" {
143		t.Errorf("SystemPrompt should be from env var, got %q", cfg.SystemPrompt)
144	}
145	
146	if cfg.Model != "env-model" {
147		t.Errorf("Model should be from env var, got %s", cfg.Model)
148	}
149	
150	if cfg.MaxTokens != 9999 {
151		t.Errorf("MaxTokens should be from env var, got %d", cfg.MaxTokens)
152	}
153}
154
155func TestConfigExists(t *testing.T) {
156	// Create temporary directory for test
157	tempDir := t.TempDir()
158	oldEnv := os.Getenv("XDG_CONFIG_HOME")
159	os.Setenv("XDG_CONFIG_HOME", tempDir)
160	defer os.Setenv("XDG_CONFIG_HOME", oldEnv)
161	
162	// Get config path to verify
163	configPath, err := GetConfigPath()
164	if err != nil {
165		t.Fatalf("Failed to get config path: %v", err)
166	}
167	
168	// Ensure it's in our temp directory
169	if !contains(configPath, tempDir) {
170		t.Logf("Config path %s not in temp dir %s", configPath, tempDir)
171	}
172	
173	// Initially should not exist
174	exists, err := ConfigExists()
175	if err != nil {
176		t.Fatalf("Failed to check config existence: %v", err)
177	}
178	
179	if exists {
180		// Config exists, maybe from previous test run
181		t.Logf("Config exists at %s, removing...", configPath)
182		os.Remove(configPath)
183		// Check again
184		exists, err = ConfigExists()
185		if err != nil {
186			t.Fatalf("Failed to check config existence after removal: %v", err)
187		}
188		if exists {
189			t.Error("Config should not exist after removal")
190		}
191	}
192	
193	// Create config
194	cfg := DefaultConfig()
195	if err := cfg.Save(); err != nil {
196		t.Fatalf("Failed to save config: %v", err)
197	}
198	
199	// Now should exist
200	exists, err = ConfigExists()
201	if err != nil {
202		t.Fatalf("Failed to check config existence: %v", err)
203	}
204	
205	if !exists {
206		t.Error("Config should exist after saving")
207	}
208}
209
210// TestIsZero tests the isZero helper function with various types including pointers.
211func TestIsZero(t *testing.T) {
212	tests := []struct {
213		name     string
214		value    any
215		expected bool
216	}{
217		// String tests
218		{"empty string is zero", "", true},
219		{"non-empty string is not zero", "hello", false},
220
221		// Int tests
222		{"zero int is zero", 0, true},
223		{"non-zero int is not zero", 42, false},
224
225		// Float tests
226		{"zero float is zero", 0.0, true},
227		{"non-zero float is not zero", 0.7, false},
228
229		// Bool tests
230		{"false bool is zero", false, true},
231		{"true bool is not zero", true, false},
232
233		// Pointer tests - the bug we fixed
234		{"nil *string is zero", (*string)(nil), true},
235		{"non-nil *string is not zero", strPtr("hello"), false},
236		{"nil *int is zero", (*int)(nil), true},
237		{"non-nil *int is not zero", intPtr(42), false},
238		{"nil *float64 is zero", (*float64)(nil), true},
239		{"non-nil *float64 is not zero", floatPtr(0.7), false},
240		{"nil *bool is zero", (*bool)(nil), true},
241		{"non-nil *bool is not zero", boolPtr(true), false},
242
243		// Unknown type
244		{"nil interface is zero", nil, true},
245	}
246
247	for _, tt := range tests {
248		t.Run(tt.name, func(t *testing.T) {
249			result := isZero(tt.value)
250			if result != tt.expected {
251				t.Errorf("isZero(%v) = %v, want %v", tt.value, result, tt.expected)
252			}
253		})
254	}
255}
256
257// TestViperToRuntimePrecedence tests that ViperToRuntime correctly respects precedence.
258// Order from highest to lowest: CLI args > Environment variables > Config file > Defaults
259func TestViperToRuntimePrecedence(t *testing.T) {
260	// Create temporary directory for test
261	tempDir := t.TempDir()
262	oldEnv := os.Getenv("XDG_CONFIG_HOME")
263	os.Setenv("XDG_CONFIG_HOME", tempDir)
264	defer func() {
265		os.Setenv("XDG_CONFIG_HOME", oldEnv)
266	}()
267
268	// Save a config file with known values
269	configuredModel := "config-file-model"
270	configuredBaseURL := "https://config-file.example.com"
271	configuredTemperature := 0.3
272
273	cfg := DefaultConfig()
274	cfg.Model = configuredModel
275	cfg.BaseURL = configuredBaseURL
276	cfg.Temperature = configuredTemperature
277	cfg.MaxTokens = 5000
278
279	if err := cfg.Save(); err != nil {
280		t.Fatalf("Failed to save config: %v", err)
281	}
282
283	// Test 1: Config file should override defaults
284	t.Run("config file overrides defaults", func(t *testing.T) {
285		// Clear any env vars
286		os.Unsetenv("LLM_AGGREGATOR_MODEL")
287		os.Unsetenv("LLM_AGGREGATOR_BASE_URL")
288		os.Unsetenv("LLM_AGGREGATOR_TEMPERATURE")
289		os.Unsetenv("LLM_AGGREGATOR_MAX_TOKENS")
290
291		v := GetViper()
292		rt := ViperToRuntime(v, "/tmp/feeds.txt", "test prompt")
293
294		if rt.Model != configuredModel {
295			t.Errorf("Model = %q, want %q (config file should override default)", rt.Model, configuredModel)
296		}
297		if rt.BaseURL != configuredBaseURL {
298			t.Errorf("BaseURL = %q, want %q", rt.BaseURL, configuredBaseURL)
299		}
300	})
301
302	// Test 2: Environment variables should override config file
303	t.Run("environment variables override config file", func(t *testing.T) {
304		envModel := "env-override-model"
305		envBaseURL := "https://env-override.example.com"
306		os.Setenv("LLM_AGGREGATOR_MODEL", envModel)
307		os.Setenv("LLM_AGGREGATOR_BASE_URL", envBaseURL)
308		defer func() {
309			os.Unsetenv("LLM_AGGREGATOR_MODEL")
310			os.Unsetenv("LLM_AGGREGATOR_BASE_URL")
311		}()
312
313		v := GetViper()
314		rt := ViperToRuntime(v, "/tmp/feeds.txt", "test prompt")
315
316		if rt.Model != envModel {
317			t.Errorf("Model = %q, want %q (env var should override config file)", rt.Model, envModel)
318		}
319		if rt.BaseURL != envBaseURL {
320			t.Errorf("BaseURL = %q, want %q", rt.BaseURL, envBaseURL)
321		}
322	})
323
324	// Test 3: CLI args (via BindCLIArgs) should override env vars and config file
325	t.Run("CLI args override environment variables and config file", func(t *testing.T) {
326		// Create a fresh viper instance to avoid polluting global state
327		v := viper.New()
328		v.SetDefault("model", "default-model")
329		v.SetDefault("base_url", "https://default.example.com")
330		v.SetDefault("temperature", 0.7)
331
332		// Set env vars that should be overridden
333		os.Setenv("LLM_AGGREGATOR_MODEL", "env-should-be-overridden")
334		os.Setenv("LLM_AGGREGATOR_BASE_URL", "https://env-should-be-overridden.example.com")
335		defer func() {
336			os.Unsetenv("LLM_AGGREGATOR_MODEL")
337			os.Unsetenv("LLM_AGGREGATOR_BASE_URL")
338		}()
339
340		// Simulate CLI args binding - using pointer types like the actual Args struct
341		cliModel := "cli-override-model"
342		cliBaseURL := "https://cli-override.example.com"
343		cliTemperature := 0.9
344
345		cliArgs := map[string]any{
346			"model":       &cliModel,
347			"base_url":    &cliBaseURL,
348			"temperature": &cliTemperature,
349		}
350		BindCLIArgs(v, cliArgs)
351
352		if v.GetString("model") != cliModel {
353			t.Errorf("model = %q, want %q (CLI should override env var)", v.GetString("model"), cliModel)
354		}
355		if v.GetString("base_url") != cliBaseURL {
356			t.Errorf("base_url = %q, want %q", v.GetString("base_url"), cliBaseURL)
357		}
358		if v.GetFloat64("temperature") != cliTemperature {
359			t.Errorf("temperature = %f, want %f", v.GetFloat64("temperature"), cliTemperature)
360		}
361	})
362
363	// Test 4: Nil/unset CLI args should NOT override existing values
364	// Note: This test verifies that when a CLI arg is nil (not provided), the existing
365	// value in viper is preserved. However, since viper doesn't support unsetting keys,
366	// and the test uses a fresh viper without BindEnv, we can't properly test the
367	// env var persistence here. This is actually a test design limitation.
368	t.Run("nil CLI args do not override viper values", func(t *testing.T) {
369		// Create a fresh viper instance to avoid polluting global state
370		v := viper.New()
371		v.SetDefault("model", "default-model")
372
373		// Simulate CLI args where some values were not provided (nil pointers)
374		var nilModel *string = nil
375		cliArgs := map[string]any{
376			"model": nilModel, // Not provided on CLI
377		}
378		BindCLIArgs(v, cliArgs)
379
380		// Since we didn't call BindEnv or Set any value, model should keep its default
381		if v.GetString("model") != "default-model" {
382			t.Errorf("model = %q, want %q (nil CLI arg should preserve viper default)", v.GetString("model"), "default-model")
383		}
384	})
385
386	// Test 5: Empty string CLI args should NOT override existing values
387	t.Run("empty string CLI args do not override viper values", func(t *testing.T) {
388		// Create a fresh viper instance to avoid polluting global state
389		v := viper.New()
390		v.SetDefault("model", "default-model")
391
392		// Simulate CLI args where value was provided as empty string
393		cliArgs := map[string]any{
394			"model": "", // Empty string should not override
395		}
396		BindCLIArgs(v, cliArgs)
397
398		// Empty string is a "zero" value for string type, so it should not override
399		if v.GetString("model") != "default-model" {
400			t.Errorf("model = %q, want %q (empty CLI arg should preserve viper default)", v.GetString("model"), "default-model")
401		}
402	})
403}
404
405// TestBindCLIArgsWithPointers tests BindCLIArgs specifically with pointer types.
406func TestBindCLIArgsWithPointers(t *testing.T) {
407	// Test that pointer types are handled correctly by isZero
408	t.Run("BindCLIArgs with pointer types", func(t *testing.T) {
409		v := viper.New()
410		v.SetDefault("model", "default-model")
411		v.SetDefault("max_tokens", 1000)
412		v.SetDefault("temperature", 0.7)
413
414		// Simulate CLI args with pointer types (as Args struct now uses)
415		model := "cli-model"
416		maxTokens := 2000
417		temperature := 0.5
418
419		args := map[string]any{
420			"model":       &model,
421			"max_tokens":  &maxTokens,
422			"temperature": &temperature,
423		}
424		BindCLIArgs(v, args)
425
426		if v.GetString("model") != "cli-model" {
427			t.Errorf("model = %q, want %q", v.GetString("model"), "cli-model")
428		}
429		if v.GetInt("max_tokens") != 2000 {
430			t.Errorf("max_tokens = %d, want %d", v.GetInt("max_tokens"), 2000)
431		}
432		if v.GetFloat64("temperature") != 0.5 {
433			t.Errorf("temperature = %f, want %f", v.GetFloat64("temperature"), 0.5)
434		}
435	})
436
437	t.Run("BindCLIArgs with nil pointers does not override", func(t *testing.T) {
438		v := viper.New()
439		v.SetDefault("model", "default-model")
440		v.SetDefault("max_tokens", 1000)
441
442		// Simulate CLI args where nothing was provided (all nil)
443		var nilStr *string = nil
444		var nilInt *int = nil
445
446		args := map[string]any{
447			"model":      nilStr,
448			"max_tokens": nilInt,
449		}
450		BindCLIArgs(v, args)
451
452		// Should keep defaults
453		if v.GetString("model") != "default-model" {
454			t.Errorf("model = %q, want %q (nil should not override)", v.GetString("model"), "default-model")
455		}
456		if v.GetInt("max_tokens") != 1000 {
457			t.Errorf("max_tokens = %d, want %d", v.GetInt("max_tokens"), 1000)
458		}
459	})
460
461	t.Run("BindCLIArgs with zero int pointer DOES override (user explicitly passed --max-tokens 0)", func(t *testing.T) {
462		v := viper.New()
463		v.SetDefault("max_tokens", 1000)
464
465		zero := 0
466		args := map[string]any{
467			"max_tokens": &zero, // User explicitly passed --max-tokens 0, so 0 SHOULD override
468		}
469		BindCLIArgs(v, args)
470
471		if v.GetInt("max_tokens") != 0 {
472			t.Errorf("max_tokens = %d, want %d (explicit 0 should override default)", v.GetInt("max_tokens"), 0)
473		}
474	})
475
476	t.Run("BindCLIArgs with zero float64 pointer DOES override (user explicitly passed --temperature 0)", func(t *testing.T) {
477		v := viper.New()
478		v.SetDefault("temperature", 0.7)
479
480		zero := 0.0
481		args := map[string]any{
482			"temperature": &zero, // User explicitly passed --temperature 0, so 0 SHOULD override
483		}
484		BindCLIArgs(v, args)
485
486		if v.GetFloat64("temperature") != 0.0 {
487			t.Errorf("temperature = %f, want %f (explicit 0 should override default)", v.GetFloat64("temperature"), 0.0)
488		}
489	})
490}
491
492// Helper functions for creating pointers
493func strPtr(s string) *string { return &s }
494func intPtr(i int) *int { return &i }
495func floatPtr(f float64) *float64 { return &f }
496func boolPtr(b bool) *bool { return &b }
497
498// Helper function to check if string contains substring
499func contains(s, substr string) bool {
500	return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
501		(s[0:len(substr)] == substr || contains(s[1:], substr)))
502}
503
504// Helper function to check if path has suffix
505func hasSuffix(path, suffix string) bool {
506	if len(path) < len(suffix) {
507		return false
508	}
509	return path[len(path)-len(suffix):] == suffix
510}
511
512// TestConfigParsingAlwaysPasses ensures that parsing of options always passes.
513// This tests the integration between CLI args, config loading, and Viper.
514func TestConfigParsingAlwaysPasses(t *testing.T) {
515	// Create temporary directory for test
516	tempDir := t.TempDir()
517	oldEnv := os.Getenv("XDG_CONFIG_HOME")
518	os.Setenv("XDG_CONFIG_HOME", tempDir)
519	defer func() {
520		os.Setenv("XDG_CONFIG_HOME", oldEnv)
521	}()
522
523	// Create a test feeds file
524	feedsFile := filepath.Join(tempDir, "feeds.txt")
525	if err := os.WriteFile(feedsFile, []byte("https://example.com/feed.xml\nhttps://example.org/feed.xml"), 0644); err != nil {
526		t.Fatalf("Failed to create test feeds file: %v", err)
527	}
528
529	tests := []struct {
530		name        string
531		cliArgs     map[string]string
532		expectFeeds string
533		expectModel string
534	}{
535		{
536			name: "default configuration",
537			cliArgs: map[string]string{
538				"--feeds-file": feedsFile,
539				"--prompt":     "Test prompt",
540			},
541			expectFeeds: feedsFile,
542			expectModel: "deepseek-chat",
543		},
544		{
545			name: "custom model via CLI",
546			cliArgs: map[string]string{
547				"--feeds-file": feedsFile,
548				"--prompt":     "Test prompt",
549				"--model":      "gpt-4",
550			},
551			expectFeeds: feedsFile,
552			expectModel: "gpt-4",
553		},
554		{
555			name: "custom api key via CLI",
556			cliArgs: map[string]string{
557				"--feeds-file": feedsFile,
558				"--prompt":     "Test prompt",
559				"--api-key":    "sk-test-key-12345",
560			},
561			expectFeeds: feedsFile,
562			expectModel: "deepseek-chat",
563		},
564		{
565			name: "all CLI options",
566			cliArgs: map[string]string{
567				"--feeds-file":            feedsFile,
568				"--prompt":                "Summarise tech news",
569				"--api-key":               "sk-test-key",
570				"--model":                 "deepseek-coder",
571				"--max-articles-per-feed": "5",
572				"--max-days-old":          "3",
573				"--max-total-articles":    "15",
574				"--include-keywords":      "ai,ml",
575				"--exclude-keywords":     "advertisement",
576				"--max-tokens":            "1000",
577				"--temperature":          "0.3",
578				"--output":                "json",
579			},
580			expectFeeds: feedsFile,
581			expectModel: "deepseek-coder",
582		},
583	}
584
585	for _, tt := range tests {
586		t.Run(tt.name, func(t *testing.T) {
587			// Build CLI args slice
588			args := []string{"llm_aggregator"}
589			for k, v := range tt.cliArgs {
590				// Remove leading dashes for go-arg compatibility
591				key := k
592				if strings.HasPrefix(key, "--") {
593					key = key[2:]
594				}
595				args = append(args, "--"+key, v)
596			}
597
598			// Save original os.Args
599			origArgs := os.Args
600			defer func() { os.Args = origArgs }()
601			os.Args = args
602
603			// Parse arguments - this should never fail for valid inputs
604			parsedArgs, err := cli.ParseArgs()
605			if err != nil {
606				t.Fatalf("Failed to parse CLI args: %v", err)
607			}
608
609			// Verify feeds file path is set correctly
610			if parsedArgs.FeedsFile != tt.expectFeeds {
611				t.Errorf("FeedsFile = %q, want %q", parsedArgs.FeedsFile, tt.expectFeeds)
612			}
613
614			// Verify prompt is set
615			if parsedArgs.Prompt == "" {
616				t.Error("Prompt should not be empty")
617			}
618
619			// Verify model matches expected
620			if parsedArgs.Model != nil && *parsedArgs.Model != tt.expectModel {
621				t.Errorf("Model = %q, want %q", *parsedArgs.Model, tt.expectModel)
622			}
623
624			// Verify API key if provided
625			if apiKey, ok := tt.cliArgs["--api-key"]; ok {
626				if parsedArgs.APIKey != nil && *parsedArgs.APIKey != apiKey {
627					t.Errorf("APIKey = %q, want %q", *parsedArgs.APIKey, apiKey)
628				}
629			}
630		})
631	}
632}
633
634// TestConfigLoadWithVariousSources tests that configuration loading works
635// regardless of the source (CLI, env, config file, defaults)
636func TestConfigLoadWithVariousSources(t *testing.T) {
637	// Create temporary directory for test
638	tempDir := t.TempDir()
639	oldEnv := os.Getenv("XDG_CONFIG_HOME")
640	os.Setenv("XDG_CONFIG_HOME", tempDir)
641	defer func() {
642		os.Setenv("XDG_CONFIG_HOME", oldEnv)
643	}()
644
645	// Test 1: Config loads with saved config file
646	t.Run("load with saved config file", func(t *testing.T) {
647		cfg := DefaultConfig()
648		cfg.Model = "saved-model"
649		cfg.MaxArticlesPerFeed = 30
650		cfg.Temperature = 0.9
651
652		if err := cfg.Save(); err != nil {
653			t.Fatalf("Save() failed: %v", err)
654		}
655
656		loadedCfg, err := Load()
657		if err != nil {
658			t.Fatalf("Load() failed: %v", err)
659		}
660
661		if loadedCfg.Model != "saved-model" {
662			t.Errorf("Model = %q, want %q", loadedCfg.Model, "saved-model")
663		}
664		if loadedCfg.MaxArticlesPerFeed != 30 {
665			t.Errorf("MaxArticlesPerFeed = %d, want 30", loadedCfg.MaxArticlesPerFeed)
666		}
667		if loadedCfg.Temperature != 0.9 {
668			t.Errorf("Temperature = %f, want 0.9", loadedCfg.Temperature)
669		}
670	})
671
672	// Test 2: Verify DefaultConfig returns correct defaults
673	t.Run("default config has correct values", func(t *testing.T) {
674		cfg := DefaultConfig()
675
676		if cfg.MaxArticlesPerFeed != defaults.DefaultMaxArticlesPerFeed {
677			t.Errorf("MaxArticlesPerFeed = %d, want %d", cfg.MaxArticlesPerFeed, defaults.DefaultMaxArticlesPerFeed)
678		}
679		if cfg.MaxDaysOld != defaults.DefaultMaxDaysOld {
680			t.Errorf("MaxDaysOld = %d, want %d", cfg.MaxDaysOld, defaults.DefaultMaxDaysOld)
681		}
682		if cfg.MaxTotalArticles != defaults.DefaultMaxTotalArticles {
683			t.Errorf("MaxTotalArticles = %d, want %d", cfg.MaxTotalArticles, defaults.DefaultMaxTotalArticles)
684		}
685		if cfg.BaseURL != defaults.DefaultBaseURL {
686			t.Errorf("BaseURL = %q, want %q", cfg.BaseURL, defaults.DefaultBaseURL)
687		}
688		if cfg.Model != defaults.DefaultModel {
689			t.Errorf("Model = %q, want %q", cfg.Model, defaults.DefaultModel)
690		}
691		if cfg.MaxTokens != defaults.DefaultMaxTokens {
692			t.Errorf("MaxTokens = %d, want %d", cfg.MaxTokens, defaults.DefaultMaxTokens)
693		}
694		if cfg.Temperature != defaults.DefaultTemperature {
695			t.Errorf("Temperature = %f, want %f", cfg.Temperature, defaults.DefaultTemperature)
696		}
697		if cfg.Output != defaults.DefaultOutput {
698			t.Errorf("Output = %q, want %q", cfg.Output, defaults.DefaultOutput)
699		}
700		if cfg.IncludeArticles != defaults.DefaultIncludeArticles {
701			t.Errorf("IncludeArticles = %v, want %v", cfg.IncludeArticles, defaults.DefaultIncludeArticles)
702		}
703	})
704
705	// Test 3: Verify ConfigExists works correctly
706	t.Run("config exists check", func(t *testing.T) {
707		// Should not exist initially (new temp dir)
708		exists, err := ConfigExists()
709		if err != nil {
710			t.Fatalf("ConfigExists() failed: %v", err)
711		}
712
713		// Create a config
714		cfg := DefaultConfig()
715		if err := cfg.Save(); err != nil {
716			t.Fatalf("Save() failed: %v", err)
717		}
718
719		// Now should exist
720		exists, err = ConfigExists()
721		if err != nil {
722			t.Fatalf("ConfigExists() failed after save: %v", err)
723		}
724		if !exists {
725			t.Error("ConfigExists() should return true after saving")
726		}
727	})
728}
729
730// TestViperToConfigConversion tests the ViperToConfig conversion function.
731func TestViperToConfigConversion(t *testing.T) {
732	// Create a fresh viper instance
733	v := viper.New()
734	v.Set("max_articles_per_feed", 15)
735	v.Set("max_days_old", 14)
736	v.Set("max_total_articles", 50)
737	v.Set("include_keywords", "ai,ml")
738	v.Set("exclude_keywords", "spam")
739	v.Set("api_key", "sk-test-key")
740	v.Set("base_url", "https://api.example.com")
741	v.Set("model", "test-model")
742	v.Set("max_tokens", 3000)
743	v.Set("temperature", 0.5)
744	v.Set("system_prompt", "Test system prompt")
745	v.Set("output", "markdown")
746	v.Set("output_file", "/tmp/output.md")
747	v.Set("include_articles", true)
748
749	cfg := ViperToConfig(v)
750
751	if cfg.MaxArticlesPerFeed != 15 {
752		t.Errorf("MaxArticlesPerFeed = %d, want 15", cfg.MaxArticlesPerFeed)
753	}
754	if cfg.MaxDaysOld != 14 {
755		t.Errorf("MaxDaysOld = %d, want 14", cfg.MaxDaysOld)
756	}
757	if cfg.MaxTotalArticles != 50 {
758		t.Errorf("MaxTotalArticles = %d, want 50", cfg.MaxTotalArticles)
759	}
760	if cfg.IncludeKeywords != "ai,ml" {
761		t.Errorf("IncludeKeywords = %q, want %q", cfg.IncludeKeywords, "ai,ml")
762	}
763	if cfg.ExcludeKeywords != "spam" {
764		t.Errorf("ExcludeKeywords = %q, want %q", cfg.ExcludeKeywords, "spam")
765	}
766	if cfg.APIKey != "sk-test-key" {
767		t.Errorf("APIKey = %q, want %q", cfg.APIKey, "sk-test-key")
768	}
769	if cfg.BaseURL != "https://api.example.com" {
770		t.Errorf("BaseURL = %q, want %q", cfg.BaseURL, "https://api.example.com")
771	}
772	if cfg.Model != "test-model" {
773		t.Errorf("Model = %q, want %q", cfg.Model, "test-model")
774	}
775	if cfg.MaxTokens != 3000 {
776		t.Errorf("MaxTokens = %d, want 3000", cfg.MaxTokens)
777	}
778	if cfg.Temperature != 0.5 {
779		t.Errorf("Temperature = %f, want 0.5", cfg.Temperature)
780	}
781	if cfg.SystemPrompt != "Test system prompt" {
782		t.Errorf("SystemPrompt = %q, want %q", cfg.SystemPrompt, "Test system prompt")
783	}
784	if cfg.Output != "markdown" {
785		t.Errorf("Output = %q, want %q", cfg.Output, "markdown")
786	}
787	if cfg.OutputFile != "/tmp/output.md" {
788		t.Errorf("OutputFile = %q, want %q", cfg.OutputFile, "/tmp/output.md")
789	}
790	if cfg.IncludeArticles != true {
791		t.Errorf("IncludeArticles = %v, want true", cfg.IncludeArticles)
792	}
793}
794
795// TestBindCLIArgs tests the BindCLIArgs function.
796func TestBindCLIArgs(t *testing.T) {
797	// Create a fresh viper instance with defaults
798	v := viper.New()
799	v.SetDefault("model", "default-model")
800	v.SetDefault("max_tokens", 1000)
801
802	// Bind CLI args with override values
803	args := map[string]any{
804		"model":      "cli-model",
805		"max_tokens": 2000,
806	}
807	BindCLIArgs(v, args)
808
809	if v.GetString("model") != "cli-model" {
810		t.Errorf("model = %q, want %q", v.GetString("model"), "cli-model")
811	}
812	if v.GetInt("max_tokens") != 2000 {
813		t.Errorf("max_tokens = %d, want %d", v.GetInt("max_tokens"), 2000)
814	}
815
816	// Test that zero/empty values don't override
817	v2 := viper.New()
818	v2.SetDefault("model", "default-model")
819	v2.SetDefault("temperature", 0.7)
820
821	args2 := map[string]any{
822		"model":      "", // Empty string should not override
823		"temperature": 0,  // Zero should not override
824	}
825	BindCLIArgs(v2, args2)
826
827	if v2.GetString("model") != "default-model" {
828		t.Errorf("model = %q, want %q (empty should not override)", v2.GetString("model"), "default-model")
829	}
830	if v2.GetFloat64("temperature") != 0.7 {
831		t.Errorf("temperature = %f, want %f (zero should not override)", v2.GetFloat64("temperature"), 0.7)
832	}
833}