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 // Try to read config file
94 if err := v.ReadInConfig(); err != nil {
95 // If config file doesn't exist, that's OK - we'll use defaults + env vars
96 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
97 fmt.Fprintln(os.Stderr, style.Warningf("error reading config file: %v", err))
98 }
99 }
100 }
101
102 return v
103}
104
105// setDefaults sets default values on the global viper instance.
106func setDefaults() {
107 v := viper.GetViper()
108 setDefaultsOn(v)
109}
110
111// setDefaultsOn sets default values on the given viper instance.
112func setDefaultsOn(v *viper.Viper) {
113 // Feed aggregation defaults
114 v.SetDefault("max_articles_per_feed", defaults.DefaultMaxArticlesPerFeed)
115 v.SetDefault("max_days_old", defaults.DefaultMaxDaysOld)
116 v.SetDefault("max_total_articles", defaults.DefaultMaxTotalArticles)
117
118 // LLM API defaults
119 v.SetDefault("base_url", defaults.DefaultBaseURL)
120 v.SetDefault("model", defaults.DefaultModel)
121 v.SetDefault("max_tokens", defaults.DefaultMaxTokens)
122 v.SetDefault("temperature", defaults.DefaultTemperature)
123
124 // System prompt default
125 v.SetDefault("system_prompt", defaults.DefaultSystemPrompt)
126
127 // Output defaults
128 v.SetDefault("output", defaults.DefaultOutput)
129 v.SetDefault("include_articles", defaults.DefaultIncludeArticles)
130}
131
132// ViperToConfig converts viper configuration to a Config struct.
133func ViperToConfig(v *viper.Viper) *Config {
134 return &Config{
135 MaxArticlesPerFeed: v.GetInt("max_articles_per_feed"),
136 MaxDaysOld: v.GetInt("max_days_old"),
137 MaxTotalArticles: v.GetInt("max_total_articles"),
138 IncludeKeywords: v.GetString("include_keywords"),
139 ExcludeKeywords: v.GetString("exclude_keywords"),
140 APIKey: v.GetString("api_key"),
141 BaseURL: v.GetString("base_url"),
142 Model: v.GetString("model"),
143 MaxTokens: v.GetInt("max_tokens"),
144 Temperature: v.GetFloat64("temperature"),
145 SystemPrompt: v.GetString("system_prompt"),
146 Output: v.GetString("output"),
147 OutputFile: v.GetString("output_file"),
148 IncludeArticles: v.GetBool("include_articles"),
149 }
150}
151
152// BindCLIArgs binds CLI argument values to viper with highest precedence.
153// This should be called after parsing CLI arguments to override config file/env defaults.
154func BindCLIArgs(v *viper.Viper, args map[string]any) {
155 for key, value := range args {
156 if value != nil && !isZero(value) {
157 v.Set(key, value)
158 }
159 }
160}
161
162// isZero checks if a value is the zero value for its type.
163func isZero(v any) bool {
164 switch val := v.(type) {
165 case string:
166 return val == ""
167 case int, int8, int16, int32, int64:
168 return val == 0
169 case float32, float64:
170 return val == 0
171 case bool:
172 return !val
173 default:
174 return false
175 }
176}
177
178// ViperToRuntime converts viper configuration directly to runtime.Runtime.
179func ViperToRuntime(v *viper.Viper, feedsFile, prompt string) *runtime.Runtime {
180 rt := &runtime.Runtime{
181 FeedsFile: feedsFile,
182 Prompt: prompt,
183 Progress: &progress.NoopLogger{},
184 }
185
186 // Feed aggregation options
187 rt.MaxArticlesPerFeed = v.GetInt("max_articles_per_feed")
188 rt.MaxDaysOld = v.GetInt("max_days_old")
189 rt.MaxTotalArticles = v.GetInt("max_total_articles")
190
191 // Keywords
192 includeKeywords := v.GetString("include_keywords")
193 excludeKeywords := v.GetString("exclude_keywords")
194 if includeKeywords != "" {
195 rt.IncludeKeywords = cli.ParseKeywords(includeKeywords)
196 }
197 if excludeKeywords != "" {
198 rt.ExcludeKeywords = cli.ParseKeywords(excludeKeywords)
199 }
200
201 // LLM API options
202 rt.APIKey = v.GetString("api_key")
203 rt.BaseURL = v.GetString("base_url")
204 rt.Model = v.GetString("model")
205 rt.MaxTokens = v.GetInt("max_tokens")
206 rt.Temperature = v.GetFloat64("temperature")
207
208 // System prompt
209 rt.SystemPrompt = v.GetString("system_prompt")
210
211 // Output options
212 rt.Output = v.GetString("output")
213 rt.OutputFile = v.GetString("output_file")
214 rt.IncludeArticles = v.GetBool("include_articles")
215
216 return rt
217}
218
219// bindEnvVars binds environment variables to viper keys.
220func bindEnvVars(v *viper.Viper) {
221 // Feed aggregation options
222 v.BindEnv("max_articles_per_feed", "LLM_AGGREGATOR_MAX_ARTICLES_PER_FEED")
223 v.BindEnv("max_days_old", "LLM_AGGREGATOR_MAX_DAYS_OLD")
224 v.BindEnv("max_total_articles", "LLM_AGGREGATOR_MAX_TOTAL_ARTICLES")
225 v.BindEnv("include_keywords", "LLM_AGGREGATOR_INCLUDE_KEYWORDS")
226 v.BindEnv("exclude_keywords", "LLM_AGGREGATOR_EXCLUDE_KEYWORDS")
227
228 // LLM API options
229 v.BindEnv("api_key", "LLM_AGGREGATOR_API_KEY")
230 v.BindEnv("base_url", "LLM_AGGREGATOR_BASE_URL")
231 v.BindEnv("model", "LLM_AGGREGATOR_MODEL")
232 v.BindEnv("max_tokens", "LLM_AGGREGATOR_MAX_TOKENS")
233 v.BindEnv("temperature", "LLM_AGGREGATOR_TEMPERATURE")
234
235 // System prompt
236 v.BindEnv("system_prompt", "LLM_AGGREGATOR_SYSTEM_PROMPT")
237
238 // Output options
239 v.BindEnv("output", "LLM_AGGREGATOR_OUTPUT")
240 v.BindEnv("output_file", "LLM_AGGREGATOR_OUTPUT_FILE")
241 v.BindEnv("include_articles", "LLM_AGGREGATOR_INCLUDE_ARTICLES")
242}
243
244// GetConfigPath returns the path to the config file.
245func GetConfigPath() (string, error) {
246 configDir, err := os.UserConfigDir()
247 if err != nil {
248 return "", fmt.Errorf("failed to get user config directory: %w", err)
249 }
250 return filepath.Join(configDir, "llm_aggregator", "config.toml"), nil
251}
252
253// ConfigExists returns true if a config file exists.
254func ConfigExists() (bool, error) {
255 configPath, err := GetConfigPath()
256 if err != nil {
257 return false, err
258 }
259 _, err = os.Stat(configPath)
260 if err != nil {
261 if os.IsNotExist(err) {
262 return false, nil
263 }
264 return false, err
265 }
266 return true, nil
267}
268
269// Save saves the configuration to the TOML file.
270func (c *Config) Save() error {
271 v := viper.New()
272 v.SetConfigType("toml")
273
274 // Set values from config struct
275 v.Set("max_articles_per_feed", c.MaxArticlesPerFeed)
276 v.Set("max_days_old", c.MaxDaysOld)
277 v.Set("max_total_articles", c.MaxTotalArticles)
278 v.Set("include_keywords", c.IncludeKeywords)
279 v.Set("exclude_keywords", c.ExcludeKeywords)
280 v.Set("api_key", c.APIKey)
281 v.Set("base_url", c.BaseURL)
282 v.Set("model", c.Model)
283 v.Set("max_tokens", c.MaxTokens)
284 v.Set("temperature", c.Temperature)
285 v.Set("system_prompt", c.SystemPrompt)
286 v.Set("output", c.Output)
287 v.Set("output_file", c.OutputFile)
288 v.Set("include_articles", c.IncludeArticles)
289
290 // Get config path
291 configPath, err := GetConfigPath()
292 if err != nil {
293 return fmt.Errorf("failed to get config path: %w", err)
294 }
295
296 // Create directory if it doesn't exist
297 configDir := filepath.Dir(configPath)
298 if err := os.MkdirAll(configDir, 0755); err != nil {
299 return fmt.Errorf("failed to create config directory: %w", err)
300 }
301
302 // Write config file
303 if err := v.WriteConfigAs(configPath); err != nil {
304 return fmt.Errorf("failed to write config file: %w", err)
305 }
306
307 return nil
308}
309
310// IsLoaded returns true if the configuration was loaded from a file.
311func (c *Config) IsLoaded() bool {
312 // Check if config file exists - if it does, values would have been loaded from it
313 configPath, err := GetConfigPath()
314 if err != nil {
315 return false
316 }
317 _, err = os.Stat(configPath)
318 return err == nil
319}