v0.29.0 21b3150

validation.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close