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