1// Package config provides configuration management for dumber with Viper integration.
2package config
3
4import (
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "time"
12
13 "github.com/fsnotify/fsnotify"
14 "github.com/spf13/viper"
15)
16
17// Config represents the complete configuration for dumber.
18type Config struct {
19 Database DatabaseConfig `mapstructure:"database" yaml:"database"`
20 History HistoryConfig `mapstructure:"history" yaml:"history"`
21 SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"`
22 Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"`
23 Logging LoggingConfig `mapstructure:"logging" yaml:"logging"`
24 Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"`
25}
26
27// DatabaseConfig holds database-related configuration.
28type DatabaseConfig struct {
29 Path string `mapstructure:"path" yaml:"path"`
30 MaxConnections int `mapstructure:"max_connections" yaml:"max_connections"`
31 MaxIdleTime time.Duration `mapstructure:"max_idle_time" yaml:"max_idle_time"`
32 QueryTimeout time.Duration `mapstructure:"query_timeout" yaml:"query_timeout"`
33}
34
35// HistoryConfig holds history-related configuration.
36type HistoryConfig struct {
37 MaxEntries int `mapstructure:"max_entries" yaml:"max_entries"`
38 RetentionPeriodDays int `mapstructure:"retention_period_days" yaml:"retention_period_days"`
39 CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days"`
40}
41
42// SearchShortcut represents a search shortcut configuration.
43type SearchShortcut struct {
44 URL string `mapstructure:"url" yaml:"url"`
45 Description string `mapstructure:"description" yaml:"description"`
46}
47
48// DmenuConfig holds dmenu/rofi integration configuration.
49type DmenuConfig struct {
50 MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items"`
51 ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count"`
52 ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited"`
53 HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix"`
54 ShortcutPrefix string `mapstructure:"shortcut_prefix" yaml:"shortcut_prefix"`
55 URLPrefix string `mapstructure:"url_prefix" yaml:"url_prefix"`
56 DateFormat string `mapstructure:"date_format" yaml:"date_format"`
57 SortByVisitCount bool `mapstructure:"sort_by_visit_count" yaml:"sort_by_visit_count"`
58}
59
60// LoggingConfig holds logging configuration.
61type LoggingConfig struct {
62 Level string `mapstructure:"level" yaml:"level"`
63 Format string `mapstructure:"format" yaml:"format"`
64 Filename string `mapstructure:"filename" yaml:"filename"`
65 MaxSize int `mapstructure:"max_size" yaml:"max_size"`
66 MaxBackups int `mapstructure:"max_backups" yaml:"max_backups"`
67 MaxAge int `mapstructure:"max_age" yaml:"max_age"`
68 Compress bool `mapstructure:"compress" yaml:"compress"`
69}
70
71// AppearanceConfig holds UI/rendering preferences.
72type AppearanceConfig struct {
73 // Default fonts for pages that do not specify fonts.
74 SansFont string `mapstructure:"sans_font" yaml:"sans_font"`
75 SerifFont string `mapstructure:"serif_font" yaml:"serif_font"`
76 MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font"`
77 // Default font size in CSS pixels (approx).
78 DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size"`
79}
80
81// Manager handles configuration loading, watching, and reloading.
82type Manager struct {
83 config *Config
84 viper *viper.Viper
85 mu sync.RWMutex
86 callbacks []func(*Config)
87 watching bool
88}
89
90// NewManager creates a new configuration manager.
91func NewManager() (*Manager, error) {
92 v := viper.New()
93
94 // Configure Viper - supports yaml, json, toml automatically
95 v.SetConfigName("config") // Will find config.yaml, config.json, config.toml, etc.
96
97 // Add config paths
98 configDir, err := GetConfigDir()
99 if err != nil {
100 return nil, fmt.Errorf("failed to get config directory: %w", err)
101 }
102 v.AddConfigPath(configDir)
103 v.AddConfigPath(".") // Current directory for development
104
105 // Set up environment variable support
106 v.SetEnvPrefix("DUMB_BROWSER")
107 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
108 v.AutomaticEnv()
109
110 // Bind specific environment variables
111 bindings := map[string]string{
112 "database.path": "DATABASE_PATH",
113 "database.max_connections": "DATABASE_MAX_CONNECTIONS",
114 "database.max_idle_time": "DATABASE_MAX_IDLE_TIME",
115 "database.query_timeout": "DATABASE_QUERY_TIMEOUT",
116 "browser.command": "BROWSER_COMMAND",
117 "browser.timeout": "BROWSER_TIMEOUT",
118 "browser.detach_process": "BROWSER_DETACH_PROCESS",
119 "history.max_entries": "HISTORY_MAX_ENTRIES",
120 "history.retention_period": "HISTORY_RETENTION_PERIOD",
121 "history.cleanup_interval": "HISTORY_CLEANUP_INTERVAL",
122 "dmenu.max_history_items": "DMENU_MAX_HISTORY_ITEMS",
123 "dmenu.show_visit_count": "DMENU_SHOW_VISIT_COUNT",
124 "dmenu.show_last_visited": "DMENU_SHOW_LAST_VISITED",
125 "dmenu.history_prefix": "DMENU_HISTORY_PREFIX",
126 "dmenu.shortcut_prefix": "DMENU_SHORTCUT_PREFIX",
127 "dmenu.url_prefix": "DMENU_URL_PREFIX",
128 "dmenu.date_format": "DMENU_DATE_FORMAT",
129 "dmenu.sort_by_visit_count": "DMENU_SORT_BY_VISIT_COUNT",
130 "logging.level": "LOGGING_LEVEL",
131 "logging.format": "LOGGING_FORMAT",
132 "logging.filename": "LOGGING_FILENAME",
133 "logging.max_size": "LOGGING_MAX_SIZE",
134 "logging.max_backups": "LOGGING_MAX_BACKUPS",
135 "logging.max_age": "LOGGING_MAX_AGE",
136 "logging.compress": "LOGGING_COMPRESS",
137 }
138
139 for key, env := range bindings {
140 if err := v.BindEnv(key, "DUMB_BROWSER_"+env); err != nil {
141 return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
142 }
143 }
144
145 return &Manager{
146 viper: v,
147 callbacks: make([]func(*Config), 0),
148 }, nil
149}
150
151// Load loads the configuration from file and environment variables.
152func (m *Manager) Load() error {
153 m.mu.Lock()
154 defer m.mu.Unlock()
155
156 // Ensure directories exist
157 if err := EnsureDirectories(); err != nil {
158 return fmt.Errorf("failed to ensure directories: %w", err)
159 }
160
161 // Set defaults
162 m.setDefaults()
163
164 // Read config file if it exists
165 if err := m.viper.ReadInConfig(); err != nil {
166 if _, ok := err.(viper.ConfigFileNotFoundError); ok {
167 // Config file not found, create default one
168 if err := m.createDefaultConfig(); err != nil {
169 return fmt.Errorf("failed to create default config: %w", err)
170 }
171 } else {
172 return fmt.Errorf("failed to read config file: %w", err)
173 }
174 }
175
176 // Unmarshal into config struct
177 config := &Config{}
178 if err := m.viper.Unmarshal(config); err != nil {
179 return fmt.Errorf("failed to unmarshal config: %w", err)
180 }
181
182 // Set database path if not specified
183 if config.Database.Path == "" {
184 dbPath, err := GetDatabaseFile()
185 if err != nil {
186 return fmt.Errorf("failed to get database path: %w", err)
187 }
188 config.Database.Path = dbPath
189 }
190
191 m.config = config
192 return nil
193}
194
195// Get returns the current configuration (thread-safe).
196func (m *Manager) Get() *Config {
197 m.mu.RLock()
198 defer m.mu.RUnlock()
199
200 // Return a copy to prevent external modification
201 configCopy := *m.config
202 return &configCopy
203}
204
205// Watch starts watching the config file for changes and reloads automatically.
206func (m *Manager) Watch() error {
207 m.mu.Lock()
208 defer m.mu.Unlock()
209
210 if m.watching {
211 return nil // Already watching
212 }
213
214 m.viper.WatchConfig()
215 m.viper.OnConfigChange(func(_ fsnotify.Event) {
216 // Reload config
217 if err := m.reload(); err != nil {
218 fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err)
219 return
220 }
221
222 // Notify callbacks
223 m.mu.RLock()
224 config := m.config
225 callbacks := make([]func(*Config), len(m.callbacks))
226 copy(callbacks, m.callbacks)
227 m.mu.RUnlock()
228
229 for _, callback := range callbacks {
230 callback(config)
231 }
232 })
233
234 m.watching = true
235 return nil
236}
237
238// OnConfigChange registers a callback function to be called when config changes.
239func (m *Manager) OnConfigChange(callback func(*Config)) {
240 m.mu.Lock()
241 defer m.mu.Unlock()
242
243 m.callbacks = append(m.callbacks, callback)
244}
245
246// reload reloads the configuration (internal method, must be called with lock).
247func (m *Manager) reload() error {
248 if err := m.viper.ReadInConfig(); err != nil {
249 return err
250 }
251
252 config := &Config{}
253 if err := m.viper.Unmarshal(config); err != nil {
254 return err
255 }
256
257 // Set database path if not specified
258 if config.Database.Path == "" {
259 dbPath, err := GetDatabaseFile()
260 if err != nil {
261 return fmt.Errorf("failed to get database path: %w", err)
262 }
263 config.Database.Path = dbPath
264 }
265
266 m.config = config
267 return nil
268}
269
270// setDefaults sets default configuration values in Viper.
271func (m *Manager) setDefaults() {
272 defaults := DefaultConfig()
273
274 // Database defaults
275 m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections)
276 m.viper.SetDefault("database.max_idle_time", defaults.Database.MaxIdleTime)
277 m.viper.SetDefault("database.query_timeout", defaults.Database.QueryTimeout)
278
279 // Note: Browser config removed - we build our own browser
280
281 // History defaults
282 m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries)
283 m.viper.SetDefault("history.retention_period_days", defaults.History.RetentionPeriodDays)
284 m.viper.SetDefault("history.cleanup_interval_days", defaults.History.CleanupIntervalDays)
285
286 // Search shortcuts defaults
287 m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts)
288
289 // Dmenu defaults
290 m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)
291 m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
292 m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
293 m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
294 m.viper.SetDefault("dmenu.shortcut_prefix", defaults.Dmenu.ShortcutPrefix)
295 m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix)
296 m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat)
297 m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount)
298
299 // Logging defaults
300 m.viper.SetDefault("logging.level", defaults.Logging.Level)
301 m.viper.SetDefault("logging.format", defaults.Logging.Format)
302 m.viper.SetDefault("logging.filename", defaults.Logging.Filename)
303 m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize)
304 m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups)
305 m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
306 m.viper.SetDefault("logging.compress", defaults.Logging.Compress)
307
308 // Appearance defaults
309 m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
310 m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
311 m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)
312 m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize)
313}
314
315// createDefaultConfig creates a default configuration file.
316func (m *Manager) createDefaultConfig() error {
317 configFile, err := GetConfigFile()
318 if err != nil {
319 return err
320 }
321
322 // Create config directory if it doesn't exist
323 if err := os.MkdirAll(filepath.Dir(configFile), 0755); err != nil {
324 return err
325 }
326
327 // Get the default configuration
328 defaultConfig := DefaultConfig()
329
330 // Marshal to JSON with proper indentation
331 configData, err := json.MarshalIndent(defaultConfig, "", " ")
332 if err != nil {
333 return fmt.Errorf("failed to marshal default config: %w", err)
334 }
335
336 // Write JSON config file
337 if err := os.WriteFile(configFile, configData, 0644); err != nil {
338 return fmt.Errorf("failed to write config file: %w", err)
339 }
340
341 fmt.Printf("Created default configuration file: %s\n", configFile)
342 return nil
343}
344
345// GetConfigFile returns the path to the configuration file being used.
346func (m *Manager) GetConfigFile() string {
347 return m.viper.ConfigFileUsed()
348}
349
350// Global configuration manager instance
351var globalManager *Manager
352var globalManagerOnce sync.Once
353
354// Init initializes the global configuration manager.
355func Init() error {
356 var err error
357 globalManagerOnce.Do(func() {
358 globalManager, err = NewManager()
359 if err != nil {
360 return
361 }
362 err = globalManager.Load()
363 })
364 return err
365}
366
367// Get returns the global configuration.
368func Get() *Config {
369 if globalManager == nil {
370 // Return defaults if not initialized
371 return DefaultConfig()
372 }
373 return globalManager.Get()
374}
375
376// Watch starts watching the global configuration for changes.
377func Watch() error {
378 if globalManager == nil {
379 return fmt.Errorf("configuration not initialized")
380 }
381 return globalManager.Watch()
382}
383
384// OnConfigChange registers a callback for global configuration changes.
385func OnConfigChange(callback func(*Config)) {
386 if globalManager == nil {
387 return
388 }
389 globalManager.OnConfigChange(callback)
390}