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
11// Config holds the application configuration loaded from TOML file, environment variables, and CLI arguments.
12type Config struct {
13 // Feed aggregation options
14 MaxArticlesPerFeed int `mapstructure:"max_articles_per_feed"`
15 MaxDaysOld int `mapstructure:"max_days_old"`
16 MaxTotalArticles int `mapstructure:"max_total_articles"`
17 IncludeKeywords string `mapstructure:"include_keywords"`
18 ExcludeKeywords string `mapstructure:"exclude_keywords"`
19
20 // Deepseek API options
21 APIKey string `mapstructure:"api_key"`
22 Model string `mapstructure:"model"`
23 MaxTokens int `mapstructure:"max_tokens"`
24 Temperature float64 `mapstructure:"temperature"`
25
26 // System prompt
27 SystemPrompt string `mapstructure:"system_prompt"`
28
29 // Output options
30 Output string `mapstructure:"output"`
31 OutputFile string `mapstructure:"output_file"`
32 IncludeArticles bool `mapstructure:"include_articles"`
33
34 // Internal state
35 loaded bool
36}
37
38// DefaultConfig returns a Config with sensible defaults.
39func DefaultConfig() *Config {
40 return &Config{
41 MaxArticlesPerFeed: 10,
42 MaxDaysOld: 7,
43 MaxTotalArticles: 20,
44 Model: "deepseek-chat",
45 MaxTokens: 4000,
46 Temperature: 0.7,
47 SystemPrompt: `You are an expert analyst and summariser.
48You analyse content from multiple sources and provide
49concise, insightful summaries based on user requests.
50Focus on key points, trends, and important information.`,
51 Output: "text",
52 IncludeArticles: false,
53 }
54}
55
56// Load loads configuration from TOML file, environment variables, and sets defaults.
57// The precedence order is: CLI args > Environment variables > Config file > Defaults.
58func Load() (*Config, error) {
59 config := DefaultConfig()
60
61 // Initialise Viper
62 v := viper.New()
63 v.SetConfigName("config")
64 v.SetConfigType("toml")
65
66 // Set environment variable prefix first
67 v.SetEnvPrefix("LLM_AGGREGATOR")
68 v.AutomaticEnv() // Read from environment variables
69
70 // Bind environment variables to config fields
71 bindEnvVars(v)
72
73 // Get config path from XDG
74 configPath, err := GetConfigPath()
75 if err != nil {
76 return nil, fmt.Errorf("failed to get config path: %w", err)
77 }
78
79 // Set config file path
80 v.AddConfigPath(filepath.Dir(configPath))
81
82 // Try to read config file
83 if err := v.ReadInConfig(); err != nil {
84 // If config file doesn't exist, that's OK - we'll use defaults + env vars
85 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
86 return nil, fmt.Errorf("error reading config file: %w", err)
87 }
88 } else {
89 // Config file was found
90 config.loaded = true
91 }
92
93 // Unmarshal config into struct
94 if err := v.Unmarshal(config); err != nil {
95 return nil, fmt.Errorf("error unmarshalling config: %w", err)
96 }
97
98 return config, nil
99}
100
101// bindEnvVars binds environment variables to viper keys.
102func bindEnvVars(v *viper.Viper) {
103 // Feed aggregation options
104 v.BindEnv("max_articles_per_feed", "LLM_AGGREGATOR_MAX_ARTICLES_PER_FEED")
105 v.BindEnv("max_days_old", "LLM_AGGREGATOR_MAX_DAYS_OLD")
106 v.BindEnv("max_total_articles", "LLM_AGGREGATOR_MAX_TOTAL_ARTICLES")
107 v.BindEnv("include_keywords", "LLM_AGGREGATOR_INCLUDE_KEYWORDS")
108 v.BindEnv("exclude_keywords", "LLM_AGGREGATOR_EXCLUDE_KEYWORDS")
109
110 // Deepseek API options
111 v.BindEnv("api_key", "LLM_AGGREGATOR_API_KEY")
112 v.BindEnv("model", "LLM_AGGREGATOR_MODEL")
113 v.BindEnv("max_tokens", "LLM_AGGREGATOR_MAX_TOKENS")
114 v.BindEnv("temperature", "LLM_AGGREGATOR_TEMPERATURE")
115
116 // System prompt
117 v.BindEnv("system_prompt", "LLM_AGGREGATOR_SYSTEM_PROMPT")
118
119 // Output options
120 v.BindEnv("output", "LLM_AGGREGATOR_OUTPUT")
121 v.BindEnv("output_file", "LLM_AGGREGATOR_OUTPUT_FILE")
122 v.BindEnv("include_articles", "LLM_AGGREGATOR_INCLUDE_ARTICLES")
123}
124
125// GetConfigPath returns the path to the config file.
126func GetConfigPath() (string, error) {
127 configDir, err := os.UserConfigDir()
128 if err != nil {
129 return "", fmt.Errorf("failed to get user config directory: %w", err)
130 }
131 return filepath.Join(configDir, "llm_aggregator", "config.toml"), nil
132}
133
134// ConfigExists returns true if a config file exists.
135func ConfigExists() (bool, error) {
136 configPath, err := GetConfigPath()
137 if err != nil {
138 return false, err
139 }
140 _, err = os.Stat(configPath)
141 if err != nil {
142 if os.IsNotExist(err) {
143 return false, nil
144 }
145 return false, err
146 }
147 return true, nil
148}
149
150// Save saves the configuration to the TOML file.
151func (c *Config) Save() error {
152 v := viper.New()
153 v.SetConfigType("toml")
154
155 // Set values from config struct
156 v.Set("max_articles_per_feed", c.MaxArticlesPerFeed)
157 v.Set("max_days_old", c.MaxDaysOld)
158 v.Set("max_total_articles", c.MaxTotalArticles)
159 v.Set("include_keywords", c.IncludeKeywords)
160 v.Set("exclude_keywords", c.ExcludeKeywords)
161 v.Set("api_key", c.APIKey)
162 v.Set("model", c.Model)
163 v.Set("max_tokens", c.MaxTokens)
164 v.Set("temperature", c.Temperature)
165 v.Set("system_prompt", c.SystemPrompt)
166 v.Set("output", c.Output)
167 v.Set("output_file", c.OutputFile)
168 v.Set("include_articles", c.IncludeArticles)
169
170 // Get config path
171 configPath, err := GetConfigPath()
172 if err != nil {
173 return fmt.Errorf("failed to get config path: %w", err)
174 }
175
176 // Create directory if it doesn't exist
177 configDir := filepath.Dir(configPath)
178 if err := os.MkdirAll(configDir, 0755); err != nil {
179 return fmt.Errorf("failed to create config directory: %w", err)
180 }
181
182 // Write config file
183 if err := v.WriteConfigAs(configPath); err != nil {
184 return fmt.Errorf("failed to write config file: %w", err)
185 }
186
187 return nil
188}
189
190// IsLoaded returns true if the configuration was loaded from a file.
191func (c *Config) IsLoaded() bool {
192 return c.loaded
193}