all repos — llm_aggregator @ e3e7be8461bb50a7fe674b4055286d34dddb0c5c

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

internal/config/config.go (view raw)

  1package config
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"os"
  7	"path/filepath"
  8
  9	"github.com/spf13/viper"
 10
 11	"llm_aggregator/internal/cli"
 12	"llm_aggregator/internal/defaults"
 13	"llm_aggregator/internal/progress"
 14	"llm_aggregator/internal/runtime"
 15	"llm_aggregator/internal/style"
 16)
 17
 18// Config holds application configuration sourced from TOML, environment variables, and CLI arguments.
 19// See config.GetViper for precedence and singleton behaviour.
 20type Config struct {
 21	// Feed aggregation options
 22	MaxArticlesPerFeed int    `mapstructure:"max_articles_per_feed"`
 23	MaxDaysOld         int    `mapstructure:"max_days_old"`
 24	MaxTotalArticles   int    `mapstructure:"max_total_articles"`
 25	IncludeKeywords    string `mapstructure:"include_keywords"`
 26	ExcludeKeywords    string `mapstructure:"exclude_keywords"`
 27
 28	// Feed input
 29	Stdin bool `mapstructure:"stdin"`
 30
 31	// LLM API options
 32	APIKey      string  `mapstructure:"api_key"`
 33	BaseURL     string  `mapstructure:"base_url"`
 34	Model       string  `mapstructure:"model"`
 35	MaxTokens   int     `mapstructure:"max_tokens"`
 36	Temperature float64 `mapstructure:"temperature"`
 37	Timeout     int     `mapstructure:"timeout"`
 38
 39	// System prompt
 40	SystemPrompt string `mapstructure:"system_prompt"`
 41
 42	// Output options
 43	Output          string `mapstructure:"output"`
 44	OutputFile      string `mapstructure:"output_file"`
 45	IncludeArticles bool   `mapstructure:"include_articles"`
 46	Plain           bool   `mapstructure:"plain"`
 47}
 48
 49// GetViper returns the global viper instance.
 50// Precedence (highest first): CLI flags → environment variables → config file → defaults.
 51//
 52// NOTE: Viper is a singleton. Subsequent calls return the SAME instance with all
 53// previously set values. Defaults, env bindings, and config-file reads are idempotent
 54// but the instance is NOT reset between calls. Use viper.New() directly for testing.
 55func GetViper() *viper.Viper {
 56	// Set defaults first (lowest priority)
 57	setDefaults()
 58	// Initialise or get existing Viper instance
 59	v := viper.GetViper()
 60	// Ensure defaults are set (for fresh instances)
 61	setDefaultsOn(v)
 62	// Set environment variable prefix
 63	v.SetEnvPrefix("LLM_AGGREGATOR")
 64	v.AutomaticEnv()
 65	// Bind environment variables to config fields
 66	bindEnvVars(v)
 67	// Get config path from XDG
 68	configPath, err := GetConfigPath()
 69	if err == nil {
 70		// Set config file path
 71		v.SetConfigFile(configPath)
 72		// Debug: show what config file is being used
 73		// Try to read config file
 74		if err := v.ReadInConfig(); err != nil {
 75			// If config file doesn't exist, that's OK - we'll use defaults + env vars
 76			var notFoundError viper.ConfigFileNotFoundError
 77			if !errors.As(err, &notFoundError) {
 78				fmt.Fprintln(os.Stderr, style.Warningf("error reading config file: %v", err))
 79			}
 80		}
 81	}
 82	return v
 83}
 84
 85// setDefaults sets default values on the global viper instance.
 86func setDefaults() {
 87	v := viper.GetViper()
 88	setDefaultsOn(v)
 89}
 90
 91// setDefaultsOn sets default values on the given viper instance.
 92func setDefaultsOn(v *viper.Viper) {
 93	// Feed aggregation defaults
 94	v.SetDefault("max_articles_per_feed", defaults.DefaultMaxArticlesPerFeed)
 95	v.SetDefault("max_days_old", defaults.DefaultMaxDaysOld)
 96	v.SetDefault("max_total_articles", defaults.DefaultMaxTotalArticles)
 97	// LLM API defaults
 98	v.SetDefault("base_url", defaults.DefaultBaseURL)
 99	v.SetDefault("model", defaults.DefaultModel)
100	v.SetDefault("max_tokens", defaults.DefaultMaxTokens)
101	v.SetDefault("temperature", defaults.DefaultTemperature)
102	v.SetDefault("timeout", defaults.DefaultLLMTimeout)
103	// System prompt default
104	v.SetDefault("system_prompt", defaults.DefaultSystemPrompt)
105	// Output defaults
106	v.SetDefault("output", defaults.DefaultOutput)
107	v.SetDefault("include_articles", defaults.DefaultIncludeArticles)
108}
109
110// BindCLIArgs binds CLI argument values to viper with highest precedence.
111// This should be called after parsing CLI arguments to override config file/env defaults.
112func BindCLIArgs(v *viper.Viper, args map[string]any) {
113	for key, value := range args {
114		if value != nil && !isZero(value) {
115			v.Set(key, value)
116		}
117	}
118}
119
120// isZero reports whether v is the zero value for its type.
121//
122// This is critical for CLI handling: pointer types (*string, *int, …) return true
123// when nil (flag not provided), not when dereferencing to zero. This distinguishes
124// "--temperature 0" (explicit zero, overrides default) from no flag at all (nil,
125// preserves default from config/env).
126func isZero(v any) bool {
127	// Handle nil interface values first (before type switch)
128	if v == nil {
129		return true
130	}
131	switch val := v.(type) {
132	case string:
133		return val == ""
134	case int:
135		return val == 0
136	case int8:
137		return val == 0
138	case int16:
139		return val == 0
140	case int32:
141		return val == 0
142	case int64:
143		return val == 0
144	case float32:
145		return val == 0.0
146	case float64:
147		return val == 0.0
148	case bool:
149		return !val
150	case *string:
151		return val == nil
152	case *int:
153		return val == nil
154	case *int8:
155		return val == nil
156	case *int16:
157		return val == nil
158	case *int32:
159		return val == nil
160	case *int64:
161		return val == nil
162	case *float32:
163		return val == nil
164	case *float64:
165		return val == nil
166	case *bool:
167		return val == nil
168	default:
169		return false
170	}
171}
172
173// ViperToRuntime converts viper configuration into a Runtime value.
174// FeedsFile and Prompt are passed separately as they come from positional CLI args.
175func ViperToRuntime(v *viper.Viper, feedsFile, prompt string) *runtime.Runtime {
176	rt := &runtime.Runtime{
177		FeedsFile: feedsFile,
178		Prompt:    prompt,
179		Progress:  &progress.NoopLogger{},
180	}
181	// Feed aggregation options
182	rt.MaxArticlesPerFeed = v.GetInt("max_articles_per_feed")
183	rt.MaxDaysOld = v.GetInt("max_days_old")
184	rt.MaxTotalArticles = v.GetInt("max_total_articles")
185	// Keywords
186	includeKeywords := v.GetString("include_keywords")
187	excludeKeywords := v.GetString("exclude_keywords")
188	if includeKeywords != "" {
189		rt.IncludeKeywords = cli.ParseKeywords(includeKeywords)
190	}
191	if excludeKeywords != "" {
192		rt.ExcludeKeywords = cli.ParseKeywords(excludeKeywords)
193	}
194	// LLM API options
195	rt.APIKey = v.GetString("api_key")
196	rt.BaseURL = v.GetString("base_url")
197	rt.Model = v.GetString("model")
198	rt.MaxTokens = v.GetInt("max_tokens")
199	rt.Temperature = v.GetFloat64("temperature")
200	rt.LLMTimeout = v.GetInt("timeout")
201	// System prompt
202	rt.SystemPrompt = v.GetString("system_prompt")
203	// Output options
204	rt.Output = v.GetString("output")
205	rt.OutputFile = v.GetString("output_file")
206	rt.IncludeArticles = v.GetBool("include_articles")
207	rt.Plain = v.GetBool("plain")
208	rt.Stdin = v.GetBool("stdin")
209	return rt
210}
211
212// bindEnvVars binds environment variables to viper keys.
213//nolint:errcheck // viper.BindEnv always returns nil in practice
214func bindEnvVars(v *viper.Viper) {
215	// Feed aggregation options
216	v.BindEnv("max_articles_per_feed", "LLM_AGGREGATOR_MAX_ARTICLES_PER_FEED")
217	v.BindEnv("max_days_old", "LLM_AGGREGATOR_MAX_DAYS_OLD")
218	v.BindEnv("max_total_articles", "LLM_AGGREGATOR_MAX_TOTAL_ARTICLES")
219	v.BindEnv("include_keywords", "LLM_AGGREGATOR_INCLUDE_KEYWORDS")
220	v.BindEnv("exclude_keywords", "LLM_AGGREGATOR_EXCLUDE_KEYWORDS")
221	// LLM API options
222	v.BindEnv("api_key", "LLM_AGGREGATOR_API_KEY")
223	v.BindEnv("base_url", "LLM_AGGREGATOR_BASE_URL")
224	v.BindEnv("model", "LLM_AGGREGATOR_MODEL")
225	v.BindEnv("max_tokens", "LLM_AGGREGATOR_MAX_TOKENS")
226	v.BindEnv("temperature", "LLM_AGGREGATOR_TEMPERATURE")
227	v.BindEnv("timeout", "LLM_AGGREGATOR_TIMEOUT")
228	// System prompt
229	v.BindEnv("system_prompt", "LLM_AGGREGATOR_SYSTEM_PROMPT")
230	// Output options
231	v.BindEnv("output", "LLM_AGGREGATOR_OUTPUT")
232	v.BindEnv("output_file", "LLM_AGGREGATOR_OUTPUT_FILE")
233	v.BindEnv("include_articles", "LLM_AGGREGATOR_INCLUDE_ARTICLES")
234	v.BindEnv("plain", "LLM_AGGREGATOR_PLAIN")
235	// Feed input
236	v.BindEnv("stdin", "LLM_AGGREGATOR_STDIN")
237}
238
239// GetConfigPath returns the path to the config file.
240func GetConfigPath() (string, error) {
241	configDir, err := os.UserConfigDir()
242	if err != nil {
243		return "", fmt.Errorf("failed to get user config directory: %w", err)
244	}
245	return filepath.Join(configDir, "llm_aggregator", "config.toml"), nil
246}