1package usecase
2
3import (
4 "context"
5 "sync"
6 "time"
7
8 "github.com/bnema/dumber/internal/domain/autocomplete"
9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/logging"
11)
12
13// AutocompleteUseCase handles inline autocomplete suggestions.
14type AutocompleteUseCase struct {
15 historyUC *SearchHistoryUseCase
16 favoritesUC *ManageFavoritesUseCase
17 shortcutsUC *SearchShortcutsUseCase
18
19 cacheMu sync.Mutex
20 favoritesCache []*entity.Favorite
21 favoritesCached time.Time
22 historyCache []*entity.HistoryEntry
23 historyCached time.Time
24}
25
26const autocompleteHistoryLimit = 200
27const autocompleteCacheTTL = 2 * time.Second
28
29// NewAutocompleteUseCase creates a new autocomplete use case.
30func NewAutocompleteUseCase(
31 historyUC *SearchHistoryUseCase,
32 favoritesUC *ManageFavoritesUseCase,
33 shortcutsUC *SearchShortcutsUseCase,
34) *AutocompleteUseCase {
35 return &AutocompleteUseCase{
36 historyUC: historyUC,
37 favoritesUC: favoritesUC,
38 shortcutsUC: shortcutsUC,
39 }
40}
41
42// GetSuggestionInput contains parameters for getting an autocomplete suggestion.
43type GetSuggestionInput struct {
44 Input string // Current user input
45}
46
47// GetSuggestionOutput contains the best autocomplete suggestion.
48type GetSuggestionOutput struct {
49 Suggestion *autocomplete.Suggestion
50 Found bool
51}
52
53// CompletionOptions controls how URL completions are resolved.
54type CompletionOptions struct {
55 VisibleURLs []string
56 AllowBangs bool
57}
58
59// GetSuggestion returns the best autocomplete suggestion for the given input.
60// Priority: Favorites > History (by frecency) > Bang shortcuts.
61func (uc *AutocompleteUseCase) GetSuggestion(ctx context.Context, input GetSuggestionInput) *GetSuggestionOutput {
62 log := logging.FromContext(ctx)
63
64 if input.Input == "" {
65 return &GetSuggestionOutput{Found: false}
66 }
67
68 // Try favorites first (highest priority)
69 if suggestion := uc.findFavoriteSuggestion(ctx, input.Input); suggestion != nil {
70 log.Debug().
71 Str("input", input.Input).
72 Str("match", suggestion.FullText).
73 Msg("autocomplete: matched favorite")
74 return &GetSuggestionOutput{Suggestion: suggestion, Found: true}
75 }
76
77 // Try history (second priority)
78 if suggestion := uc.findHistorySuggestion(ctx, input.Input); suggestion != nil {
79 log.Debug().
80 Str("input", input.Input).
81 Str("match", suggestion.FullText).
82 Msg("autocomplete: matched history")
83 return &GetSuggestionOutput{Suggestion: suggestion, Found: true}
84 }
85
86 // Try bang shortcuts (lowest priority for URL-style completion)
87 if suggestion := uc.findBangSuggestion(ctx, input.Input); suggestion != nil {
88 log.Debug().
89 Str("input", input.Input).
90 Str("match", suggestion.FullText).
91 Msg("autocomplete: matched bang shortcut")
92 return &GetSuggestionOutput{Suggestion: suggestion, Found: true}
93 }
94
95 return &GetSuggestionOutput{Found: false}
96}
97
98// ResolveCompletion resolves the best completion using visible list order first,
99// then cached favorites/history as fallback.
100func (uc *AutocompleteUseCase) ResolveCompletion(ctx context.Context, input string, opts CompletionOptions) *autocomplete.Suggestion {
101 log := logging.FromContext(ctx)
102
103 if input == "" {
104 return nil
105 }
106
107 if len(opts.VisibleURLs) > 0 {
108 if suggestion := findCompletionInURLs(ctx, input, opts.VisibleURLs, autocomplete.SourceHistory); suggestion != nil {
109 log.Debug().
110 Str("input", input).
111 Str("match", suggestion.FullText).
112 Msg("autocomplete: matched visible list")
113 return suggestion
114 }
115 }
116
117 if suggestion := uc.findFavoriteSuggestion(ctx, input); suggestion != nil {
118 log.Debug().
119 Str("input", input).
120 Str("match", suggestion.FullText).
121 Msg("autocomplete: matched favorite fallback")
122 return suggestion
123 }
124
125 if suggestion := uc.findHistorySuggestion(ctx, input); suggestion != nil {
126 log.Debug().
127 Str("input", input).
128 Str("match", suggestion.FullText).
129 Msg("autocomplete: matched history fallback")
130 return suggestion
131 }
132
133 if opts.AllowBangs {
134 if suggestion := uc.findBangSuggestion(ctx, input); suggestion != nil {
135 log.Debug().
136 Str("input", input).
137 Str("match", suggestion.FullText).
138 Msg("autocomplete: matched bang fallback")
139 return suggestion
140 }
141 }
142
143 return nil
144}
145
146// findFavoriteSuggestion finds the best matching favorite for the input.
147func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
148 log := logging.FromContext(ctx)
149
150 if uc.favoritesUC == nil {
151 return nil
152 }
153
154 favorites, err := uc.getCachedFavorites(ctx)
155 if err != nil {
156 log.Debug().Err(err).Msg("autocomplete: favorites cache fetch failed")
157 return nil
158 }
159
160 return bestSuggestionFromURLs(input, autocomplete.SourceFavorite,
161 len(favorites),
162 func(i int) string {
163 if favorites[i] == nil {
164 return ""
165 }
166 return favorites[i].URL
167 },
168 func(i int) string {
169 if favorites[i] == nil {
170 return ""
171 }
172 return favorites[i].Title
173 },
174 )
175}
176
177// findHistorySuggestion finds the best matching history entry for the input.
178func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
179 log := logging.FromContext(ctx)
180
181 if uc.historyUC == nil {
182 return nil
183 }
184
185 entries, err := uc.getCachedHistory(ctx)
186 if err != nil {
187 log.Debug().Err(err).Msg("autocomplete: history cache fetch failed")
188 return nil
189 }
190
191 return bestSuggestionFromURLs(input, autocomplete.SourceHistory,
192 len(entries),
193 func(i int) string {
194 if entries[i] == nil {
195 return ""
196 }
197 return entries[i].URL
198 },
199 func(i int) string {
200 if entries[i] == nil {
201 return ""
202 }
203 return entries[i].Title
204 },
205 )
206}
207
208// findBangSuggestion finds the best matching bang shortcut for the input.
209func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
210 _ = logging.FromContext(ctx)
211
212 if uc.shortcutsUC == nil || len(input) < 1 || input[0] != '!' {
213 return nil
214 }
215
216 // Get filtered bang suggestions
217 output := uc.shortcutsUC.FilterBangs(ctx, FilterBangsInput{Query: input})
218 if len(output.Suggestions) == 0 {
219 return nil
220 }
221
222 // Use the first (best) match
223 bang := output.Suggestions[0]
224 fullBang := "!" + bang.Key
225
226 suffix, ok := autocomplete.ComputeCompletionSuffix(input, fullBang)
227 if ok {
228 return &autocomplete.Suggestion{
229 FullText: fullBang,
230 Suffix: suffix,
231 Source: autocomplete.SourceBangShortcut,
232 Title: bang.Description,
233 }
234 }
235
236 return nil
237}
238
239func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion {
240 suffix, matchedURL, ok := autocomplete.BestURLCompletion(input, urls)
241 if ok {
242 return &autocomplete.Suggestion{
243 FullText: matchedURL,
244 Suffix: suffix,
245 Source: source,
246 }
247 }
248 return nil
249}
250
251func bestSuggestionFromURLs(
252 input string,
253 source autocomplete.SuggestionSource,
254 count int,
255 urlAt func(int) string,
256 titleAt func(int) string,
257) *autocomplete.Suggestion {
258 if count <= 0 {
259 return nil
260 }
261
262 urls := make([]string, 0, count)
263 titles := make([]string, 0, count)
264 for i := 0; i < count; i++ {
265 url := urlAt(i)
266 if url == "" {
267 continue
268 }
269 urls = append(urls, url)
270 titles = append(titles, titleAt(i))
271 }
272 if len(urls) == 0 {
273 return nil
274 }
275
276 suffix, matchedURL, idx, ok := autocomplete.BestURLCompletionWithIndex(input, urls)
277 if !ok {
278 return nil
279 }
280 title := ""
281 if idx >= 0 && idx < len(titles) {
282 title = titles[idx]
283 }
284 return &autocomplete.Suggestion{
285 FullText: matchedURL,
286 Suffix: suffix,
287 Source: source,
288 Title: title,
289 }
290}
291
292func (uc *AutocompleteUseCase) getCachedFavorites(ctx context.Context) ([]*entity.Favorite, error) {
293 log := logging.FromContext(ctx)
294
295 uc.cacheMu.Lock()
296 if time.Since(uc.favoritesCached) < autocompleteCacheTTL && uc.favoritesCache != nil {
297 favorites := uc.favoritesCache
298 uc.cacheMu.Unlock()
299 return favorites, nil
300 }
301 uc.cacheMu.Unlock()
302
303 favorites, err := uc.favoritesUC.GetAll(ctx)
304 if err != nil {
305 return nil, err
306 }
307
308 uc.cacheMu.Lock()
309 uc.favoritesCache = favorites
310 uc.favoritesCached = time.Now()
311 uc.cacheMu.Unlock()
312
313 log.Debug().Int("count", len(favorites)).Msg("autocomplete: favorites cache refreshed")
314 return favorites, nil
315}
316
317func (uc *AutocompleteUseCase) getCachedHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
318 log := logging.FromContext(ctx)
319
320 uc.cacheMu.Lock()
321 if time.Since(uc.historyCached) < autocompleteCacheTTL && uc.historyCache != nil {
322 history := uc.historyCache
323 uc.cacheMu.Unlock()
324 return history, nil
325 }
326 uc.cacheMu.Unlock()
327
328 entries, err := uc.historyUC.GetRecent(ctx, autocompleteHistoryLimit, 0)
329 if err != nil {
330 return nil, err
331 }
332
333 uc.cacheMu.Lock()
334 uc.historyCache = entries
335 uc.historyCached = time.Now()
336 uc.cacheMu.Unlock()
337
338 log.Debug().Int("count", len(entries)).Msg("autocomplete: history cache refreshed")
339 return entries, nil
340}
341
342// GetSuggestionForURL returns an autocomplete suggestion specifically for a URL.
343// This is used when the user selects a row from the list to update ghost text.
344func (*AutocompleteUseCase) GetSuggestionForURL(_ context.Context, input, targetURL string) *GetSuggestionOutput {
345 if input == "" || targetURL == "" {
346 return &GetSuggestionOutput{Found: false}
347 }
348
349 suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, targetURL)
350 if !ok {
351 return &GetSuggestionOutput{Found: false}
352 }
353
354 return &GetSuggestionOutput{
355 Suggestion: &autocomplete.Suggestion{
356 FullText: matchedURL,
357 Suffix: suffix,
358 Source: autocomplete.SourceHistory, // Default to history for selected rows
359 },
360 Found: true,
361 }
362}