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