1package config
2
3import (
4 "fmt"
5 "net/url"
6 "sort"
7 "strings"
8
9 domainurl "github.com/bnema/dumber/internal/domain/url"
10 domainvalidation "github.com/bnema/dumber/internal/domain/validation"
11)
12
13const cefLogSeverityDisabled = 99
14
15// validateConfig performs comprehensive validation of configuration values
16func validateConfig(config *Config) error {
17 var validationErrors []string
18
19 validationErrors = append(validationErrors, validateHistory(config)...)
20 validationErrors = append(validationErrors, validateDmenu(config)...)
21 validationErrors = append(validationErrors, validateAppearance(config)...)
22 validationErrors = append(validationErrors, validateSearchEngine(config)...)
23 validationErrors = append(validationErrors, validatePopups(config)...)
24 validationErrors = append(validationErrors, validateWorkspaceStyling(config)...)
25 validationErrors = append(validationErrors, validatePaneMode(config)...)
26 validationErrors = append(validationErrors, validateTabBar(config)...)
27 validationErrors = append(validationErrors, validateTabMode(config)...)
28 validationErrors = append(validationErrors, validateFloatingPane(config)...)
29 validationErrors = append(validationErrors, validateLogging(config)...)
30 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
31 validationErrors = append(validationErrors, validateOmnibox(config)...)
32 validationErrors = append(validationErrors, validateRendering(config)...)
33 validationErrors = append(validationErrors, validatePrivacy(config)...)
34 validationErrors = append(validationErrors, validateColorScheme(config)...)
35 validationErrors = append(validationErrors, validateSession(config)...)
36 validationErrors = append(validationErrors, validatePerformanceProfile(config)...)
37 validationErrors = append(validationErrors, validateCEF(config)...)
38
39 // If there are validation errors, return them
40 if len(validationErrors) > 0 {
41 return fmt.Errorf("config validation failed:\n - %s", strings.Join(validationErrors, "\n - "))
42 }
43
44 return nil
45}
46
47func validateHistory(config *Config) []string {
48 var validationErrors []string
49 if config.History.MaxEntries < 0 {
50 validationErrors = append(validationErrors, "history.max_entries must be non-negative")
51 }
52 if config.History.RetentionPeriodDays < 0 {
53 validationErrors = append(validationErrors, "history.retention_period_days must be non-negative")
54 }
55 if config.History.CleanupIntervalDays < 0 {
56 validationErrors = append(validationErrors, "history.cleanup_interval_days must be non-negative")
57 }
58 return validationErrors
59}
60
61func validateDmenu(config *Config) []string {
62 if config.Dmenu.MaxHistoryDays < 0 {
63 return []string{"dmenu.max_history_days must be non-negative"}
64 }
65 return nil
66}
67
68func validateAppearance(config *Config) []string {
69 var validationErrors []string
70 if config.Appearance.DefaultFontSize < 1 || config.Appearance.DefaultFontSize > 72 {
71 validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72")
72 }
73 validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.sans_font", config.Appearance.SansFont)...)
74 validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.serif_font", config.Appearance.SerifFont)...)
75 validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.gtk_font", config.Appearance.GtkFont)...)
76 validationErrors = append(
77 validationErrors,
78 domainvalidation.ValidateFontFamily("appearance.monospace_font", config.Appearance.MonospaceFont)...,
79 )
80 validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
81 "appearance.light_palette",
82 config.Appearance.LightPalette.Background,
83 config.Appearance.LightPalette.Surface,
84 config.Appearance.LightPalette.SurfaceVariant,
85 config.Appearance.LightPalette.Text,
86 config.Appearance.LightPalette.Muted,
87 config.Appearance.LightPalette.Accent,
88 config.Appearance.LightPalette.Border,
89 )...)
90 validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
91 "appearance.dark_palette",
92 config.Appearance.DarkPalette.Background,
93 config.Appearance.DarkPalette.Surface,
94 config.Appearance.DarkPalette.SurfaceVariant,
95 config.Appearance.DarkPalette.Text,
96 config.Appearance.DarkPalette.Muted,
97 config.Appearance.DarkPalette.Accent,
98 config.Appearance.DarkPalette.Border,
99 )...)
100 if config.DefaultWebpageZoom < 0.1 || config.DefaultWebpageZoom > 5.0 {
101 validationErrors = append(validationErrors, "default_webpage_zoom must be between 0.1 and 5.0")
102 }
103 if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 {
104 validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0")
105 }
106 return validationErrors
107}
108
109func validateSearchEngine(config *Config) []string {
110 if config.DefaultSearchEngine == "" {
111 return []string{"default_search_engine cannot be empty"}
112 }
113 if !strings.Contains(config.DefaultSearchEngine, "%s") {
114 return []string{"default_search_engine must contain %s placeholder for the search query"}
115 }
116 return nil
117}
118
119func validatePopups(config *Config) []string {
120 var validationErrors []string
121 // Validate against BrowsingContexts (canonical field).
122 // Popups is a runtime alias synced during normalization.
123 switch config.Workspace.BrowsingContexts.Behavior {
124 case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed:
125 default:
126 validationErrors = append(validationErrors, fmt.Sprintf(
127 "workspace.browsing_contexts.behavior must be one of: split, stacked, tabbed, windowed (got: %s)",
128 config.Workspace.BrowsingContexts.Behavior,
129 ))
130 }
131
132 if config.Workspace.BrowsingContexts.Behavior == PopupBehaviorSplit {
133 switch config.Workspace.BrowsingContexts.Placement {
134 case "right", "left", "top", "bottom":
135 default:
136 validationErrors = append(validationErrors, fmt.Sprintf(
137 "workspace.browsing_contexts.placement must be one of: right, left, top, bottom (got: %s)",
138 config.Workspace.BrowsingContexts.Placement,
139 ))
140 }
141 }
142
143 switch config.Workspace.BrowsingContexts.BlankTargetBehavior {
144 case "split", "stacked", "tabbed":
145 default:
146 validationErrors = append(validationErrors, fmt.Sprintf(
147 "workspace.browsing_contexts.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)",
148 config.Workspace.BrowsingContexts.BlankTargetBehavior,
149 ))
150 }
151 return validationErrors
152}
153
154func validateWorkspaceStyling(config *Config) []string {
155 var validationErrors []string
156 if config.Workspace.Styling.BorderWidth < 0 {
157 validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")
158 }
159 if config.Workspace.Styling.ModeBorderWidth < 0 {
160 validationErrors = append(validationErrors, "workspace.styling.mode_border_width must be non-negative")
161 }
162 if config.Workspace.Styling.TransitionDuration < 0 {
163 validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
164 }
165 return validationErrors
166}
167
168func validatePaneMode(config *Config) []string {
169 var validationErrors []string
170 if config.Workspace.PaneMode.TimeoutMilliseconds < 0 {
171 validationErrors = append(validationErrors, "workspace.pane_mode.timeout_ms must be non-negative")
172 }
173 if len(config.Workspace.PaneMode.Actions) == 0 {
174 validationErrors = append(validationErrors, "workspace.pane_mode.actions cannot be empty")
175 }
176
177 seenKeys := make(map[string]string)
178 for action, binding := range config.Workspace.PaneMode.Actions {
179 if len(binding.Keys) == 0 {
180 validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action))
181 }
182 for _, key := range binding.Keys {
183 if existingAction, exists := seenKeys[key]; exists {
184 validationErrors = append(validationErrors, fmt.Sprintf(
185 "duplicate key binding '%s' found in pane_mode actions '%s' and '%s'",
186 key,
187 existingAction,
188 action,
189 ))
190 }
191 seenKeys[key] = action
192 }
193 }
194 return validationErrors
195}
196
197func validateTabBar(config *Config) []string {
198 switch config.Workspace.TabBarPosition {
199 case "top", "bottom":
200 return nil
201 default:
202 return []string{fmt.Sprintf("workspace.tab_bar_position must be 'top' or 'bottom' (got: %s)", config.Workspace.TabBarPosition)}
203 }
204}
205
206func validateTabMode(config *Config) []string {
207 var validationErrors []string
208 if config.Workspace.TabMode.TimeoutMilliseconds < 0 {
209 validationErrors = append(validationErrors, "workspace.tab_mode.timeout_ms must be non-negative")
210 }
211 if len(config.Workspace.TabMode.Actions) == 0 {
212 validationErrors = append(validationErrors, "workspace.tab_mode.actions cannot be empty")
213 }
214
215 tabSeenKeys := make(map[string]string)
216 for action, binding := range config.Workspace.TabMode.Actions {
217 if len(binding.Keys) == 0 {
218 validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_mode.actions.%s must have at least one key binding", action))
219 }
220 for _, key := range binding.Keys {
221 if existingAction, exists := tabSeenKeys[key]; exists {
222 validationErrors = append(validationErrors, fmt.Sprintf(
223 "duplicate key binding '%s' found in tab_mode actions '%s' and '%s'",
224 key,
225 existingAction,
226 action,
227 ))
228 }
229 tabSeenKeys[key] = action
230 }
231 }
232 return validationErrors
233}
234
235func validateFloatingPane(config *Config) []string {
236 var validationErrors []string
237 seenProfileKeys := make(map[string]string)
238
239 if config.Workspace.FloatingPane.WidthPct <= 0 || config.Workspace.FloatingPane.WidthPct > 1 {
240 validationErrors = append(validationErrors, "workspace.floating_pane.width_pct must be in (0,1]")
241 }
242
243 if config.Workspace.FloatingPane.HeightPct <= 0 || config.Workspace.FloatingPane.HeightPct > 1 {
244 validationErrors = append(validationErrors, "workspace.floating_pane.height_pct must be in (0,1]")
245 }
246
247 profileNames := make([]string, 0, len(config.Workspace.FloatingPane.Profiles))
248 for profileName := range config.Workspace.FloatingPane.Profiles {
249 profileNames = append(profileNames, profileName)
250 }
251 sort.Strings(profileNames)
252
253 for _, profileName := range profileNames {
254 profile := config.Workspace.FloatingPane.Profiles[profileName]
255 if strings.TrimSpace(profile.URL) == "" {
256 validationErrors = append(validationErrors, fmt.Sprintf(
257 "workspace.floating_pane.profiles.%s.url is required",
258 profileName,
259 ))
260 } else {
261 validationErrors = append(validationErrors, validateWorkspaceURLValue(
262 fmt.Sprintf("workspace.floating_pane.profiles.%s.url", profileName),
263 profile.URL,
264 )...)
265 }
266 if len(profile.Keys) == 0 {
267 validationErrors = append(validationErrors, fmt.Sprintf(
268 "workspace.floating_pane.profiles.%s.keys must have at least one entry",
269 profileName,
270 ))
271 }
272
273 for _, key := range profile.Keys {
274 normalizedKey := normalizeBindingKeyString(key)
275 if normalizedKey == "" {
276 validationErrors = append(validationErrors, fmt.Sprintf(
277 "workspace.floating_pane.profiles.%s.keys contains empty or whitespace-only key binding %q",
278 profileName,
279 key,
280 ))
281 continue
282 }
283
284 if existingProfile, exists := seenProfileKeys[normalizedKey]; exists {
285 validationErrors = append(validationErrors, fmt.Sprintf(
286 "workspace.floating_pane.profiles.%s.keys contains duplicate key binding %q already used by profile %q",
287 profileName,
288 key,
289 existingProfile,
290 ))
291 continue
292 }
293
294 seenProfileKeys[normalizedKey] = profileName
295 }
296 }
297
298 return validationErrors
299}
300
301func normalizeBindingKeyString(key string) string {
302 normalized := strings.ToLower(strings.TrimSpace(key))
303 if normalized == "" {
304 return ""
305 }
306 return strings.Join(strings.Fields(normalized), "")
307}
308
309func validateLogging(config *Config) []string {
310 var validationErrors []string
311 if config.Logging.MaxAge < 0 {
312 validationErrors = append(validationErrors, "logging.max_age must be non-negative")
313 }
314 if config.Logging.MaxFiles < 0 {
315 validationErrors = append(validationErrors, "logging.max_files must be non-negative")
316 }
317 switch config.Logging.Level {
318 case "trace", "debug", "info", "warn", "error", "fatal", "":
319 default:
320 validationErrors = append(validationErrors, fmt.Sprintf(
321 "logging.level must be one of: trace, debug, info, warn, error, fatal (got: %s)",
322 config.Logging.Level,
323 ))
324 }
325 switch config.Logging.Format {
326 case "text", "json", "console", "":
327 default:
328 validationErrors = append(validationErrors, fmt.Sprintf(
329 "logging.format must be one of: text, json, console (got: %s)",
330 config.Logging.Format,
331 ))
332 }
333 return validationErrors
334}
335
336func validateCEF(config *Config) []string {
337 var validationErrors []string
338
339 switch config.Engine.CEF.CEFRenderStack() {
340 case CEFRenderStackVulkan, CEFRenderStackEGL:
341 default:
342 validationErrors = append(validationErrors, fmt.Sprintf(
343 "engine.cef.render_stack must be one of: vulkan, egl (got: %s)",
344 config.Engine.CEF.RenderStack,
345 ))
346 }
347
348 switch config.Engine.CEF.LogSeverity {
349 case 0, 1, 2, 3, 4, cefLogSeverityDisabled:
350 default:
351 validationErrors = append(validationErrors, fmt.Sprintf(
352 "engine.cef.log_severity must be one of: 0, 1, 2, 3, 4, %d (got: %d)",
353 cefLogSeverityDisabled,
354 config.Engine.CEF.LogSeverity,
355 ))
356 }
357
358 if config.Engine.CEF.WindowlessFrameRate < 0 {
359 validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0")
360 }
361 if config.Engine.CEF.WindowlessFrameRateMax < 0 {
362 validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate_max must be >= 0")
363 }
364
365 return validationErrors
366}
367
368func validateWorkspaceNewPaneURL(config *Config) []string {
369 var validationErrors []string
370
371 if config.Workspace.NewPaneURL == "" {
372 validationErrors = append(validationErrors, "workspace.new_pane_url cannot be empty")
373 return validationErrors
374 }
375
376 validationErrors = append(validationErrors, validateWorkspaceURLValue(
377 "workspace.new_pane_url",
378 config.Workspace.NewPaneURL,
379 )...)
380
381 return validationErrors
382}
383
384func validateWorkspaceURLValue(fieldPath, value string) []string {
385 trimmed := strings.TrimSpace(value)
386 candidate := trimmed
387 parsed, err := url.Parse(candidate)
388 if err != nil || parsed.Scheme == "" {
389 candidate = domainurl.Normalize(trimmed)
390 parsed, err = url.Parse(candidate)
391 }
392 if err != nil {
393 return []string{fmt.Sprintf("%s must be a valid URL (got: %s)", fieldPath, value)}
394 }
395
396 switch parsed.Scheme {
397 case "http", "https", "dumb", "file", "about":
398 return nil
399 default:
400 return []string{fmt.Sprintf(
401 "%s must use one of: http, https, dumb, file, about (got: %s)",
402 fieldPath,
403 parsed.Scheme,
404 )}
405 }
406}
407
408func validateOmnibox(config *Config) []string {
409 var validationErrors []string
410 if config.Omnibox.MostVisitedDays < 0 {
411 validationErrors = append(validationErrors, "omnibox.most_visited_days must be non-negative")
412 }
413 switch config.Omnibox.InitialBehavior {
414 case OmniboxInitialBehaviorRecent, OmniboxInitialBehaviorMostVisited, OmniboxInitialBehaviorNone:
415 default:
416 validationErrors = append(validationErrors, fmt.Sprintf(
417 "omnibox.initial_behavior must be one of: %s, %s, %s (got: %s)",
418 OmniboxInitialBehaviorRecent,
419 OmniboxInitialBehaviorMostVisited,
420 OmniboxInitialBehaviorNone,
421 config.Omnibox.InitialBehavior,
422 ))
423 }
424 return validationErrors
425}
426
427func validateRendering(config *Config) []string {
428 var validationErrors []string
429
430 switch config.Engine.WebKit.GSKRenderer {
431 case GSKRendererAuto, GSKRendererOpenGL, GSKRendererVulkan, GSKRendererCairo, "":
432 default:
433 validationErrors = append(
434 validationErrors,
435 fmt.Sprintf(
436 "engine.webkit.gsk_renderer must be one of: auto, opengl, vulkan, cairo (got: %s)",
437 config.Engine.WebKit.GSKRenderer,
438 ),
439 )
440 }
441
442 if config.Engine.WebKit.ForceCompositingMode && config.Engine.WebKit.DisableCompositingMode {
443 validationErrors = append(validationErrors,
444 "engine.webkit.force_compositing_mode and engine.webkit.disable_compositing_mode cannot both be true",
445 )
446 }
447
448 return validationErrors
449}
450
451func validatePrivacy(config *Config) []string {
452 switch config.Engine.CookiePolicy {
453 case CookiePolicyAlways, CookiePolicyNoThirdParty, CookiePolicyNever, "":
454 return nil
455 default:
456 return []string{fmt.Sprintf(
457 "engine.cookie_policy must be one of: always, no_third_party, never (got: %s)",
458 config.Engine.CookiePolicy,
459 )}
460 }
461}
462
463func validateColorScheme(config *Config) []string {
464 switch config.Appearance.ColorScheme {
465 case ThemePreferDark, ThemePreferLight, ThemeDefault, "":
466 return nil
467 default:
468 return []string{fmt.Sprintf(
469 "appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)",
470 config.Appearance.ColorScheme,
471 )}
472 }
473}
474
475func validateSession(config *Config) []string {
476 var validationErrors []string
477 if config.Session.MaxExitedSessions < 0 {
478 validationErrors = append(validationErrors, "session.max_exited_sessions must be non-negative")
479 }
480 if config.Session.MaxExitedSessionAgeDays < 0 {
481 validationErrors = append(validationErrors, "session.max_exited_session_age_days must be non-negative")
482 }
483 if config.Session.SnapshotIntervalMs < 0 {
484 validationErrors = append(validationErrors, "session.snapshot_interval_ms must be non-negative")
485 }
486 return validationErrors
487}
488
489func validatePerformanceProfile(config *Config) []string {
490 var validationErrors []string
491
492 // Performance profiles and webkit-specific tuning only apply to the webkit engine.
493 engineType := config.Engine.ResolveEngineType()
494 if engineType != "webkit" {
495 return nil
496 }
497
498 // Validate profile name
499 if !IsValidPerformanceProfile(config.Engine.Profile) {
500 validationErrors = append(validationErrors, fmt.Sprintf(
501 "engine.profile must be one of: default, lite, balanced, max, custom (got: %s)",
502 config.Engine.Profile,
503 ))
504 }
505
506 // When profile is not "custom", warn if individual fields are set
507 // (they will be ignored in favor of profile-computed values)
508 if config.Engine.Profile != ProfileCustom && config.Engine.Profile != "" {
509 perfCfg := PerformanceConfigFromEngine(&config.Engine)
510 if HasCustomPerformanceFields(&perfCfg) {
511 validationErrors = append(validationErrors,
512 "engine performance tuning fields (skia_*, *_memory_*) are ignored when profile is not 'custom'; "+
513 "set profile = \"custom\" to use individual field values",
514 )
515 }
516 }
517
518 // Validate memory pressure threshold ordering: WebKit requires conservative < strict
519 webCons := config.Engine.WebKit.WebProcessMemoryConservativeThreshold
520 webStrict := config.Engine.WebKit.WebProcessMemoryStrictThreshold
521 if webCons > 0 && webStrict > 0 && webCons >= webStrict {
522 validationErrors = append(validationErrors,
523 "engine.webkit.web_process_memory_conservative_threshold must be less than "+
524 "web_process_memory_strict_threshold when both are set",
525 )
526 }
527
528 netCons := config.Engine.WebKit.NetworkProcessMemoryConservativeThreshold
529 netStrict := config.Engine.WebKit.NetworkProcessMemoryStrictThreshold
530 if netCons > 0 && netStrict > 0 && netCons >= netStrict {
531 validationErrors = append(validationErrors,
532 "engine.webkit.network_process_memory_conservative_threshold must be less than "+
533 "network_process_memory_strict_threshold when both are set",
534 )
535 }
536
537 return validationErrors
538}