all repos — llm_aggregator @ af6da634ea141bc028760fd78253d03cee78a82f

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