all repos — llm_aggregator @ d3fad3374aee5ee549219c1387a0c5d87ce1a99b

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 the application configuration loaded from TOML file, environment variables, and CLI arguments.
 19type Config struct {
 20	// Feed aggregation options
 21	MaxArticlesPerFeed int    `mapstructure:"max_articles_per_feed"`
 22	MaxDaysOld         int    `mapstructure:"max_days_old"`
 23	MaxTotalArticles   int    `mapstructure:"max_total_articles"`
 24	IncludeKeywords    string `mapstructure:"include_keywords"`
 25	ExcludeKeywords    string `mapstructure:"exclude_keywords"`
 26
 27	// Feed input
 28	Stdin bool `mapstructure:"stdin"`
 29
 30	// LLM API options
 31	APIKey      string  `mapstructure:"api_key"`
 32	BaseURL     string  `mapstructure:"base_url"`
 33	Model       string  `mapstructure:"model"`
 34	MaxTokens   int     `mapstructure:"max_tokens"`
 35	Temperature float64 `mapstructure:"temperature"`
 36	Timeout     int     `mapstructure:"timeout"`
 37
 38	// System prompt
 39	SystemPrompt string `mapstructure:"system_prompt"`
 40
 41	// Output options
 42	Output          string `mapstructure:"output"`
 43	OutputFile      string `mapstructure:"output_file"`
 44	IncludeArticles bool   `mapstructure:"include_articles"`
 45	Plain           bool   `mapstructure:"plain"`
 46}
 47
 48// GetViper returns the global viper instance with all configuration sources set up.
 49// The precedence order is: CLI flags > Environment variables > Config file > Defaults.
 50//
 51// NOTE: Viper uses a singleton pattern. Subsequent calls to GetViper() return the SAME
 52// instance with all previously set values. This means defaults are only set once,
 53// environment vars are bound once, and the config file is read once. Do NOT call this
 54// expecting a fresh state — if you need a fresh instance for testing, use viper.New() directly.
 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 checks if a value is the zero value for its type.
121//
122// NOTE: This function is critical for CLI argument handling. Pointer types
123// (*string, *int, etc.) return true only when nil (not provided), NOT when
124// pointing to a zero value. This allows distinguishing between "flag not
125// provided" (nil) vs "flag explicitly set to zero" (e.g., --temperature 0
126// should override default, but no flag means preserve default).
127func isZero(v any) bool {
128	// Handle nil interface values first (before type switch)
129	if v == nil {
130		return true
131	}
132	switch val := v.(type) {
133	case string:
134		return val == ""
135	case int:
136		return val == 0
137	case int8:
138		return val == 0
139	case int16:
140		return val == 0
141	case int32:
142		return val == 0
143	case int64:
144		return val == 0
145	case float32:
146		return val == 0.0
147	case float64:
148		return val == 0.0
149	case bool:
150		return !val
151	case *string:
152		return val == nil
153	case *int:
154		return val == nil
155	case *int8:
156		return val == nil
157	case *int16:
158		return val == nil
159	case *int32:
160		return val == nil
161	case *int64:
162		return val == nil
163	case *float32:
164		return val == nil
165	case *float64:
166		return val == nil
167	case *bool:
168		return val == nil
169	default:
170		return false
171	}
172}
173
174// ViperToRuntime converts viper configuration directly to runtime.Runtime.
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}