all repos — llm_aggregator @ 2e7ca2fff0eca5f948de3dec07502b00d7f03039

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

internal/config/config_test.go (view raw)

  1package config
  2
  3import (
  4	"fmt"
  5	"os"
  6	"path/filepath"
  7	"strings"
  8	"testing"
  9
 10	"github.com/spf13/viper"
 11
 12	"codeberg.org/maxwelljensen/llm_aggregator/internal/cli"
 13)
 14
 15func TestIsZero(t *testing.T) {
 16	tests := []struct {
 17		name     string
 18		value    any
 19		expected bool
 20	}{
 21		// String tests
 22		{"empty string is zero", "", true},
 23		{"non-empty string is not zero", "hello", false},
 24
 25		// Int tests
 26		{"zero int is zero", 0, true},
 27		{"non-zero int is not zero", 42, false},
 28
 29		// Float tests
 30		{"zero float is zero", 0.0, true},
 31		{"non-zero float is not zero", 0.7, false},
 32
 33		// Bool tests
 34		{"false bool is zero", false, true},
 35		{"true bool is not zero", true, false},
 36
 37		// Pointer tests - the bug we fixed
 38		{"nil *string is zero", (*string)(nil), true},
 39		{"non-nil *string is not zero", strPtr("hello"), false},
 40		{"nil *int is zero", (*int)(nil), true},
 41		{"non-nil *int is not zero", intPtr(42), false},
 42		{"nil *float64 is zero", (*float64)(nil), true},
 43		{"non-nil *float64 is not zero", floatPtr(0.7), false},
 44		{"nil *bool is zero", (*bool)(nil), true},
 45		{"non-nil *bool is not zero", boolPtr(true), false},
 46
 47		// Unknown type
 48		{"nil interface is zero", nil, true},
 49	}
 50
 51	for _, tt := range tests {
 52		t.Run(tt.name, func(t *testing.T) {
 53			result := isZero(tt.value)
 54			if result != tt.expected {
 55				t.Errorf("isZero(%v) = %v, want %v", tt.value, result, tt.expected)
 56			}
 57		})
 58	}
 59}
 60
 61// TestViperToRuntimePrecedence tests that ViperToRuntime correctly respects precedence.
 62// Order from highest to lowest: CLI args > Environment variables > Config file > Defaults
 63func TestViperToRuntimePrecedence(t *testing.T) {
 64	// Create temporary directory for test
 65	tempDir := t.TempDir()
 66	t.Setenv("XDG_CONFIG_HOME", tempDir)
 67
 68	// Save a config file with known values (manually write TOML since DefaultConfig/Save don't exist)
 69	configuredModel := "config-file-model"
 70	configuredBaseURL := "https://config-file.example.com"
 71	configuredTemperature := 0.3
 72	configuredMaxTokens := 5000
 73
 74	configContent := fmt.Sprintf(`model = "%s"
 75base_url = "%s"
 76temperature = %f
 77max_tokens = %d
 78`, configuredModel, configuredBaseURL, configuredTemperature, configuredMaxTokens)
 79
 80	configPath := filepath.Join(tempDir, "llm_aggregator", "config.toml")
 81	_ = os.MkdirAll(filepath.Dir(configPath), 0755) //nolint:errcheck
 82	if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
 83		t.Fatalf("Failed to write config file: %v", err)
 84	}
 85
 86	// Test 1: Config file should override defaults
 87	t.Run("config file overrides defaults", func(t *testing.T) {
 88		// Clear any env vars
 89		_ = os.Unsetenv("LLM_AGGREGATOR_MODEL")
 90		_ = os.Unsetenv("LLM_AGGREGATOR_BASE_URL")
 91		_ = os.Unsetenv("LLM_AGGREGATOR_TEMPERATURE")
 92		_ = os.Unsetenv("LLM_AGGREGATOR_MAX_TOKENS")
 93
 94		v := GetViper()
 95		rt := ViperToRuntime(v, "/tmp/feeds.txt", "test prompt")
 96
 97		if rt.Model != configuredModel {
 98			t.Errorf("Model = %q, want %q (config file should override default)", rt.Model, configuredModel)
 99		}
100		if rt.BaseURL != configuredBaseURL {
101			t.Errorf("BaseURL = %q, want %q", rt.BaseURL, configuredBaseURL)
102		}
103	})
104
105	// Test 2: Environment variables should override config file
106	t.Run("environment variables override config file", func(t *testing.T) {
107		envModel := "env-override-model"
108		envBaseURL := "https://env-override.example.com"
109		t.Setenv("LLM_AGGREGATOR_MODEL", envModel)
110		t.Setenv("LLM_AGGREGATOR_BASE_URL", envBaseURL)
111		defer func() {
112			_ = os.Unsetenv("LLM_AGGREGATOR_MODEL")
113			_ = os.Unsetenv("LLM_AGGREGATOR_BASE_URL")
114		}()
115
116		v := GetViper()
117		rt := ViperToRuntime(v, "/tmp/feeds.txt", "test prompt")
118
119		if rt.Model != envModel {
120			t.Errorf("Model = %q, want %q (env var should override config file)", rt.Model, envModel)
121		}
122		if rt.BaseURL != envBaseURL {
123			t.Errorf("BaseURL = %q, want %q", rt.BaseURL, envBaseURL)
124		}
125	})
126
127	// Test 3: CLI args (via BindCLIArgs) should override env vars and config file
128	t.Run("CLI args override environment variables and config file", func(t *testing.T) {
129		// Create a fresh viper instance to avoid polluting global state
130		v := viper.New()
131		v.SetDefault("model", "default-model")
132		v.SetDefault("base_url", "https://default.example.com")
133		v.SetDefault("temperature", 0.7)
134
135		// Set env vars that should be overridden
136		t.Setenv("LLM_AGGREGATOR_MODEL", "env-should-be-overridden")
137		t.Setenv("LLM_AGGREGATOR_BASE_URL", "https://env-should-be-overridden.example.com")
138		defer func() {
139			_ = os.Unsetenv("LLM_AGGREGATOR_MODEL")
140			_ = os.Unsetenv("LLM_AGGREGATOR_BASE_URL")
141		}()
142
143		// Simulate CLI args binding - using pointer types like the actual Args struct
144		cliModel := "cli-override-model"
145		cliBaseURL := "https://cli-override.example.com"
146		cliTemperature := 0.9
147
148		cliArgs := map[string]any{
149			"model":       &cliModel,
150			"base_url":    &cliBaseURL,
151			"temperature": &cliTemperature,
152		}
153		BindCLIArgs(v, cliArgs)
154
155		if v.GetString("model") != cliModel {
156			t.Errorf("model = %q, want %q (CLI should override env var)", v.GetString("model"), cliModel)
157		}
158		if v.GetString("base_url") != cliBaseURL {
159			t.Errorf("base_url = %q, want %q", v.GetString("base_url"), cliBaseURL)
160		}
161		if v.GetFloat64("temperature") != cliTemperature {
162			t.Errorf("temperature = %f, want %f", v.GetFloat64("temperature"), cliTemperature)
163		}
164	})
165
166	// Test 4: Nil/unset CLI args should NOT override existing values
167	// Note: This test verifies that when a CLI arg is nil (not provided), the existing
168	// value in viper is preserved. However, since viper doesn't support unsetting keys,
169	// and the test uses a fresh viper without BindEnv, we can't properly test the
170	// env var persistence here. This is actually a test design limitation.
171	t.Run("nil CLI args do not override viper values", func(t *testing.T) {
172		// Create a fresh viper instance to avoid polluting global state
173		v := viper.New()
174		v.SetDefault("model", "default-model")
175
176		// Simulate CLI args where some values were not provided (nil pointers)
177		var nilModel *string = nil
178		cliArgs := map[string]any{
179			"model": nilModel, // Not provided on CLI
180		}
181		BindCLIArgs(v, cliArgs)
182
183		// Since we didn't call BindEnv or Set any value, model should keep its default
184		if v.GetString("model") != "default-model" {
185			t.Errorf("model = %q, want %q (nil CLI arg should preserve viper default)", v.GetString("model"), "default-model")
186		}
187	})
188
189	// Test 5: Empty string CLI args should NOT override existing values
190	t.Run("empty string CLI args do not override viper values", func(t *testing.T) {
191		// Create a fresh viper instance to avoid polluting global state
192		v := viper.New()
193		v.SetDefault("model", "default-model")
194
195		// Simulate CLI args where value was provided as empty string
196		cliArgs := map[string]any{
197			"model": "", // Empty string should not override
198		}
199		BindCLIArgs(v, cliArgs)
200
201		// Empty string is a "zero" value for string type, so it should not override
202		if v.GetString("model") != "default-model" {
203			t.Errorf("model = %q, want %q (empty CLI arg should preserve viper default)", v.GetString("model"), "default-model")
204		}
205	})
206}
207
208// TestBindCLIArgsWithPointers tests BindCLIArgs specifically with pointer types.
209func TestBindCLIArgsWithPointers(t *testing.T) {
210	// Test that pointer types are handled correctly by isZero
211	t.Run("BindCLIArgs with pointer types", func(t *testing.T) {
212		v := viper.New()
213		v.SetDefault("model", "default-model")
214		v.SetDefault("max_tokens", 1000)
215		v.SetDefault("temperature", 0.7)
216
217		// Simulate CLI args with pointer types (as Args struct now uses)
218		model := "cli-model"
219		maxTokens := 2000
220		temperature := 0.5
221
222		args := map[string]any{
223			"model":       &model,
224			"max_tokens":  &maxTokens,
225			"temperature": &temperature,
226		}
227		BindCLIArgs(v, args)
228
229		if v.GetString("model") != "cli-model" {
230			t.Errorf("model = %q, want %q", v.GetString("model"), "cli-model")
231		}
232		if v.GetInt("max_tokens") != 2000 {
233			t.Errorf("max_tokens = %d, want %d", v.GetInt("max_tokens"), 2000)
234		}
235		if v.GetFloat64("temperature") != 0.5 {
236			t.Errorf("temperature = %f, want %f", v.GetFloat64("temperature"), 0.5)
237		}
238	})
239
240	t.Run("BindCLIArgs with nil pointers does not override", func(t *testing.T) {
241		v := viper.New()
242		v.SetDefault("model", "default-model")
243		v.SetDefault("max_tokens", 1000)
244
245		// Simulate CLI args where nothing was provided (all nil)
246		var nilStr *string = nil
247		var nilInt *int = nil
248
249		args := map[string]any{
250			"model":      nilStr,
251			"max_tokens": nilInt,
252		}
253		BindCLIArgs(v, args)
254
255		// Should keep defaults
256		if v.GetString("model") != "default-model" {
257			t.Errorf("model = %q, want %q (nil should not override)", v.GetString("model"), "default-model")
258		}
259		if v.GetInt("max_tokens") != 1000 {
260			t.Errorf("max_tokens = %d, want %d", v.GetInt("max_tokens"), 1000)
261		}
262	})
263
264	t.Run("BindCLIArgs with zero int pointer DOES override (user explicitly passed --max-tokens 0)", func(t *testing.T) {
265		v := viper.New()
266		v.SetDefault("max_tokens", 1000)
267
268		zero := 0
269		args := map[string]any{
270			"max_tokens": &zero, // User explicitly passed --max-tokens 0, so 0 SHOULD override
271		}
272		BindCLIArgs(v, args)
273
274		if v.GetInt("max_tokens") != 0 {
275			t.Errorf("max_tokens = %d, want %d (explicit 0 should override default)", v.GetInt("max_tokens"), 0)
276		}
277	})
278
279	t.Run("BindCLIArgs with zero float64 pointer DOES override (user explicitly passed --temperature 0)", func(t *testing.T) {
280		v := viper.New()
281		v.SetDefault("temperature", 0.7)
282
283		zero := 0.0
284		args := map[string]any{
285			"temperature": &zero, // User explicitly passed --temperature 0, so 0 SHOULD override
286		}
287		BindCLIArgs(v, args)
288
289		if v.GetFloat64("temperature") != 0.0 {
290			t.Errorf("temperature = %f, want %f (explicit 0 should override default)", v.GetFloat64("temperature"), 0.0)
291		}
292	})
293}
294
295// Helper functions for creating pointers
296func strPtr(s string) *string { return &s }
297func intPtr(i int) *int { return &i }
298func floatPtr(f float64) *float64 { return &f }
299func boolPtr(b bool) *bool { return &b }
300
301// TestConfigParsingAlwaysPasses ensures that parsing of options always passes.
302// This tests the integration between CLI args, config loading, and Viper.
303func TestConfigParsingAlwaysPasses(t *testing.T) {
304	// Create temporary directory for test
305	tempDir := t.TempDir()
306	t.Setenv("XDG_CONFIG_HOME", tempDir)
307
308	// Create a test feeds file
309	feedsFile := tempDir + "/feeds.txt"
310	if err := os.WriteFile(feedsFile, []byte("https://example.com/feed.xml\nhttps://example.org/feed.xml"), 0644); err != nil {
311		t.Fatalf("Failed to create test feeds file: %v", err)
312	}
313
314	tests := []struct {
315		name        string
316		cliArgs     map[string]string
317		expectFeeds string
318		expectModel string
319	}{
320		{
321			name: "default configuration",
322			cliArgs: map[string]string{
323				"--feeds-file": feedsFile,
324				"--prompt":     "Test prompt",
325			},
326			expectFeeds: feedsFile,
327			expectModel: "deepseek-chat",
328		},
329		{
330			name: "custom model via CLI",
331			cliArgs: map[string]string{
332				"--feeds-file": feedsFile,
333				"--prompt":     "Test prompt",
334				"--model":      "gpt-4",
335			},
336			expectFeeds: feedsFile,
337			expectModel: "gpt-4",
338		},
339		{
340			name: "custom api key via CLI",
341			cliArgs: map[string]string{
342				"--feeds-file": feedsFile,
343				"--prompt":     "Test prompt",
344				"--api-key":    "sk-test-key-12345",
345			},
346			expectFeeds: feedsFile,
347			expectModel: "deepseek-chat",
348		},
349		{
350			name: "all CLI options",
351			cliArgs: map[string]string{
352				"--feeds-file":            feedsFile,
353				"--prompt":                "Summarise tech news",
354				"--api-key":               "sk-test-key",
355				"--model":                 "deepseek-coder",
356				"--max-articles-per-feed": "5",
357				"--max-days-old":          "3",
358				"--max-total-articles":    "15",
359				"--include-keywords":      "ai,ml",
360				"--exclude-keywords":     "advertisement",
361				"--max-tokens":            "1000",
362				"--temperature":          "0.3",
363				"--output":                "json",
364				"--stdin":                 "",
365				"--plain":                 "",
366			},
367			expectFeeds: feedsFile,
368			expectModel: "deepseek-coder",
369		},
370		{
371			name: "stdin only",
372			cliArgs: map[string]string{
373				"--prompt": "Summarise from stdin",
374				"--stdin":  "",
375			},
376			expectFeeds: "",
377			expectModel: "deepseek-chat",
378		},
379	}
380
381	for _, tt := range tests {
382		t.Run(tt.name, func(t *testing.T) {
383			// Build CLI args slice
384			args := []string{"llm_aggregator"}
385			for k, v := range tt.cliArgs {
386				// Remove leading dashes for go-arg compatibility
387				key := strings.TrimPrefix(k, "--")
388				args = append(args, "--"+key)
389				if v != "" {
390					args = append(args, v)
391				}
392			}
393
394			// Save original os.Args
395			origArgs := os.Args
396			defer func() { os.Args = origArgs }()
397			os.Args = args
398
399			// Parse arguments - this should never fail for valid inputs
400			parsedArgs, err := cli.ParseArgs()
401			if err != nil {
402				t.Fatalf("Failed to parse CLI args: %v", err)
403			}
404
405			// Verify feeds file path is set correctly
406			if parsedArgs.FeedsFile != tt.expectFeeds {
407				t.Errorf("FeedsFile = %q, want %q", parsedArgs.FeedsFile, tt.expectFeeds)
408			}
409
410			// Verify prompt is set
411			if parsedArgs.Prompt == "" {
412				t.Error("Prompt should not be empty")
413			}
414
415			// Verify model matches expected
416			if parsedArgs.Model != nil && *parsedArgs.Model != tt.expectModel {
417				t.Errorf("Model = %q, want %q", *parsedArgs.Model, tt.expectModel)
418			}
419
420			// Verify API key if provided
421			if apiKey, ok := tt.cliArgs["--api-key"]; ok {
422				if parsedArgs.APIKey != nil && *parsedArgs.APIKey != apiKey {
423					t.Errorf("APIKey = %q, want %q", *parsedArgs.APIKey, apiKey)
424				}
425			}
426
427			// Verify plain flag
428			if tt.name == "all CLI options" && !parsedArgs.Plain {
429				t.Error("Plain should be true when --plain is provided")
430			}
431
432			// Verify stdin flag
433			if tt.name == "all CLI options" && !parsedArgs.Stdin {
434				t.Error("Stdin should be true when --stdin is provided")
435			}
436			if tt.name == "stdin only" && !parsedArgs.Stdin {
437				t.Error("Stdin should be true when --stdin is provided")
438			}
439		})
440	}
441}
442
443// TestBindCLIArgs tests the BindCLIArgs function.
444func TestBindCLIArgs(t *testing.T) {
445	// Create a fresh viper instance with defaults
446	v := viper.New()
447	v.SetDefault("model", "default-model")
448	v.SetDefault("max_tokens", 1000)
449
450	// Bind CLI args with override values
451	args := map[string]any{
452		"model":      "cli-model",
453		"max_tokens": 2000,
454	}
455	BindCLIArgs(v, args)
456
457	if v.GetString("model") != "cli-model" {
458		t.Errorf("model = %q, want %q", v.GetString("model"), "cli-model")
459	}
460	if v.GetInt("max_tokens") != 2000 {
461		t.Errorf("max_tokens = %d, want %d", v.GetInt("max_tokens"), 2000)
462	}
463
464	// Test that zero/empty values don't override
465	v2 := viper.New()
466	v2.SetDefault("model", "default-model")
467	v2.SetDefault("temperature", 0.7)
468
469	args2 := map[string]any{
470		"model":      "", // Empty string should not override
471		"temperature": 0,  // Zero should not override
472	}
473	BindCLIArgs(v2, args2)
474
475	if v2.GetString("model") != "default-model" {
476		t.Errorf("model = %q, want %q (empty should not override)", v2.GetString("model"), "default-model")
477	}
478	if v2.GetFloat64("temperature") != 0.7 {
479		t.Errorf("temperature = %f, want %f (zero should not override)", v2.GetFloat64("temperature"), 0.7)
480	}
481}