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