all repos — llm_aggregator @ 4606d779c74690e4871c7f038cef0b43c93b6788

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)
 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}