all repos — llm_aggregator @ 4316d19f105d3ded269a393c048b38d2e83d3141

A CLI tool to aggregate RSS feeds and summarise them with LLMs

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}