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