1package usecase
2
3import (
4 "context"
5 "fmt"
6 "math"
7 "strings"
8
9 "github.com/bnema/dumber/internal/application/dto"
10 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/domain/validation"
12)
13
14type SaveWebUIConfigUseCase struct {
15 saver port.WebUIConfigSaver
16}
17
18func NewSaveWebUIConfigUseCase(saver port.WebUIConfigSaver) *SaveWebUIConfigUseCase {
19 return &SaveWebUIConfigUseCase{saver: saver}
20}
21
22func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg dto.WebUIConfig) error {
23 if uc == nil || uc.saver == nil {
24 return fmt.Errorf("config saver is nil")
25 }
26
27 if err := validateSearchShortcutKeyCollisions(cfg.SearchShortcuts); err != nil {
28 return err
29 }
30
31 normalized := normalizeWebUIConfig(cfg)
32 if err := validateWebUIConfig(normalized); err != nil {
33 return err
34 }
35
36 return uc.saver.SaveWebUIConfig(ctx, normalized)
37}
38
39func validateSearchShortcutKeyCollisions(shortcuts map[string]dto.SearchShortcut) error {
40 seen := make(map[string]string, len(shortcuts))
41 for key := range shortcuts {
42 trimmedKey := strings.TrimSpace(key)
43 if trimmedKey == "" {
44 continue
45 }
46 if previous, ok := seen[trimmedKey]; ok && previous != key {
47 return fmt.Errorf("search shortcut key %q duplicates %q after trimming", key, previous)
48 }
49 seen[trimmedKey] = key
50 }
51 return nil
52}
53
54func normalizeWebUIConfig(cfg dto.WebUIConfig) dto.WebUIConfig {
55 cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont)
56 cfg.Appearance.SerifFont = strings.TrimSpace(cfg.Appearance.SerifFont)
57 cfg.Appearance.MonospaceFont = strings.TrimSpace(cfg.Appearance.MonospaceFont)
58 cfg.Appearance.GtkFont = strings.TrimSpace(cfg.Appearance.GtkFont)
59 cfg.Appearance.ColorScheme = strings.TrimSpace(cfg.Appearance.ColorScheme)
60 cfg.DefaultSearchEngine = strings.TrimSpace(cfg.DefaultSearchEngine)
61 if len(cfg.SearchShortcuts) > 0 {
62 normalized := make(map[string]dto.SearchShortcut, len(cfg.SearchShortcuts))
63 for key, shortcut := range cfg.SearchShortcuts {
64 trimmedKey := strings.TrimSpace(key)
65 if trimmedKey == "" {
66 continue
67 }
68 shortcut.URL = strings.TrimSpace(shortcut.URL)
69 shortcut.Description = strings.TrimSpace(shortcut.Description)
70 normalized[trimmedKey] = shortcut
71 }
72 cfg.SearchShortcuts = normalized
73 }
74 return cfg
75}
76
77func validateWebUIConfig(cfg dto.WebUIConfig) error {
78 var errs []string
79
80 if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 {
81 errs = append(errs, "appearance.default_font_size must be between 1 and 72")
82 }
83 if math.IsNaN(cfg.DefaultUIScale) || math.IsInf(cfg.DefaultUIScale, 0) || cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 {
84 errs = append(errs, "default_ui_scale must be a finite value between 0.5 and 3.0")
85 }
86
87 errs = append(errs, validation.ValidateFontFamily("appearance.sans_font", cfg.Appearance.SansFont)...)
88 errs = append(errs, validation.ValidateFontFamily("appearance.serif_font", cfg.Appearance.SerifFont)...)
89 errs = append(errs, validation.ValidateFontFamily("appearance.monospace_font", cfg.Appearance.MonospaceFont)...)
90 errs = append(errs, validation.ValidateFontFamily("appearance.gtk_font", cfg.Appearance.GtkFont)...)
91
92 for _, err := range validation.ValidateShortcutURL(cfg.DefaultSearchEngine) {
93 errs = append(errs, fmt.Sprintf("default_search_engine: %s", err))
94 }
95
96 for key, shortcut := range cfg.SearchShortcuts {
97 for _, err := range validation.ValidateShortcutKey(key) {
98 errs = append(errs, fmt.Sprintf("search_shortcuts.%s: %s", key, err))
99 }
100 for _, err := range validation.ValidateShortcutURL(shortcut.URL) {
101 errs = append(errs, fmt.Sprintf("search_shortcuts.%s.url: %s", key, err))
102 }
103 for _, err := range validation.ValidateShortcutDescription(shortcut.Description) {
104 errs = append(errs, fmt.Sprintf("search_shortcuts.%s.description: %s", key, err))
105 }
106 }
107
108 switch cfg.Performance.Profile {
109 case "", "default", "lite", "balanced", "max", "custom":
110 // ok
111 default:
112 errs = append(errs, fmt.Sprintf(
113 "performance.profile must be one of: default, lite, balanced, max, custom (got: %s)",
114 cfg.Performance.Profile,
115 ))
116 }
117
118 switch cfg.Appearance.ColorScheme {
119 case "default", "prefer-dark", "prefer-light":
120 // ok
121 default:
122 errs = append(errs, fmt.Sprintf(
123 "appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)",
124 cfg.Appearance.ColorScheme,
125 ))
126 }
127
128 errs = append(errs, validation.ValidatePaletteHex(
129 "appearance.light_palette",
130 cfg.Appearance.LightPalette.Background,
131 cfg.Appearance.LightPalette.Surface,
132 cfg.Appearance.LightPalette.SurfaceVariant,
133 cfg.Appearance.LightPalette.Text,
134 cfg.Appearance.LightPalette.Muted,
135 cfg.Appearance.LightPalette.Accent,
136 cfg.Appearance.LightPalette.Border,
137 )...)
138 errs = append(errs, validation.ValidatePaletteHex(
139 "appearance.dark_palette",
140 cfg.Appearance.DarkPalette.Background,
141 cfg.Appearance.DarkPalette.Surface,
142 cfg.Appearance.DarkPalette.SurfaceVariant,
143 cfg.Appearance.DarkPalette.Text,
144 cfg.Appearance.DarkPalette.Muted,
145 cfg.Appearance.DarkPalette.Accent,
146 cfg.Appearance.DarkPalette.Border,
147 )...)
148
149 if len(errs) > 0 {
150 return fmt.Errorf("config validation failed:\n - %s", strings.Join(errs, "\n - "))
151 }
152 return nil
153}