all repos — llm_aggregator @ 715e67108f4989876184fa73e7dcadecc63d59af

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