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 Plain bool `mapstructure:"plain"`
41}
42
43// GetViper returns the global viper instance with all configuration sources set up.
44// The precedence order is: CLI flags > Environment variables > Config file > Defaults.
45//
46// NOTE: Viper uses a singleton pattern. Subsequent calls to GetViper() return the SAME
47// instance with all previously set values. This means defaults are only set once,
48// environment vars are bound once, and the config file is read once. Do NOT call this
49// expecting a fresh state — if you need a fresh instance for testing, use viper.New() directly.
50func GetViper() *viper.Viper {
51 // Set defaults first (lowest priority)
52 setDefaults()
53 // Initialise or get existing Viper instance
54 v := viper.GetViper()
55 // Ensure defaults are set (for fresh instances)
56 setDefaultsOn(v)
57 // Set environment variable prefix
58 v.SetEnvPrefix("LLM_AGGREGATOR")
59 v.AutomaticEnv()
60 // Bind environment variables to config fields
61 bindEnvVars(v)
62 // Get config path from XDG
63 configPath, err := GetConfigPath()
64 if err == nil {
65 // Set config file path
66 v.SetConfigFile(configPath)
67 // Debug: show what config file is being used
68 // Try to read config file
69 if err := v.ReadInConfig(); err != nil {
70 // If config file doesn't exist, that's OK - we'll use defaults + env vars
71 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
72 fmt.Fprintln(os.Stderr, style.Warningf("error reading config file: %v", err))
73 } else {
74 }
75 } else {
76 }
77 }
78 return v
79}
80
81// setDefaults sets default values on the global viper instance.
82func setDefaults() {
83 v := viper.GetViper()
84 setDefaultsOn(v)
85}
86
87// setDefaultsOn sets default values on the given viper instance.
88func setDefaultsOn(v *viper.Viper) {
89 // Feed aggregation defaults
90 v.SetDefault("max_articles_per_feed", defaults.DefaultMaxArticlesPerFeed)
91 v.SetDefault("max_days_old", defaults.DefaultMaxDaysOld)
92 v.SetDefault("max_total_articles", defaults.DefaultMaxTotalArticles)
93 // LLM API defaults
94 v.SetDefault("base_url", defaults.DefaultBaseURL)
95 v.SetDefault("model", defaults.DefaultModel)
96 v.SetDefault("max_tokens", defaults.DefaultMaxTokens)
97 v.SetDefault("temperature", defaults.DefaultTemperature)
98 // System prompt default
99 v.SetDefault("system_prompt", defaults.DefaultSystemPrompt)
100 // Output defaults
101 v.SetDefault("output", defaults.DefaultOutput)
102 v.SetDefault("include_articles", defaults.DefaultIncludeArticles)
103}
104
105// BindCLIArgs binds CLI argument values to viper with highest precedence.
106// This should be called after parsing CLI arguments to override config file/env defaults.
107func BindCLIArgs(v *viper.Viper, args map[string]any) {
108 for key, value := range args {
109 if value != nil && !isZero(value) {
110 v.Set(key, value)
111 }
112 }
113}
114
115// isZero checks if a value is the zero value for its type.
116//
117// NOTE: This function is critical for CLI argument handling. Pointer types
118// (*string, *int, etc.) return true only when nil (not provided), NOT when
119// pointing to a zero value. This allows distinguishing between "flag not
120// provided" (nil) vs "flag explicitly set to zero" (e.g., --temperature 0
121// should override default, but no flag means preserve default).
122func isZero(v any) bool {
123 // Handle nil interface values first (before type switch)
124 if v == nil {
125 return true
126 }
127 switch val := v.(type) {
128 case string:
129 return val == ""
130 case int:
131 return val == 0
132 case int8:
133 return val == 0
134 case int16:
135 return val == 0
136 case int32:
137 return val == 0
138 case int64:
139 return val == 0
140 case float32:
141 return val == 0.0
142 case float64:
143 return val == 0.0
144 case bool:
145 return !val
146 case *string:
147 return val == nil
148 case *int:
149 return val == nil
150 case *int8:
151 return val == nil
152 case *int16:
153 return val == nil
154 case *int32:
155 return val == nil
156 case *int64:
157 return val == nil
158 case *float32:
159 return val == nil
160 case *float64:
161 return val == nil
162 case *bool:
163 return val == nil
164 default:
165 return false
166 }
167}
168
169// ViperToRuntime converts viper configuration directly to runtime.Runtime.
170func ViperToRuntime(v *viper.Viper, feedsFile, prompt string) *runtime.Runtime {
171 rt := &runtime.Runtime{
172 FeedsFile: feedsFile,
173 Prompt: prompt,
174 Progress: &progress.NoopLogger{},
175 }
176 // Feed aggregation options
177 rt.MaxArticlesPerFeed = v.GetInt("max_articles_per_feed")
178 rt.MaxDaysOld = v.GetInt("max_days_old")
179 rt.MaxTotalArticles = v.GetInt("max_total_articles")
180 // Keywords
181 includeKeywords := v.GetString("include_keywords")
182 excludeKeywords := v.GetString("exclude_keywords")
183 if includeKeywords != "" {
184 rt.IncludeKeywords = cli.ParseKeywords(includeKeywords)
185 }
186 if excludeKeywords != "" {
187 rt.ExcludeKeywords = cli.ParseKeywords(excludeKeywords)
188 }
189 // LLM API options
190 rt.APIKey = v.GetString("api_key")
191 rt.BaseURL = v.GetString("base_url")
192 rt.Model = v.GetString("model")
193 rt.MaxTokens = v.GetInt("max_tokens")
194 rt.Temperature = v.GetFloat64("temperature")
195 // System prompt
196 rt.SystemPrompt = v.GetString("system_prompt")
197 // Output options
198 rt.Output = v.GetString("output")
199 rt.OutputFile = v.GetString("output_file")
200 rt.IncludeArticles = v.GetBool("include_articles")
201 rt.Plain = v.GetBool("plain")
202 return rt
203}
204
205// bindEnvVars binds environment variables to viper keys.
206func bindEnvVars(v *viper.Viper) {
207 // Feed aggregation options
208 v.BindEnv("max_articles_per_feed", "LLM_AGGREGATOR_MAX_ARTICLES_PER_FEED")
209 v.BindEnv("max_days_old", "LLM_AGGREGATOR_MAX_DAYS_OLD")
210 v.BindEnv("max_total_articles", "LLM_AGGREGATOR_MAX_TOTAL_ARTICLES")
211 v.BindEnv("include_keywords", "LLM_AGGREGATOR_INCLUDE_KEYWORDS")
212 v.BindEnv("exclude_keywords", "LLM_AGGREGATOR_EXCLUDE_KEYWORDS")
213 // LLM API options
214 v.BindEnv("api_key", "LLM_AGGREGATOR_API_KEY")
215 v.BindEnv("base_url", "LLM_AGGREGATOR_BASE_URL")
216 v.BindEnv("model", "LLM_AGGREGATOR_MODEL")
217 v.BindEnv("max_tokens", "LLM_AGGREGATOR_MAX_TOKENS")
218 v.BindEnv("temperature", "LLM_AGGREGATOR_TEMPERATURE")
219 // System prompt
220 v.BindEnv("system_prompt", "LLM_AGGREGATOR_SYSTEM_PROMPT")
221 // Output options
222 v.BindEnv("output", "LLM_AGGREGATOR_OUTPUT")
223 v.BindEnv("output_file", "LLM_AGGREGATOR_OUTPUT_FILE")
224 v.BindEnv("include_articles", "LLM_AGGREGATOR_INCLUDE_ARTICLES")
225 v.BindEnv("plain", "LLM_AGGREGATOR_PLAIN")
226}
227
228// GetConfigPath returns the path to the config file.
229func GetConfigPath() (string, error) {
230 configDir, err := os.UserConfigDir()
231 if err != nil {
232 return "", fmt.Errorf("failed to get user config directory: %w", err)
233 }
234 return filepath.Join(configDir, "llm_aggregator", "config.toml"), nil
235}