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, ¬FoundError) {
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}