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