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}
41
42// DefaultConfig returns a Config with sensible defaults.
43func DefaultConfig() *Config {
44 return &Config{
45 MaxArticlesPerFeed: defaults.DefaultMaxArticlesPerFeed,
46 MaxDaysOld: defaults.DefaultMaxDaysOld,
47 MaxTotalArticles: defaults.DefaultMaxTotalArticles,
48 Model: defaults.DefaultModel,
49 BaseURL: defaults.DefaultBaseURL,
50 MaxTokens: defaults.DefaultMaxTokens,
51 Temperature: defaults.DefaultTemperature,
52 SystemPrompt: defaults.DefaultSystemPrompt,
53 Output: defaults.DefaultOutput,
54 IncludeArticles: defaults.DefaultIncludeArticles,
55 }
56}
57
58// Load loads configuration from TOML file, environment variables, and sets defaults.
59// The precedence order is: CLI args > Environment variables > Config file > Defaults.
60func Load() (*Config, error) {
61 // Get the global viper instance which handles precedence automatically
62 v := GetViper()
63
64 // Return the current configuration as a Config struct
65 return ViperToConfig(v), nil
66}
67
68// GetViper returns the global viper instance with all configuration sources set up.
69// The precedence order is: CLI flags > Environment variables > Config file > Defaults.
70func GetViper() *viper.Viper {
71 // Set defaults first (lowest priority)
72 setDefaults()
73
74 // Initialise or get existing Viper instance
75 v := viper.GetViper()
76
77 // Ensure defaults are set (for fresh instances)
78 setDefaultsOn(v)
79
80 // Set environment variable prefix
81 v.SetEnvPrefix("LLM_AGGREGATOR")
82 v.AutomaticEnv()
83
84 // Bind environment variables to config fields
85 bindEnvVars(v)
86
87 // Get config path from XDG
88 configPath, err := GetConfigPath()
89 if err == nil {
90 // Set config file path
91 v.SetConfigFile(configPath)
92
93 // Debug: show what config file is being used
94
95 // Try to read config file
96 if err := v.ReadInConfig(); err != nil {
97 // If config file doesn't exist, that's OK - we'll use defaults + env vars
98 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
99 fmt.Fprintln(os.Stderr, style.Warningf("error reading config file: %v", err))
100 } else {
101 }
102 } else {
103 }
104 }
105
106 return v
107}
108
109// setDefaults sets default values on the global viper instance.
110func setDefaults() {
111 v := viper.GetViper()
112 setDefaultsOn(v)
113}
114
115// setDefaultsOn sets default values on the given viper instance.
116func setDefaultsOn(v *viper.Viper) {
117 // Feed aggregation defaults
118 v.SetDefault("max_articles_per_feed", defaults.DefaultMaxArticlesPerFeed)
119 v.SetDefault("max_days_old", defaults.DefaultMaxDaysOld)
120 v.SetDefault("max_total_articles", defaults.DefaultMaxTotalArticles)
121
122 // LLM API defaults
123 v.SetDefault("base_url", defaults.DefaultBaseURL)
124 v.SetDefault("model", defaults.DefaultModel)
125 v.SetDefault("max_tokens", defaults.DefaultMaxTokens)
126 v.SetDefault("temperature", defaults.DefaultTemperature)
127
128 // System prompt default
129 v.SetDefault("system_prompt", defaults.DefaultSystemPrompt)
130
131 // Output defaults
132 v.SetDefault("output", defaults.DefaultOutput)
133 v.SetDefault("include_articles", defaults.DefaultIncludeArticles)
134}
135
136// ViperToConfig converts viper configuration to a Config struct.
137func ViperToConfig(v *viper.Viper) *Config {
138 return &Config{
139 MaxArticlesPerFeed: v.GetInt("max_articles_per_feed"),
140 MaxDaysOld: v.GetInt("max_days_old"),
141 MaxTotalArticles: v.GetInt("max_total_articles"),
142 IncludeKeywords: v.GetString("include_keywords"),
143 ExcludeKeywords: v.GetString("exclude_keywords"),
144 APIKey: v.GetString("api_key"),
145 BaseURL: v.GetString("base_url"),
146 Model: v.GetString("model"),
147 MaxTokens: v.GetInt("max_tokens"),
148 Temperature: v.GetFloat64("temperature"),
149 SystemPrompt: v.GetString("system_prompt"),
150 Output: v.GetString("output"),
151 OutputFile: v.GetString("output_file"),
152 IncludeArticles: v.GetBool("include_articles"),
153 }
154}
155
156// BindCLIArgs binds CLI argument values to viper with highest precedence.
157// This should be called after parsing CLI arguments to override config file/env defaults.
158func BindCLIArgs(v *viper.Viper, args map[string]any) {
159 for key, value := range args {
160 if value != nil && !isZero(value) {
161 v.Set(key, value)
162 }
163 }
164}
165
166// isZero checks if a value is the zero value for its type.
167func isZero(v any) bool {
168 // Handle nil interface values first (before type switch)
169 if v == nil {
170 return true
171 }
172 switch val := v.(type) {
173 case string:
174 return val == ""
175 case int:
176 return val == 0
177 case int8:
178 return val == 0
179 case int16:
180 return val == 0
181 case int32:
182 return val == 0
183 case int64:
184 return val == 0
185 case float32:
186 return val == 0.0
187 case float64:
188 return val == 0.0
189 case bool:
190 return !val
191 case *string:
192 return val == nil
193 case *int:
194 return val == nil
195 case *int8:
196 return val == nil
197 case *int16:
198 return val == nil
199 case *int32:
200 return val == nil
201 case *int64:
202 return val == nil
203 case *float32:
204 return val == nil
205 case *float64:
206 return val == nil
207 case *bool:
208 return val == nil
209 default:
210 return false
211 }
212}
213
214// ViperToRuntime converts viper configuration directly to runtime.Runtime.
215func ViperToRuntime(v *viper.Viper, feedsFile, prompt string) *runtime.Runtime {
216 rt := &runtime.Runtime{
217 FeedsFile: feedsFile,
218 Prompt: prompt,
219 Progress: &progress.NoopLogger{},
220 }
221
222 // Feed aggregation options
223 rt.MaxArticlesPerFeed = v.GetInt("max_articles_per_feed")
224 rt.MaxDaysOld = v.GetInt("max_days_old")
225 rt.MaxTotalArticles = v.GetInt("max_total_articles")
226
227 // Keywords
228 includeKeywords := v.GetString("include_keywords")
229 excludeKeywords := v.GetString("exclude_keywords")
230 if includeKeywords != "" {
231 rt.IncludeKeywords = cli.ParseKeywords(includeKeywords)
232 }
233 if excludeKeywords != "" {
234 rt.ExcludeKeywords = cli.ParseKeywords(excludeKeywords)
235 }
236
237 // LLM API options
238 rt.APIKey = v.GetString("api_key")
239 rt.BaseURL = v.GetString("base_url")
240 rt.Model = v.GetString("model")
241 rt.MaxTokens = v.GetInt("max_tokens")
242 rt.Temperature = v.GetFloat64("temperature")
243
244 // System prompt
245 rt.SystemPrompt = v.GetString("system_prompt")
246
247 // Output options
248 rt.Output = v.GetString("output")
249 rt.OutputFile = v.GetString("output_file")
250 rt.IncludeArticles = v.GetBool("include_articles")
251
252 return rt
253}
254
255// bindEnvVars binds environment variables to viper keys.
256func bindEnvVars(v *viper.Viper) {
257 // Feed aggregation options
258 v.BindEnv("max_articles_per_feed", "LLM_AGGREGATOR_MAX_ARTICLES_PER_FEED")
259 v.BindEnv("max_days_old", "LLM_AGGREGATOR_MAX_DAYS_OLD")
260 v.BindEnv("max_total_articles", "LLM_AGGREGATOR_MAX_TOTAL_ARTICLES")
261 v.BindEnv("include_keywords", "LLM_AGGREGATOR_INCLUDE_KEYWORDS")
262 v.BindEnv("exclude_keywords", "LLM_AGGREGATOR_EXCLUDE_KEYWORDS")
263
264 // LLM API options
265 v.BindEnv("api_key", "LLM_AGGREGATOR_API_KEY")
266 v.BindEnv("base_url", "LLM_AGGREGATOR_BASE_URL")
267 v.BindEnv("model", "LLM_AGGREGATOR_MODEL")
268 v.BindEnv("max_tokens", "LLM_AGGREGATOR_MAX_TOKENS")
269 v.BindEnv("temperature", "LLM_AGGREGATOR_TEMPERATURE")
270
271 // System prompt
272 v.BindEnv("system_prompt", "LLM_AGGREGATOR_SYSTEM_PROMPT")
273
274 // Output options
275 v.BindEnv("output", "LLM_AGGREGATOR_OUTPUT")
276 v.BindEnv("output_file", "LLM_AGGREGATOR_OUTPUT_FILE")
277 v.BindEnv("include_articles", "LLM_AGGREGATOR_INCLUDE_ARTICLES")
278}
279
280// GetConfigPath returns the path to the config file.
281func GetConfigPath() (string, error) {
282 configDir, err := os.UserConfigDir()
283 if err != nil {
284 return "", fmt.Errorf("failed to get user config directory: %w", err)
285 }
286 return filepath.Join(configDir, "llm_aggregator", "config.toml"), nil
287}
288
289// ConfigExists returns true if a config file exists.
290func ConfigExists() (bool, error) {
291 configPath, err := GetConfigPath()
292 if err != nil {
293 return false, err
294 }
295 _, err = os.Stat(configPath)
296 if err != nil {
297 if os.IsNotExist(err) {
298 return false, nil
299 }
300 return false, err
301 }
302 return true, nil
303}
304
305// Save saves the configuration to the TOML file.
306func (c *Config) Save() error {
307 v := viper.New()
308 v.SetConfigType("toml")
309
310 // Set values from config struct
311 v.Set("max_articles_per_feed", c.MaxArticlesPerFeed)
312 v.Set("max_days_old", c.MaxDaysOld)
313 v.Set("max_total_articles", c.MaxTotalArticles)
314 v.Set("include_keywords", c.IncludeKeywords)
315 v.Set("exclude_keywords", c.ExcludeKeywords)
316 v.Set("api_key", c.APIKey)
317 v.Set("base_url", c.BaseURL)
318 v.Set("model", c.Model)
319 v.Set("max_tokens", c.MaxTokens)
320 v.Set("temperature", c.Temperature)
321 v.Set("system_prompt", c.SystemPrompt)
322 v.Set("output", c.Output)
323 v.Set("output_file", c.OutputFile)
324 v.Set("include_articles", c.IncludeArticles)
325
326 // Get config path
327 configPath, err := GetConfigPath()
328 if err != nil {
329 return fmt.Errorf("failed to get config path: %w", err)
330 }
331
332 // Create directory if it doesn't exist
333 configDir := filepath.Dir(configPath)
334 if err := os.MkdirAll(configDir, 0755); err != nil {
335 return fmt.Errorf("failed to create config directory: %w", err)
336 }
337
338 // Write config file
339 if err := v.WriteConfigAs(configPath); err != nil {
340 return fmt.Errorf("failed to write config file: %w", err)
341 }
342
343 return nil
344}
345
346// IsLoaded returns true if the configuration was loaded from a file.
347func (c *Config) IsLoaded() bool {
348 // Check if config file exists - if it does, values would have been loaded from it
349 configPath, err := GetConfigPath()
350 if err != nil {
351 return false
352 }
353 _, err = os.Stat(configPath)
354 return err == nil
355}