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/progress"
12 "llm_aggregator/internal/runtime"
13)
14
15// Config holds the application configuration loaded from TOML file, environment variables, and CLI arguments.
16type Config struct {
17 // Feed aggregation options
18 MaxArticlesPerFeed int `mapstructure:"max_articles_per_feed"`
19 MaxDaysOld int `mapstructure:"max_days_old"`
20 MaxTotalArticles int `mapstructure:"max_total_articles"`
21 IncludeKeywords string `mapstructure:"include_keywords"`
22 ExcludeKeywords string `mapstructure:"exclude_keywords"`
23
24 // LLM API options
25 APIKey string `mapstructure:"api_key"`
26 Model string `mapstructure:"model"`
27 MaxTokens int `mapstructure:"max_tokens"`
28 Temperature float64 `mapstructure:"temperature"`
29
30 // System prompt
31 SystemPrompt string `mapstructure:"system_prompt"`
32
33 // Output options
34 Output string `mapstructure:"output"`
35 OutputFile string `mapstructure:"output_file"`
36 IncludeArticles bool `mapstructure:"include_articles"`
37}
38
39// DefaultConfig returns a Config with sensible defaults.
40func DefaultConfig() *Config {
41 return &Config{
42 MaxArticlesPerFeed: 10,
43 MaxDaysOld: 7,
44 MaxTotalArticles: 20,
45 Model: "deepseek-chat",
46 MaxTokens: 4000,
47 Temperature: 0.7,
48 SystemPrompt: `You are an expert analyst and summariser.
49You analyse content from multiple sources and provide
50concise, insightful summaries based on user requests.
51Focus on key points, trends, and important information.`,
52 Output: "text",
53 IncludeArticles: false,
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", 10)
114 v.SetDefault("max_days_old", 7)
115 v.SetDefault("max_total_articles", 20)
116
117 // LLM API defaults
118 v.SetDefault("model", "deepseek-chat")
119 v.SetDefault("max_tokens", 4000)
120 v.SetDefault("temperature", 0.7)
121
122 // System prompt default
123 v.SetDefault("system_prompt", `You are an expert analyst and summariser.
124You analyse content from multiple sources and provide
125concise, insightful summaries based on user requests.
126Focus on key points, trends, and important information.`)
127
128 // Output defaults
129 v.SetDefault("output", "text")
130 v.SetDefault("include_articles", false)
131}
132
133// ViperToConfig converts viper configuration to a Config struct.
134func ViperToConfig(v *viper.Viper) *Config {
135 return &Config{
136 MaxArticlesPerFeed: v.GetInt("max_articles_per_feed"),
137 MaxDaysOld: v.GetInt("max_days_old"),
138 MaxTotalArticles: v.GetInt("max_total_articles"),
139 IncludeKeywords: v.GetString("include_keywords"),
140 ExcludeKeywords: v.GetString("exclude_keywords"),
141 APIKey: v.GetString("api_key"),
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.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("model", "LLM_AGGREGATOR_MODEL")
230 v.BindEnv("max_tokens", "LLM_AGGREGATOR_MAX_TOKENS")
231 v.BindEnv("temperature", "LLM_AGGREGATOR_TEMPERATURE")
232
233 // System prompt
234 v.BindEnv("system_prompt", "LLM_AGGREGATOR_SYSTEM_PROMPT")
235
236 // Output options
237 v.BindEnv("output", "LLM_AGGREGATOR_OUTPUT")
238 v.BindEnv("output_file", "LLM_AGGREGATOR_OUTPUT_FILE")
239 v.BindEnv("include_articles", "LLM_AGGREGATOR_INCLUDE_ARTICLES")
240}
241
242// GetConfigPath returns the path to the config file.
243func GetConfigPath() (string, error) {
244 configDir, err := os.UserConfigDir()
245 if err != nil {
246 return "", fmt.Errorf("failed to get user config directory: %w", err)
247 }
248 return filepath.Join(configDir, "llm_aggregator", "config.toml"), nil
249}
250
251// ConfigExists returns true if a config file exists.
252func ConfigExists() (bool, error) {
253 configPath, err := GetConfigPath()
254 if err != nil {
255 return false, err
256 }
257 _, err = os.Stat(configPath)
258 if err != nil {
259 if os.IsNotExist(err) {
260 return false, nil
261 }
262 return false, err
263 }
264 return true, nil
265}
266
267// Save saves the configuration to the TOML file.
268func (c *Config) Save() error {
269 v := viper.New()
270 v.SetConfigType("toml")
271
272 // Set values from config struct
273 v.Set("max_articles_per_feed", c.MaxArticlesPerFeed)
274 v.Set("max_days_old", c.MaxDaysOld)
275 v.Set("max_total_articles", c.MaxTotalArticles)
276 v.Set("include_keywords", c.IncludeKeywords)
277 v.Set("exclude_keywords", c.ExcludeKeywords)
278 v.Set("api_key", c.APIKey)
279 v.Set("model", c.Model)
280 v.Set("max_tokens", c.MaxTokens)
281 v.Set("temperature", c.Temperature)
282 v.Set("system_prompt", c.SystemPrompt)
283 v.Set("output", c.Output)
284 v.Set("output_file", c.OutputFile)
285 v.Set("include_articles", c.IncludeArticles)
286
287 // Get config path
288 configPath, err := GetConfigPath()
289 if err != nil {
290 return fmt.Errorf("failed to get config path: %w", err)
291 }
292
293 // Create directory if it doesn't exist
294 configDir := filepath.Dir(configPath)
295 if err := os.MkdirAll(configDir, 0755); err != nil {
296 return fmt.Errorf("failed to create config directory: %w", err)
297 }
298
299 // Write config file
300 if err := v.WriteConfigAs(configPath); err != nil {
301 return fmt.Errorf("failed to write config file: %w", err)
302 }
303
304 return nil
305}
306
307// IsLoaded returns true if the configuration was loaded from a file.
308func (c *Config) IsLoaded() bool {
309 // Check if config file exists - if it does, values would have been loaded from it
310 configPath, err := GetConfigPath()
311 if err != nil {
312 return false
313 }
314 _, err = os.Stat(configPath)
315 return err == nil
316}