all repos — llm_aggregator @ a0e7d1a442a251ae94645cdddccf30ded92e97a1

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

internal/config/config.go (view raw)

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