1package config
2
3import (
4 "fmt"
5 "net/url"
6 "strings"
7
8 domainurl "github.com/bnema/dumber/internal/domain/url"
9 domainvalidation "github.com/bnema/dumber/internal/domain/validation"
10)
11
12// validateConfig performs comprehensive validation of configuration values
13func validateConfig(config *Config) error {
14 var validationErrors []string
15
16 validationErrors = append(validationErrors, validateHistory(config)...)
17 validationErrors = append(validationErrors, validateDmenu(config)...)
18 validationErrors = append(validationErrors, validateAppearance(config)...)
19 validationErrors = append(validationErrors, validateSearchEngine(config)...)
20 validationErrors = append(validationErrors, validatePopups(config)...)
21 validationErrors = append(validationErrors, validateWorkspaceStyling(config)...)
22 validationErrors = append(validationErrors, validatePaneMode(config)...)
23 validationErrors = append(validationErrors, validateTabBar(config)...)
24 validationErrors = append(validationErrors, validateTabMode(config)...)
25 validationErrors = append(validationErrors, validateLogging(config)...)
26 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
27 validationErrors = append(validationErrors, validateOmnibox(config)...)
28 validationErrors = append(validationErrors, validateRendering(config)...)
29 validationErrors = append(validationErrors, validateColorScheme(config)...)
30 validationErrors = append(validationErrors, validateSession(config)...)
31
32 // If there are validation errors, return them
33 if len(validationErrors) > 0 {
34 return fmt.Errorf("config validation failed:\n - %s", strings.Join(validationErrors, "\n - "))
35 }
36
37 return nil
38}
39
40func validateHistory(config *Config) []string {
41 var validationErrors []string
42 if config.History.MaxEntries < 0 {
43 validationErrors = append(validationErrors, "history.max_entries must be non-negative")
44 }
45 if config.History.RetentionPeriodDays < 0 {
46 validationErrors = append(validationErrors, "history.retention_period_days must be non-negative")
47 }
48 if config.History.CleanupIntervalDays < 0 {
49 validationErrors = append(validationErrors, "history.cleanup_interval_days must be non-negative")
50 }
51 return validationErrors
52}
53
54func validateDmenu(config *Config) []string {
55 if config.Dmenu.MaxHistoryItems < 0 {
56 return []string{"dmenu.max_history_items must be non-negative"}
57 }
58 return nil
59}
60
61func validateAppearance(config *Config) []string {
62 var validationErrors []string
63 if config.Appearance.DefaultFontSize < 1 || config.Appearance.DefaultFontSize > 72 {
64 validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72")
65 }
66 validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.sans_font", config.Appearance.SansFont)...)
67 validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.serif_font", config.Appearance.SerifFont)...)
68 validationErrors = append(
69 validationErrors,
70 domainvalidation.ValidateFontFamily("appearance.monospace_font", config.Appearance.MonospaceFont)...,
71 )
72 validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
73 "appearance.light_palette",
74 config.Appearance.LightPalette.Background,
75 config.Appearance.LightPalette.Surface,
76 config.Appearance.LightPalette.SurfaceVariant,
77 config.Appearance.LightPalette.Text,
78 config.Appearance.LightPalette.Muted,
79 config.Appearance.LightPalette.Accent,
80 config.Appearance.LightPalette.Border,
81 )...)
82 validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
83 "appearance.dark_palette",
84 config.Appearance.DarkPalette.Background,
85 config.Appearance.DarkPalette.Surface,
86 config.Appearance.DarkPalette.SurfaceVariant,
87 config.Appearance.DarkPalette.Text,
88 config.Appearance.DarkPalette.Muted,
89 config.Appearance.DarkPalette.Accent,
90 config.Appearance.DarkPalette.Border,
91 )...)
92 if config.DefaultWebpageZoom < 0.1 || config.DefaultWebpageZoom > 5.0 {
93 validationErrors = append(validationErrors, "default_webpage_zoom must be between 0.1 and 5.0")
94 }
95 if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 {
96 validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0")
97 }
98 return validationErrors
99}
100
101func validateSearchEngine(config *Config) []string {
102 if config.DefaultSearchEngine == "" {
103 return []string{"default_search_engine cannot be empty"}
104 }
105 if !strings.Contains(config.DefaultSearchEngine, "%s") {
106 return []string{"default_search_engine must contain %s placeholder for the search query"}
107 }
108 return nil
109}
110
111func validatePopups(config *Config) []string {
112 var validationErrors []string
113 switch config.Workspace.Popups.Behavior {
114 case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed:
115 default:
116 validationErrors = append(validationErrors, fmt.Sprintf(
117 "workspace.popups.behavior must be one of: split, stacked, tabbed, windowed (got: %s)",
118 config.Workspace.Popups.Behavior,
119 ))
120 }
121
122 if config.Workspace.Popups.Behavior == PopupBehaviorSplit {
123 switch config.Workspace.Popups.Placement {
124 case "right", "left", "top", "bottom":
125 default:
126 validationErrors = append(validationErrors, fmt.Sprintf(
127 "workspace.popups.placement must be one of: right, left, top, bottom (got: %s)",
128 config.Workspace.Popups.Placement,
129 ))
130 }
131 }
132
133 switch config.Workspace.Popups.BlankTargetBehavior {
134 case "split", "stacked", "tabbed":
135 default:
136 validationErrors = append(validationErrors, fmt.Sprintf(
137 "workspace.popups.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)",
138 config.Workspace.Popups.BlankTargetBehavior,
139 ))
140 }
141 return validationErrors
142}
143
144func validateWorkspaceStyling(config *Config) []string {
145 var validationErrors []string
146 if config.Workspace.Styling.BorderWidth < 0 {
147 validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")
148 }
149 if config.Workspace.Styling.ModeBorderWidth < 0 {
150 validationErrors = append(validationErrors, "workspace.styling.mode_border_width must be non-negative")
151 }
152 if config.Workspace.Styling.TransitionDuration < 0 {
153 validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
154 }
155 return validationErrors
156}
157
158func validatePaneMode(config *Config) []string {
159 var validationErrors []string
160 if config.Workspace.PaneMode.TimeoutMilliseconds < 0 {
161 validationErrors = append(validationErrors, "workspace.pane_mode.timeout_ms must be non-negative")
162 }
163 if len(config.Workspace.PaneMode.Actions) == 0 {
164 validationErrors = append(validationErrors, "workspace.pane_mode.actions cannot be empty")
165 }
166
167 seenKeys := make(map[string]string)
168 for action, keys := range config.Workspace.PaneMode.Actions {
169 if len(keys) == 0 {
170 validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action))
171 }
172 for _, key := range keys {
173 if existingAction, exists := seenKeys[key]; exists {
174 validationErrors = append(validationErrors, fmt.Sprintf(
175 "duplicate key binding '%s' found in pane_mode actions '%s' and '%s'",
176 key,
177 existingAction,
178 action,
179 ))
180 }
181 seenKeys[key] = action
182 }
183 }
184 return validationErrors
185}
186
187func validateTabBar(config *Config) []string {
188 switch config.Workspace.TabBarPosition {
189 case "top", "bottom":
190 return nil
191 default:
192 return []string{fmt.Sprintf("workspace.tab_bar_position must be 'top' or 'bottom' (got: %s)", config.Workspace.TabBarPosition)}
193 }
194}
195
196func validateTabMode(config *Config) []string {
197 var validationErrors []string
198 if config.Workspace.TabMode.TimeoutMilliseconds < 0 {
199 validationErrors = append(validationErrors, "workspace.tab_mode.timeout_ms must be non-negative")
200 }
201 if len(config.Workspace.TabMode.Actions) == 0 {
202 validationErrors = append(validationErrors, "workspace.tab_mode.actions cannot be empty")
203 }
204
205 tabSeenKeys := make(map[string]string)
206 for action, keys := range config.Workspace.TabMode.Actions {
207 if len(keys) == 0 {
208 validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_mode.actions.%s must have at least one key binding", action))
209 }
210 for _, key := range keys {
211 if existingAction, exists := tabSeenKeys[key]; exists {
212 validationErrors = append(validationErrors, fmt.Sprintf(
213 "duplicate key binding '%s' found in tab_mode actions '%s' and '%s'",
214 key,
215 existingAction,
216 action,
217 ))
218 }
219 tabSeenKeys[key] = action
220 }
221 }
222 return validationErrors
223}
224
225func validateLogging(config *Config) []string {
226 var validationErrors []string
227 if config.Logging.MaxAge < 0 {
228 validationErrors = append(validationErrors, "logging.max_age must be non-negative")
229 }
230 switch config.Logging.Level {
231 case "trace", "debug", "info", "warn", "error", "fatal", "":
232 default:
233 validationErrors = append(validationErrors, fmt.Sprintf(
234 "logging.level must be one of: trace, debug, info, warn, error, fatal (got: %s)",
235 config.Logging.Level,
236 ))
237 }
238 switch config.Logging.Format {
239 case "text", "json", "console", "":
240 default:
241 validationErrors = append(validationErrors, fmt.Sprintf(
242 "logging.format must be one of: text, json, console (got: %s)",
243 config.Logging.Format,
244 ))
245 }
246 return validationErrors
247}
248
249func validateWorkspaceNewPaneURL(config *Config) []string {
250 var validationErrors []string
251
252 if config.Workspace.NewPaneURL == "" {
253 validationErrors = append(validationErrors, "workspace.new_pane_url cannot be empty")
254 return validationErrors
255 }
256
257 normalized := domainurl.Normalize(config.Workspace.NewPaneURL)
258 parsed, err := url.Parse(normalized)
259 if err != nil {
260 validationErrors = append(validationErrors, fmt.Sprintf(
261 "workspace.new_pane_url must be a valid URL (got: %s)",
262 config.Workspace.NewPaneURL,
263 ))
264 return validationErrors
265 }
266
267 switch parsed.Scheme {
268 case "http", "https", "dumb", "file", "about":
269 // ok
270 default:
271 validationErrors = append(validationErrors, fmt.Sprintf(
272 "workspace.new_pane_url must use one of: http, https, dumb, file, about (got: %s)",
273 parsed.Scheme,
274 ))
275 }
276
277 return validationErrors
278}
279
280func validateOmnibox(config *Config) []string {
281 switch config.Omnibox.InitialBehavior {
282 case "recent", "most_visited", "none":
283 return nil
284 default:
285 return []string{fmt.Sprintf(
286 "omnibox.initial_behavior must be one of: recent, most_visited, none (got: %s)",
287 config.Omnibox.InitialBehavior,
288 )}
289 }
290}
291
292func validateRendering(config *Config) []string {
293 var validationErrors []string
294
295 switch config.Rendering.Mode {
296 case RenderingModeAuto, RenderingModeGPU, RenderingModeCPU, "":
297 default:
298 validationErrors = append(
299 validationErrors,
300 fmt.Sprintf("rendering.mode must be one of: auto, gpu, cpu (got: %s)", config.Rendering.Mode),
301 )
302 }
303
304 switch config.Rendering.GSKRenderer {
305 case GSKRendererAuto, GSKRendererOpenGL, GSKRendererVulkan, GSKRendererCairo, "":
306 default:
307 validationErrors = append(
308 validationErrors,
309 fmt.Sprintf(
310 "rendering.gsk_renderer must be one of: auto, opengl, vulkan, cairo (got: %s)",
311 config.Rendering.GSKRenderer,
312 ),
313 )
314 }
315
316 if config.Rendering.ForceCompositingMode && config.Rendering.DisableCompositingMode {
317 validationErrors = append(validationErrors,
318 "rendering.force_compositing_mode and rendering.disable_compositing_mode cannot both be true",
319 )
320 }
321
322 return validationErrors
323}
324
325func validateColorScheme(config *Config) []string {
326 switch config.Appearance.ColorScheme {
327 case ThemePreferDark, ThemePreferLight, ThemeDefault, "":
328 return nil
329 default:
330 return []string{fmt.Sprintf(
331 "appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)",
332 config.Appearance.ColorScheme,
333 )}
334 }
335}
336
337func validateSession(config *Config) []string {
338 var validationErrors []string
339 if config.Session.MaxExitedSessions < 0 {
340 validationErrors = append(validationErrors, "session.max_exited_sessions must be non-negative")
341 }
342 if config.Session.MaxExitedSessionAgeDays < 0 {
343 validationErrors = append(validationErrors, "session.max_exited_session_age_days must be non-negative")
344 }
345 if config.Session.SnapshotIntervalMs < 0 {
346 validationErrors = append(validationErrors, "session.snapshot_interval_ms must be non-negative")
347 }
348 return validationErrors
349}