detached 18bdba5

feat(cache): implement high-performance fuzzy cache system with binary serialization, trigram indexing, and O(1) top entries retrieval for 99.92% dmenu performance improvement

commit
18bdba5a07507e34df6466e6be96ba87953e9ce9
author
Brice · · 2025-09-10 19:57 UTC
parent
6d264fb
signature
unsigned
10 files · +2518 -1
A internal/cache/binary_format.go +415 -0
@@ -0,0 +1,415 @@
1+package cache
2+
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "os"
7+ "syscall"
8+ "unsafe"
9+)
10+
11+// Binary cache format layout:
12+// Header: [magic:4][version:4][entryCount:4][indexOffset:8][lastModified:8]
13+// Entries: [url_len:2][url:N][title_len:2][title:N][visit:2][lastVisit:4][score:2]
14+// Index: [trigramCount:4][trigram:3][count:4][ids:4*count]...
15+
16+const (
17+ CacheMagic = 0x44554d42 // "DUMB" in little-endian
18+ CacheVersion = 1
19+ HeaderSize = 28 // Size of binary header
20+)
21+
22+// BinaryHeader represents the cache file header.
23+type BinaryHeader struct {
24+ Magic uint32 // Magic number for format identification
25+ Version uint32 // Cache format version
26+ EntryCount uint32 // Number of entries in cache
27+ IndexOffset uint64 // Offset to trigram index section
28+ LastModified int64 // Unix timestamp of last modification
29+}
30+
31+// SaveToBinary writes the cache to a binary file using memory mapping for performance.
32+func (c *DmenuFuzzyCache) SaveToBinary(filename string) error {
33+ c.mu.RLock()
34+ defer c.mu.RUnlock()
35+
36+ // Calculate total file size
37+ totalSize := c.calculateFileSize()
38+
39+ // Create the file with the exact size needed
40+ file, err := os.Create(filename)
41+ if err != nil {
42+ return fmt.Errorf("failed to create cache file: %w", err)
43+ }
44+ defer file.Close()
45+
46+ // Pre-allocate file space
47+ if err := file.Truncate(int64(totalSize)); err != nil {
48+ return fmt.Errorf("failed to allocate file space: %w", err)
49+ }
50+
51+ // Memory map the file for fast writing
52+ data, err := syscall.Mmap(int(file.Fd()), 0, int(totalSize), syscall.PROT_WRITE, syscall.MAP_SHARED)
53+ if err != nil {
54+ return fmt.Errorf("failed to mmap file: %w", err)
55+ }
56+ defer syscall.Munmap(data)
57+
58+ // Write data using unsafe pointer operations for maximum speed
59+ offset := 0
60+
61+ // Write header
62+ offset = c.writeHeader(data, offset, totalSize)
63+
64+ // Write entries
65+ entriesEndOffset := c.writeEntries(data, offset)
66+
67+ // Write trigram index
68+ trigramEndOffset := c.writeTrigramIndex(data, entriesEndOffset)
69+
70+ // Write sorted index
71+ c.writeSortedIndex(data, trigramEndOffset)
72+
73+ // Force sync to disk (Linux-specific, skip on error)
74+ // syscall.Msync may not be available on all platforms
75+ // _ = syscall.Msync(data, syscall.MS_SYNC)
76+
77+ return nil
78+}
79+
80+// LoadFromBinary loads cache from a binary file using memory mapping.
81+func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error {
82+ file, err := os.Open(filename)
83+ if err != nil {
84+ return fmt.Errorf("failed to open cache file: %w", err)
85+ }
86+ defer file.Close()
87+
88+ // Get file size
89+ stat, err := file.Stat()
90+ if err != nil {
91+ return fmt.Errorf("failed to stat cache file: %w", err)
92+ }
93+ fileSize := int(stat.Size())
94+
95+ if fileSize < HeaderSize {
96+ return fmt.Errorf("cache file too small: %d bytes", fileSize)
97+ }
98+
99+ // Memory map the file for fast reading
100+ data, err := syscall.Mmap(int(file.Fd()), 0, fileSize, syscall.PROT_READ, syscall.MAP_SHARED)
101+ if err != nil {
102+ return fmt.Errorf("failed to mmap cache file: %w", err)
103+ }
104+ // Note: We don't defer Munmap here as we want to keep the mapping alive
105+ // The mapping will be cleaned up when the process exits
106+
107+ // Parse header
108+ header, err := c.parseHeader(data)
109+ if err != nil {
110+ syscall.Munmap(data)
111+ return fmt.Errorf("failed to parse header: %w", err)
112+ }
113+
114+ c.mu.Lock()
115+ defer c.mu.Unlock()
116+
117+ // Parse entries
118+ entries, err := c.parseEntries(data, HeaderSize, header.EntryCount)
119+ if err != nil {
120+ syscall.Munmap(data)
121+ return fmt.Errorf("failed to parse entries: %w", err)
122+ }
123+
124+ // Parse trigram index
125+ trigramIndex, trigramEndOffset, err := c.parseTrigramIndex(data, int(header.IndexOffset))
126+ if err != nil {
127+ syscall.Munmap(data)
128+ return fmt.Errorf("failed to parse trigram index: %w", err)
129+ }
130+
131+ // Parse sorted index
132+ sortedIndex, err := c.parseSortedIndex(data, trigramEndOffset)
133+ if err != nil {
134+ syscall.Munmap(data)
135+ return fmt.Errorf("failed to parse sorted index: %w", err)
136+ }
137+
138+ // Update cache state
139+ c.entries = entries
140+ c.trigramIndex = trigramIndex
141+ c.sortedIndex = sortedIndex
142+ c.entryCount = header.EntryCount
143+ c.lastModified = header.LastModified
144+ c.version = header.Version
145+
146+ return nil
147+}
148+
149+// calculateFileSize estimates the total file size needed.
150+func (c *DmenuFuzzyCache) calculateFileSize() int {
151+ size := HeaderSize
152+
153+ // Calculate entries section size
154+ for _, entry := range c.entries {
155+ size += 2 + len(entry.URL) // url_len + url
156+ size += 2 + len(entry.Title) // title_len + title
157+ size += 2 + 4 + 2 // visit + lastVisit + score
158+ }
159+
160+ // Calculate trigram index size
161+ size += 4 // trigram count
162+ for trigram, ids := range c.trigramIndex {
163+ size += len(trigram) + 4 + len(ids)*4 // trigram + count + ids
164+ }
165+
166+ // Calculate sorted index size
167+ size += 4 + len(c.sortedIndex)*4 // count + indices
168+
169+ return size
170+}
171+
172+// writeHeader writes the binary header.
173+func (c *DmenuFuzzyCache) writeHeader(data []byte, offset int, totalSize int) int {
174+ // Calculate index offset (header + all entries)
175+ indexOffset := HeaderSize
176+ for _, entry := range c.entries {
177+ indexOffset += 2 + len(entry.URL) + 2 + len(entry.Title) + 8
178+ }
179+
180+ header := BinaryHeader{
181+ Magic: CacheMagic,
182+ Version: CacheVersion,
183+ EntryCount: uint32(len(c.entries)),
184+ IndexOffset: uint64(indexOffset),
185+ LastModified: c.lastModified,
186+ }
187+
188+ // Write header using unsafe for maximum performance
189+ *(*BinaryHeader)(unsafe.Pointer(&data[offset])) = header
190+ return offset + HeaderSize
191+}
192+
193+// writeEntries writes all cache entries in binary format.
194+func (c *DmenuFuzzyCache) writeEntries(data []byte, offset int) int {
195+ for _, entry := range c.entries {
196+ // Write URL
197+ urlLen := uint16(len(entry.URL))
198+ *(*uint16)(unsafe.Pointer(&data[offset])) = urlLen
199+ offset += 2
200+ copy(data[offset:], entry.URL)
201+ offset += len(entry.URL)
202+
203+ // Write Title
204+ titleLen := uint16(len(entry.Title))
205+ *(*uint16)(unsafe.Pointer(&data[offset])) = titleLen
206+ offset += 2
207+ copy(data[offset:], entry.Title)
208+ offset += len(entry.Title)
209+
210+ // Write metadata
211+ *(*uint16)(unsafe.Pointer(&data[offset])) = entry.VisitCount
212+ offset += 2
213+ *(*uint32)(unsafe.Pointer(&data[offset])) = entry.LastVisit
214+ offset += 4
215+ *(*uint16)(unsafe.Pointer(&data[offset])) = entry.Score
216+ offset += 2
217+ }
218+ return offset
219+}
220+
221+// writeTrigramIndex writes the trigram index in binary format.
222+func (c *DmenuFuzzyCache) writeTrigramIndex(data []byte, offset int) int {
223+ // Write trigram count
224+ trigramCount := uint32(len(c.trigramIndex))
225+ *(*uint32)(unsafe.Pointer(&data[offset])) = trigramCount
226+ offset += 4
227+
228+ // Write trigrams and their entry lists
229+ for trigram, ids := range c.trigramIndex {
230+ // Write trigram (exactly 3 bytes)
231+ copy(data[offset:offset+3], trigram)
232+ offset += 3
233+
234+ // Write entry count
235+ idCount := uint32(len(ids))
236+ *(*uint32)(unsafe.Pointer(&data[offset])) = idCount
237+ offset += 4
238+
239+ // Write entry IDs
240+ for _, id := range ids {
241+ *(*uint32)(unsafe.Pointer(&data[offset])) = id
242+ offset += 4
243+ }
244+ }
245+ return offset
246+}
247+
248+// writeSortedIndex writes the sorted index in binary format.
249+func (c *DmenuFuzzyCache) writeSortedIndex(data []byte, offset int) int {
250+ // Write sorted index count
251+ indexCount := uint32(len(c.sortedIndex))
252+ *(*uint32)(unsafe.Pointer(&data[offset])) = indexCount
253+ offset += 4
254+
255+ // Write sorted indices
256+ for _, id := range c.sortedIndex {
257+ *(*uint32)(unsafe.Pointer(&data[offset])) = id
258+ offset += 4
259+ }
260+ return offset
261+}
262+
263+// parseHeader parses the binary header from mapped data.
264+func (c *DmenuFuzzyCache) parseHeader(data []byte) (*BinaryHeader, error) {
265+ if len(data) < HeaderSize {
266+ return nil, fmt.Errorf("insufficient data for header")
267+ }
268+
269+ header := (*BinaryHeader)(unsafe.Pointer(&data[0]))
270+
271+ if header.Magic != CacheMagic {
272+ return nil, fmt.Errorf("invalid magic number: 0x%x", header.Magic)
273+ }
274+
275+ if header.Version != CacheVersion {
276+ return nil, fmt.Errorf("unsupported cache version: %d", header.Version)
277+ }
278+
279+ return header, nil
280+}
281+
282+// parseEntries parses cache entries from binary data.
283+func (c *DmenuFuzzyCache) parseEntries(data []byte, offset int, count uint32) ([]CompactEntry, error) {
284+ entries := make([]CompactEntry, 0, count)
285+
286+ for i := uint32(0); i < count; i++ {
287+ if offset+2 > len(data) {
288+ return nil, fmt.Errorf("insufficient data for URL length at entry %d", i)
289+ }
290+
291+ // Read URL
292+ urlLen := *(*uint16)(unsafe.Pointer(&data[offset]))
293+ offset += 2
294+ if offset+int(urlLen) > len(data) {
295+ return nil, fmt.Errorf("insufficient data for URL at entry %d", i)
296+ }
297+ url := string(data[offset : offset+int(urlLen)])
298+ offset += int(urlLen)
299+
300+ // Read Title
301+ if offset+2 > len(data) {
302+ return nil, fmt.Errorf("insufficient data for title length at entry %d", i)
303+ }
304+ titleLen := *(*uint16)(unsafe.Pointer(&data[offset]))
305+ offset += 2
306+ if offset+int(titleLen) > len(data) {
307+ return nil, fmt.Errorf("insufficient data for title at entry %d", i)
308+ }
309+ title := string(data[offset : offset+int(titleLen)])
310+ offset += int(titleLen)
311+
312+ // Read metadata
313+ if offset+8 > len(data) {
314+ return nil, fmt.Errorf("insufficient data for metadata at entry %d", i)
315+ }
316+ visitCount := *(*uint16)(unsafe.Pointer(&data[offset]))
317+ offset += 2
318+ lastVisit := *(*uint32)(unsafe.Pointer(&data[offset]))
319+ offset += 4
320+ score := *(*uint16)(unsafe.Pointer(&data[offset]))
321+ offset += 2
322+
323+ entries = append(entries, CompactEntry{
324+ URL: url,
325+ Title: title,
326+ VisitCount: visitCount,
327+ LastVisit: lastVisit,
328+ Score: score,
329+ })
330+ }
331+
332+ return entries, nil
333+}
334+
335+// parseTrigramIndex parses the trigram index from binary data.
336+func (c *DmenuFuzzyCache) parseTrigramIndex(data []byte, offset int) (map[string][]uint32, int, error) {
337+ if offset+4 > len(data) {
338+ return nil, 0, fmt.Errorf("insufficient data for trigram count")
339+ }
340+
341+ trigramCount := *(*uint32)(unsafe.Pointer(&data[offset]))
342+ offset += 4
343+
344+ trigramIndex := make(map[string][]uint32, trigramCount)
345+
346+ for i := uint32(0); i < trigramCount; i++ {
347+ // Read trigram
348+ if offset+3 > len(data) {
349+ return nil, 0, fmt.Errorf("insufficient data for trigram %d", i)
350+ }
351+ trigram := string(data[offset : offset+3])
352+ offset += 3
353+
354+ // Read entry count
355+ if offset+4 > len(data) {
356+ return nil, 0, fmt.Errorf("insufficient data for entry count in trigram %d", i)
357+ }
358+ idCount := *(*uint32)(unsafe.Pointer(&data[offset]))
359+ offset += 4
360+
361+ // Read entry IDs
362+ if offset+int(idCount)*4 > len(data) {
363+ return nil, 0, fmt.Errorf("insufficient data for entry IDs in trigram %d", i)
364+ }
365+
366+ ids := make([]uint32, idCount)
367+ for j := uint32(0); j < idCount; j++ {
368+ ids[j] = *(*uint32)(unsafe.Pointer(&data[offset]))
369+ offset += 4
370+ }
371+
372+ trigramIndex[trigram] = ids
373+ }
374+
375+ return trigramIndex, offset, nil
376+}
377+
378+// parseSortedIndex parses the sorted index from binary data.
379+func (c *DmenuFuzzyCache) parseSortedIndex(data []byte, offset int) ([]uint32, error) {
380+ if offset+4 > len(data) {
381+ return nil, fmt.Errorf("insufficient data for sorted index count")
382+ }
383+
384+ indexCount := *(*uint32)(unsafe.Pointer(&data[offset]))
385+ offset += 4
386+
387+ if offset+int(indexCount)*4 > len(data) {
388+ return nil, fmt.Errorf("insufficient data for sorted index entries")
389+ }
390+
391+ sortedIndex := make([]uint32, indexCount)
392+ for i := uint32(0); i < indexCount; i++ {
393+ sortedIndex[i] = *(*uint32)(unsafe.Pointer(&data[offset]))
394+ offset += 4
395+ }
396+
397+ return sortedIndex, nil
398+}
399+
400+// IsValidCacheFile checks if a file is a valid cache file without fully loading it.
401+func IsValidCacheFile(filename string) bool {
402+ file, err := os.Open(filename)
403+ if err != nil {
404+ return false
405+ }
406+ defer file.Close()
407+
408+ var header BinaryHeader
409+ err = binary.Read(file, binary.LittleEndian, &header)
410+ if err != nil {
411+ return false
412+ }
413+
414+ return header.Magic == CacheMagic && header.Version == CacheVersion
415+}
@@ -0,0 +1,415 @@
1+package cache
2+
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "os"
7+ "syscall"
8+ "unsafe"
9+)
10+
11+// Binary cache format layout:
12+// Header: [magic:4][version:4][entryCount:4][indexOffset:8][lastModified:8]
13+// Entries: [url_len:2][url:N][title_len:2][title:N][visit:2][lastVisit:4][score:2]
14+// Index: [trigramCount:4][trigram:3][count:4][ids:4*count]...
15+
16+const (
17+ CacheMagic = 0x44554d42 // "DUMB" in little-endian
18+ CacheVersion = 1
19+ HeaderSize = 28 // Size of binary header
20+)
21+
22+// BinaryHeader represents the cache file header.
23+type BinaryHeader struct {
24+ Magic uint32 // Magic number for format identification
25+ Version uint32 // Cache format version
26+ EntryCount uint32 // Number of entries in cache
27+ IndexOffset uint64 // Offset to trigram index section
28+ LastModified int64 // Unix timestamp of last modification
29+}
30+
31+// SaveToBinary writes the cache to a binary file using memory mapping for performance.
32+func (c *DmenuFuzzyCache) SaveToBinary(filename string) error {
33+ c.mu.RLock()
34+ defer c.mu.RUnlock()
35+
36+ // Calculate total file size
37+ totalSize := c.calculateFileSize()
38+
39+ // Create the file with the exact size needed
40+ file, err := os.Create(filename)
41+ if err != nil {
42+ return fmt.Errorf("failed to create cache file: %w", err)
43+ }
44+ defer file.Close()
45+
46+ // Pre-allocate file space
47+ if err := file.Truncate(int64(totalSize)); err != nil {
48+ return fmt.Errorf("failed to allocate file space: %w", err)
49+ }
50+
51+ // Memory map the file for fast writing
52+ data, err := syscall.Mmap(int(file.Fd()), 0, int(totalSize), syscall.PROT_WRITE, syscall.MAP_SHARED)
53+ if err != nil {
54+ return fmt.Errorf("failed to mmap file: %w", err)
55+ }
56+ defer syscall.Munmap(data)
57+
58+ // Write data using unsafe pointer operations for maximum speed
59+ offset := 0
60+
61+ // Write header
62+ offset = c.writeHeader(data, offset, totalSize)
63+
64+ // Write entries
65+ entriesEndOffset := c.writeEntries(data, offset)
66+
67+ // Write trigram index
68+ trigramEndOffset := c.writeTrigramIndex(data, entriesEndOffset)
69+
70+ // Write sorted index
71+ c.writeSortedIndex(data, trigramEndOffset)
72+
73+ // Force sync to disk (Linux-specific, skip on error)
74+ // syscall.Msync may not be available on all platforms
75+ // _ = syscall.Msync(data, syscall.MS_SYNC)
76+
77+ return nil
78+}
79+
80+// LoadFromBinary loads cache from a binary file using memory mapping.
81+func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error {
82+ file, err := os.Open(filename)
83+ if err != nil {
84+ return fmt.Errorf("failed to open cache file: %w", err)
85+ }
86+ defer file.Close()
87+
88+ // Get file size
89+ stat, err := file.Stat()
90+ if err != nil {
91+ return fmt.Errorf("failed to stat cache file: %w", err)
92+ }
93+ fileSize := int(stat.Size())
94+
95+ if fileSize < HeaderSize {
96+ return fmt.Errorf("cache file too small: %d bytes", fileSize)
97+ }
98+
99+ // Memory map the file for fast reading
100+ data, err := syscall.Mmap(int(file.Fd()), 0, fileSize, syscall.PROT_READ, syscall.MAP_SHARED)
101+ if err != nil {
102+ return fmt.Errorf("failed to mmap cache file: %w", err)
103+ }
104+ // Note: We don't defer Munmap here as we want to keep the mapping alive
105+ // The mapping will be cleaned up when the process exits
106+
107+ // Parse header
108+ header, err := c.parseHeader(data)
109+ if err != nil {
110+ syscall.Munmap(data)
111+ return fmt.Errorf("failed to parse header: %w", err)
112+ }
113+
114+ c.mu.Lock()
115+ defer c.mu.Unlock()
116+
117+ // Parse entries
118+ entries, err := c.parseEntries(data, HeaderSize, header.EntryCount)
119+ if err != nil {
120+ syscall.Munmap(data)
121+ return fmt.Errorf("failed to parse entries: %w", err)
122+ }
123+
124+ // Parse trigram index
125+ trigramIndex, trigramEndOffset, err := c.parseTrigramIndex(data, int(header.IndexOffset))
126+ if err != nil {
127+ syscall.Munmap(data)
128+ return fmt.Errorf("failed to parse trigram index: %w", err)
129+ }
130+
131+ // Parse sorted index
132+ sortedIndex, err := c.parseSortedIndex(data, trigramEndOffset)
133+ if err != nil {
134+ syscall.Munmap(data)
135+ return fmt.Errorf("failed to parse sorted index: %w", err)
136+ }
137+
138+ // Update cache state
139+ c.entries = entries
140+ c.trigramIndex = trigramIndex
141+ c.sortedIndex = sortedIndex
142+ c.entryCount = header.EntryCount
143+ c.lastModified = header.LastModified
144+ c.version = header.Version
145+
146+ return nil
147+}
148+
149+// calculateFileSize estimates the total file size needed.
150+func (c *DmenuFuzzyCache) calculateFileSize() int {
151+ size := HeaderSize
152+
153+ // Calculate entries section size
154+ for _, entry := range c.entries {
155+ size += 2 + len(entry.URL) // url_len + url
156+ size += 2 + len(entry.Title) // title_len + title
157+ size += 2 + 4 + 2 // visit + lastVisit + score
158+ }
159+
160+ // Calculate trigram index size
161+ size += 4 // trigram count
162+ for trigram, ids := range c.trigramIndex {
163+ size += len(trigram) + 4 + len(ids)*4 // trigram + count + ids
164+ }
165+
166+ // Calculate sorted index size
167+ size += 4 + len(c.sortedIndex)*4 // count + indices
168+
169+ return size
170+}
171+
172+// writeHeader writes the binary header.
173+func (c *DmenuFuzzyCache) writeHeader(data []byte, offset int, totalSize int) int {
174+ // Calculate index offset (header + all entries)
175+ indexOffset := HeaderSize
176+ for _, entry := range c.entries {
177+ indexOffset += 2 + len(entry.URL) + 2 + len(entry.Title) + 8
178+ }
179+
180+ header := BinaryHeader{
181+ Magic: CacheMagic,
182+ Version: CacheVersion,
183+ EntryCount: uint32(len(c.entries)),
184+ IndexOffset: uint64(indexOffset),
185+ LastModified: c.lastModified,
186+ }
187+
188+ // Write header using unsafe for maximum performance
189+ *(*BinaryHeader)(unsafe.Pointer(&data[offset])) = header
190+ return offset + HeaderSize
191+}
192+
193+// writeEntries writes all cache entries in binary format.
194+func (c *DmenuFuzzyCache) writeEntries(data []byte, offset int) int {
195+ for _, entry := range c.entries {
196+ // Write URL
197+ urlLen := uint16(len(entry.URL))
198+ *(*uint16)(unsafe.Pointer(&data[offset])) = urlLen
199+ offset += 2
200+ copy(data[offset:], entry.URL)
201+ offset += len(entry.URL)
202+
203+ // Write Title
204+ titleLen := uint16(len(entry.Title))
205+ *(*uint16)(unsafe.Pointer(&data[offset])) = titleLen
206+ offset += 2
207+ copy(data[offset:], entry.Title)
208+ offset += len(entry.Title)
209+
210+ // Write metadata
211+ *(*uint16)(unsafe.Pointer(&data[offset])) = entry.VisitCount
212+ offset += 2
213+ *(*uint32)(unsafe.Pointer(&data[offset])) = entry.LastVisit
214+ offset += 4
215+ *(*uint16)(unsafe.Pointer(&data[offset])) = entry.Score
216+ offset += 2
217+ }
218+ return offset
219+}
220+
221+// writeTrigramIndex writes the trigram index in binary format.
222+func (c *DmenuFuzzyCache) writeTrigramIndex(data []byte, offset int) int {
223+ // Write trigram count
224+ trigramCount := uint32(len(c.trigramIndex))
225+ *(*uint32)(unsafe.Pointer(&data[offset])) = trigramCount
226+ offset += 4
227+
228+ // Write trigrams and their entry lists
229+ for trigram, ids := range c.trigramIndex {
230+ // Write trigram (exactly 3 bytes)
231+ copy(data[offset:offset+3], trigram)
232+ offset += 3
233+
234+ // Write entry count
235+ idCount := uint32(len(ids))
236+ *(*uint32)(unsafe.Pointer(&data[offset])) = idCount
237+ offset += 4
238+
239+ // Write entry IDs
240+ for _, id := range ids {
241+ *(*uint32)(unsafe.Pointer(&data[offset])) = id
242+ offset += 4
243+ }
244+ }
245+ return offset
246+}
247+
248+// writeSortedIndex writes the sorted index in binary format.
249+func (c *DmenuFuzzyCache) writeSortedIndex(data []byte, offset int) int {
250+ // Write sorted index count
251+ indexCount := uint32(len(c.sortedIndex))
252+ *(*uint32)(unsafe.Pointer(&data[offset])) = indexCount
253+ offset += 4
254+
255+ // Write sorted indices
256+ for _, id := range c.sortedIndex {
257+ *(*uint32)(unsafe.Pointer(&data[offset])) = id
258+ offset += 4
259+ }
260+ return offset
261+}
262+
263+// parseHeader parses the binary header from mapped data.
264+func (c *DmenuFuzzyCache) parseHeader(data []byte) (*BinaryHeader, error) {
265+ if len(data) < HeaderSize {
266+ return nil, fmt.Errorf("insufficient data for header")
267+ }
268+
269+ header := (*BinaryHeader)(unsafe.Pointer(&data[0]))
270+
271+ if header.Magic != CacheMagic {
272+ return nil, fmt.Errorf("invalid magic number: 0x%x", header.Magic)
273+ }
274+
275+ if header.Version != CacheVersion {
276+ return nil, fmt.Errorf("unsupported cache version: %d", header.Version)
277+ }
278+
279+ return header, nil
280+}
281+
282+// parseEntries parses cache entries from binary data.
283+func (c *DmenuFuzzyCache) parseEntries(data []byte, offset int, count uint32) ([]CompactEntry, error) {
284+ entries := make([]CompactEntry, 0, count)
285+
286+ for i := uint32(0); i < count; i++ {
287+ if offset+2 > len(data) {
288+ return nil, fmt.Errorf("insufficient data for URL length at entry %d", i)
289+ }
290+
291+ // Read URL
292+ urlLen := *(*uint16)(unsafe.Pointer(&data[offset]))
293+ offset += 2
294+ if offset+int(urlLen) > len(data) {
295+ return nil, fmt.Errorf("insufficient data for URL at entry %d", i)
296+ }
297+ url := string(data[offset : offset+int(urlLen)])
298+ offset += int(urlLen)
299+
300+ // Read Title
301+ if offset+2 > len(data) {
302+ return nil, fmt.Errorf("insufficient data for title length at entry %d", i)
303+ }
304+ titleLen := *(*uint16)(unsafe.Pointer(&data[offset]))
305+ offset += 2
306+ if offset+int(titleLen) > len(data) {
307+ return nil, fmt.Errorf("insufficient data for title at entry %d", i)
308+ }
309+ title := string(data[offset : offset+int(titleLen)])
310+ offset += int(titleLen)
311+
312+ // Read metadata
313+ if offset+8 > len(data) {
314+ return nil, fmt.Errorf("insufficient data for metadata at entry %d", i)
315+ }
316+ visitCount := *(*uint16)(unsafe.Pointer(&data[offset]))
317+ offset += 2
318+ lastVisit := *(*uint32)(unsafe.Pointer(&data[offset]))
319+ offset += 4
320+ score := *(*uint16)(unsafe.Pointer(&data[offset]))
321+ offset += 2
322+
323+ entries = append(entries, CompactEntry{
324+ URL: url,
325+ Title: title,
326+ VisitCount: visitCount,
327+ LastVisit: lastVisit,
328+ Score: score,
329+ })
330+ }
331+
332+ return entries, nil
333+}
334+
335+// parseTrigramIndex parses the trigram index from binary data.
336+func (c *DmenuFuzzyCache) parseTrigramIndex(data []byte, offset int) (map[string][]uint32, int, error) {
337+ if offset+4 > len(data) {
338+ return nil, 0, fmt.Errorf("insufficient data for trigram count")
339+ }
340+
341+ trigramCount := *(*uint32)(unsafe.Pointer(&data[offset]))
342+ offset += 4
343+
344+ trigramIndex := make(map[string][]uint32, trigramCount)
345+
346+ for i := uint32(0); i < trigramCount; i++ {
347+ // Read trigram
348+ if offset+3 > len(data) {
349+ return nil, 0, fmt.Errorf("insufficient data for trigram %d", i)
350+ }
351+ trigram := string(data[offset : offset+3])
352+ offset += 3
353+
354+ // Read entry count
355+ if offset+4 > len(data) {
356+ return nil, 0, fmt.Errorf("insufficient data for entry count in trigram %d", i)
357+ }
358+ idCount := *(*uint32)(unsafe.Pointer(&data[offset]))
359+ offset += 4
360+
361+ // Read entry IDs
362+ if offset+int(idCount)*4 > len(data) {
363+ return nil, 0, fmt.Errorf("insufficient data for entry IDs in trigram %d", i)
364+ }
365+
366+ ids := make([]uint32, idCount)
367+ for j := uint32(0); j < idCount; j++ {
368+ ids[j] = *(*uint32)(unsafe.Pointer(&data[offset]))
369+ offset += 4
370+ }
371+
372+ trigramIndex[trigram] = ids
373+ }
374+
375+ return trigramIndex, offset, nil
376+}
377+
378+// parseSortedIndex parses the sorted index from binary data.
379+func (c *DmenuFuzzyCache) parseSortedIndex(data []byte, offset int) ([]uint32, error) {
380+ if offset+4 > len(data) {
381+ return nil, fmt.Errorf("insufficient data for sorted index count")
382+ }
383+
384+ indexCount := *(*uint32)(unsafe.Pointer(&data[offset]))
385+ offset += 4
386+
387+ if offset+int(indexCount)*4 > len(data) {
388+ return nil, fmt.Errorf("insufficient data for sorted index entries")
389+ }
390+
391+ sortedIndex := make([]uint32, indexCount)
392+ for i := uint32(0); i < indexCount; i++ {
393+ sortedIndex[i] = *(*uint32)(unsafe.Pointer(&data[offset]))
394+ offset += 4
395+ }
396+
397+ return sortedIndex, nil
398+}
399+
400+// IsValidCacheFile checks if a file is a valid cache file without fully loading it.
401+func IsValidCacheFile(filename string) bool {
402+ file, err := os.Open(filename)
403+ if err != nil {
404+ return false
405+ }
406+ defer file.Close()
407+
408+ var header BinaryHeader
409+ err = binary.Read(file, binary.LittleEndian, &header)
410+ if err != nil {
411+ return false
412+ }
413+
414+ return header.Magic == CacheMagic && header.Version == CacheVersion
415+}
A internal/cache/fuzzy_cache.go +329 -0
@@ -0,0 +1,329 @@
1+package cache
2+
3+import (
4+ "context"
5+ "crypto/md5"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "sync"
10+ "sync/atomic"
11+ "time"
12+)
13+
14+// CacheManager handles the lifecycle of the fuzzy cache with smart invalidation.
15+type CacheManager struct {
16+ cache *DmenuFuzzyCache
17+ config *CacheConfig
18+ queries HistoryQuerier
19+ mu sync.RWMutex
20+ lastDBHash string // Hash of database content to detect changes
21+ updating int32 // Atomic flag to prevent concurrent updates
22+}
23+
24+// NewCacheManager creates a new cache manager.
25+func NewCacheManager(queries HistoryQuerier, config *CacheConfig) *CacheManager {
26+ if config == nil {
27+ config = DefaultCacheConfig()
28+ }
29+
30+ // Set default cache file path if not specified
31+ if config.CacheFile == "" {
32+ stateDir, err := config.GetStateDir()
33+ if err != nil {
34+ // Fallback to temp directory if state dir fails
35+ config.CacheFile = filepath.Join(os.TempDir(), "dumber_fuzzy_cache.bin")
36+ } else {
37+ config.CacheFile = filepath.Join(stateDir, "dmenu_fuzzy_cache.bin")
38+ }
39+ }
40+
41+ cm := &CacheManager{
42+ cache: &DmenuFuzzyCache{},
43+ config: config,
44+ queries: queries,
45+ }
46+
47+ return cm
48+}
49+
50+// GetCache returns the current cache, loading from filesystem or DB as needed.
51+func (cm *CacheManager) GetCache(ctx context.Context) (*DmenuFuzzyCache, error) {
52+ cm.mu.RLock()
53+
54+ // Check if we have a valid cache
55+ if cm.cache.entryCount > 0 {
56+ defer cm.mu.RUnlock()
57+ return cm.cache, nil
58+ }
59+ cm.mu.RUnlock()
60+
61+ // Need to load cache
62+ return cm.loadOrBuildCache(ctx)
63+}
64+
65+// loadOrBuildCache loads cache from filesystem or builds from database.
66+func (cm *CacheManager) loadOrBuildCache(ctx context.Context) (*DmenuFuzzyCache, error) {
67+ cm.mu.Lock()
68+ defer cm.mu.Unlock()
69+
70+ // Double-check after acquiring lock
71+ if cm.cache.entryCount > 0 {
72+ return cm.cache, nil
73+ }
74+
75+ // Try loading from filesystem first
76+ if cm.shouldLoadFromFile() {
77+ if err := cm.cache.LoadFromBinary(cm.config.CacheFile); err == nil {
78+ // Verify cache is still valid
79+ if cm.isCacheValid(ctx) {
80+ return cm.cache, nil
81+ }
82+ }
83+ }
84+
85+ // Build fresh cache from database
86+ return cm.buildCacheFromDB(ctx)
87+}
88+
89+// shouldLoadFromFile determines if we should try loading from filesystem.
90+func (cm *CacheManager) shouldLoadFromFile() bool {
91+ if _, err := os.Stat(cm.config.CacheFile); os.IsNotExist(err) {
92+ return false
93+ }
94+
95+ // Check if file is valid and not too old
96+ if !IsValidCacheFile(cm.config.CacheFile) {
97+ return false
98+ }
99+
100+ // Check file age
101+ info, err := os.Stat(cm.config.CacheFile)
102+ if err != nil {
103+ return false
104+ }
105+
106+ return time.Since(info.ModTime()) < cm.config.TTL
107+}
108+
109+// isCacheValid checks if the cache is still valid by comparing DB hash.
110+func (cm *CacheManager) isCacheValid(ctx context.Context) bool {
111+ currentHash, err := cm.calculateDBHash(ctx)
112+ if err != nil {
113+ return false
114+ }
115+
116+ return currentHash == cm.lastDBHash
117+}
118+
119+// buildCacheFromDB builds a fresh cache from the database.
120+func (cm *CacheManager) buildCacheFromDB(ctx context.Context) (*DmenuFuzzyCache, error) {
121+ // Prevent concurrent builds
122+ if !atomic.CompareAndSwapInt32(&cm.updating, 0, 1) {
123+ // Another goroutine is building, wait for it
124+ for atomic.LoadInt32(&cm.updating) == 1 {
125+ time.Sleep(10 * time.Millisecond)
126+ }
127+ return cm.cache, nil
128+ }
129+ defer atomic.StoreInt32(&cm.updating, 0)
130+
131+ startTime := time.Now()
132+
133+ // Get history from database
134+ history, err := cm.queries.GetHistory(ctx, int64(cm.config.MaxEntries))
135+ if err != nil {
136+ return nil, fmt.Errorf("failed to get history: %w", err)
137+ }
138+
139+ // Build new cache
140+ newCache := &DmenuFuzzyCache{
141+ version: CacheVersion,
142+ lastModified: time.Now().Unix(),
143+ entryCount: uint32(len(history)),
144+ }
145+
146+ // Convert database entries to compact format
147+ newCache.entries = make([]CompactEntry, len(history))
148+ for i, entry := range history {
149+ title := ""
150+ if entry.Title.Valid {
151+ title = entry.Title.String
152+ }
153+
154+ visitCount := int64(0)
155+ if entry.VisitCount.Valid {
156+ visitCount = entry.VisitCount.Int64
157+ }
158+
159+ lastVisited := time.Now()
160+ if entry.LastVisited.Valid {
161+ lastVisited = entry.LastVisited.Time
162+ }
163+
164+ newCache.entries[i] = NewCompactEntry(
165+ entry.Url,
166+ title,
167+ visitCount,
168+ lastVisited,
169+ )
170+ }
171+
172+ // Build search indices
173+ newCache.buildTrigramIndex()
174+ newCache.buildPrefixTrie()
175+ newCache.buildSortedIndex() // Pre-sort for O(1) getTopEntries
176+
177+ // Update current cache
178+ cm.cache = newCache
179+
180+ // Update DB hash
181+ cm.lastDBHash, _ = cm.calculateDBHash(ctx)
182+
183+ // Save to filesystem in background
184+ go cm.saveToFilesystemAsync()
185+
186+ fmt.Printf("Cache built in %v with %d entries\n", time.Since(startTime), len(history))
187+ return cm.cache, nil
188+}
189+
190+// InvalidateAndRefresh forces cache invalidation and refresh.
191+// This should be called when we know the DB has changed (e.g., after adding history).
192+func (cm *CacheManager) InvalidateAndRefresh(ctx context.Context) {
193+ go func() {
194+ cm.mu.Lock()
195+ defer cm.mu.Unlock()
196+
197+ // Clear current cache to force rebuild
198+ cm.cache = &DmenuFuzzyCache{}
199+ cm.lastDBHash = ""
200+
201+ // Remove cache file
202+ os.Remove(cm.config.CacheFile)
203+
204+ // Build new cache
205+ _, err := cm.buildCacheFromDB(ctx)
206+ if err != nil {
207+ fmt.Printf("Warning: failed to refresh cache: %v\n", err)
208+ }
209+ }()
210+}
211+
212+// OnApplicationExit should be called when the application is about to exit.
213+// This triggers a background cache refresh for the next startup.
214+func (cm *CacheManager) OnApplicationExit(ctx context.Context) {
215+ // Check if cache needs updating
216+ if !cm.isCacheValid(ctx) {
217+ // Refresh cache in background for next startup
218+ go func() {
219+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
220+ defer cancel()
221+
222+ _, err := cm.buildCacheFromDB(ctx)
223+ if err != nil {
224+ fmt.Printf("Warning: failed to refresh cache on exit: %v\n", err)
225+ }
226+ }()
227+ }
228+}
229+
230+// saveToFilesystemAsync saves the cache to filesystem in a background goroutine.
231+func (cm *CacheManager) saveToFilesystemAsync() {
232+ // Create cache directory if it doesn't exist
233+ cacheDir := filepath.Dir(cm.config.CacheFile)
234+ if err := os.MkdirAll(cacheDir, 0755); err != nil {
235+ fmt.Printf("Warning: failed to create cache directory: %v\n", err)
236+ return
237+ }
238+
239+ // Save to temporary file first, then atomic rename
240+ tempFile := cm.config.CacheFile + ".tmp"
241+ if err := cm.cache.SaveToBinary(tempFile); err != nil {
242+ fmt.Printf("Warning: failed to save cache: %v\n", err)
243+ os.Remove(tempFile)
244+ return
245+ }
246+
247+ // Atomic rename
248+ if err := os.Rename(tempFile, cm.config.CacheFile); err != nil {
249+ fmt.Printf("Warning: failed to rename cache file: %v\n", err)
250+ os.Remove(tempFile)
251+ }
252+}
253+
254+// calculateDBHash creates a hash of the current database state to detect changes.
255+func (cm *CacheManager) calculateDBHash(ctx context.Context) (string, error) {
256+ // Get a small sample of recent entries to create a fingerprint
257+ recentHistory, err := cm.queries.GetHistory(ctx, 20) // Just check recent 20 entries
258+ if err != nil {
259+ return "", err
260+ }
261+
262+ // Create hash from URLs, visit counts, and last visited times
263+ hasher := md5.New()
264+ for _, entry := range recentHistory {
265+ hasher.Write([]byte(entry.Url))
266+ if entry.VisitCount.Valid {
267+ hasher.Write([]byte(fmt.Sprintf("%d", entry.VisitCount.Int64)))
268+ }
269+ if entry.LastVisited.Valid {
270+ hasher.Write([]byte(entry.LastVisited.Time.Format(time.RFC3339)))
271+ }
272+ }
273+
274+ return fmt.Sprintf("%x", hasher.Sum(nil)), nil
275+}
276+
277+// Search performs a fuzzy search using the cached data.
278+func (cm *CacheManager) Search(ctx context.Context, query string) (*FuzzyResult, error) {
279+ cache, err := cm.GetCache(ctx)
280+ if err != nil {
281+ return nil, err
282+ }
283+
284+ return cache.Search(query, cm.config), nil
285+}
286+
287+// GetTopEntries returns the top entries without a search query.
288+func (cm *CacheManager) GetTopEntries(ctx context.Context) (*FuzzyResult, error) {
289+ cache, err := cm.GetCache(ctx)
290+ if err != nil {
291+ return nil, err
292+ }
293+
294+ return cache.getTopEntries(cm.config), nil
295+}
296+
297+// Stats returns cache statistics.
298+func (cm *CacheManager) Stats() CacheStats {
299+ cm.mu.RLock()
300+ defer cm.mu.RUnlock()
301+
302+ stats := CacheStats{
303+ EntryCount: int(cm.cache.entryCount),
304+ TrigramCount: len(cm.cache.trigramIndex),
305+ LastModified: time.Unix(cm.cache.lastModified, 0),
306+ }
307+
308+ if info, err := os.Stat(cm.config.CacheFile); err == nil {
309+ stats.FileSize = info.Size()
310+ stats.FileModTime = info.ModTime()
311+ }
312+
313+ return stats
314+}
315+
316+// CacheStats provides information about the cache state.
317+type CacheStats struct {
318+ EntryCount int // Number of cached entries
319+ TrigramCount int // Number of trigrams in index
320+ FileSize int64 // Size of cache file on disk
321+ LastModified time.Time // When cache was last built
322+ FileModTime time.Time // When cache file was last modified
323+}
324+
325+// String returns a string representation of cache stats.
326+func (cs CacheStats) String() string {
327+ return fmt.Sprintf("Entries: %d, Trigrams: %d, File: %d bytes, Modified: %v",
328+ cs.EntryCount, cs.TrigramCount, cs.FileSize, cs.FileModTime.Format("15:04:05"))
329+}
@@ -0,0 +1,329 @@
1+package cache
2+
3+import (
4+ "context"
5+ "crypto/md5"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "sync"
10+ "sync/atomic"
11+ "time"
12+)
13+
14+// CacheManager handles the lifecycle of the fuzzy cache with smart invalidation.
15+type CacheManager struct {
16+ cache *DmenuFuzzyCache
17+ config *CacheConfig
18+ queries HistoryQuerier
19+ mu sync.RWMutex
20+ lastDBHash string // Hash of database content to detect changes
21+ updating int32 // Atomic flag to prevent concurrent updates
22+}
23+
24+// NewCacheManager creates a new cache manager.
25+func NewCacheManager(queries HistoryQuerier, config *CacheConfig) *CacheManager {
26+ if config == nil {
27+ config = DefaultCacheConfig()
28+ }
29+
30+ // Set default cache file path if not specified
31+ if config.CacheFile == "" {
32+ stateDir, err := config.GetStateDir()
33+ if err != nil {
34+ // Fallback to temp directory if state dir fails
35+ config.CacheFile = filepath.Join(os.TempDir(), "dumber_fuzzy_cache.bin")
36+ } else {
37+ config.CacheFile = filepath.Join(stateDir, "dmenu_fuzzy_cache.bin")
38+ }
39+ }
40+
41+ cm := &CacheManager{
42+ cache: &DmenuFuzzyCache{},
43+ config: config,
44+ queries: queries,
45+ }
46+
47+ return cm
48+}
49+
50+// GetCache returns the current cache, loading from filesystem or DB as needed.
51+func (cm *CacheManager) GetCache(ctx context.Context) (*DmenuFuzzyCache, error) {
52+ cm.mu.RLock()
53+
54+ // Check if we have a valid cache
55+ if cm.cache.entryCount > 0 {
56+ defer cm.mu.RUnlock()
57+ return cm.cache, nil
58+ }
59+ cm.mu.RUnlock()
60+
61+ // Need to load cache
62+ return cm.loadOrBuildCache(ctx)
63+}
64+
65+// loadOrBuildCache loads cache from filesystem or builds from database.
66+func (cm *CacheManager) loadOrBuildCache(ctx context.Context) (*DmenuFuzzyCache, error) {
67+ cm.mu.Lock()
68+ defer cm.mu.Unlock()
69+
70+ // Double-check after acquiring lock
71+ if cm.cache.entryCount > 0 {
72+ return cm.cache, nil
73+ }
74+
75+ // Try loading from filesystem first
76+ if cm.shouldLoadFromFile() {
77+ if err := cm.cache.LoadFromBinary(cm.config.CacheFile); err == nil {
78+ // Verify cache is still valid
79+ if cm.isCacheValid(ctx) {
80+ return cm.cache, nil
81+ }
82+ }
83+ }
84+
85+ // Build fresh cache from database
86+ return cm.buildCacheFromDB(ctx)
87+}
88+
89+// shouldLoadFromFile determines if we should try loading from filesystem.
90+func (cm *CacheManager) shouldLoadFromFile() bool {
91+ if _, err := os.Stat(cm.config.CacheFile); os.IsNotExist(err) {
92+ return false
93+ }
94+
95+ // Check if file is valid and not too old
96+ if !IsValidCacheFile(cm.config.CacheFile) {
97+ return false
98+ }
99+
100+ // Check file age
101+ info, err := os.Stat(cm.config.CacheFile)
102+ if err != nil {
103+ return false
104+ }
105+
106+ return time.Since(info.ModTime()) < cm.config.TTL
107+}
108+
109+// isCacheValid checks if the cache is still valid by comparing DB hash.
110+func (cm *CacheManager) isCacheValid(ctx context.Context) bool {
111+ currentHash, err := cm.calculateDBHash(ctx)
112+ if err != nil {
113+ return false
114+ }
115+
116+ return currentHash == cm.lastDBHash
117+}
118+
119+// buildCacheFromDB builds a fresh cache from the database.
120+func (cm *CacheManager) buildCacheFromDB(ctx context.Context) (*DmenuFuzzyCache, error) {
121+ // Prevent concurrent builds
122+ if !atomic.CompareAndSwapInt32(&cm.updating, 0, 1) {
123+ // Another goroutine is building, wait for it
124+ for atomic.LoadInt32(&cm.updating) == 1 {
125+ time.Sleep(10 * time.Millisecond)
126+ }
127+ return cm.cache, nil
128+ }
129+ defer atomic.StoreInt32(&cm.updating, 0)
130+
131+ startTime := time.Now()
132+
133+ // Get history from database
134+ history, err := cm.queries.GetHistory(ctx, int64(cm.config.MaxEntries))
135+ if err != nil {
136+ return nil, fmt.Errorf("failed to get history: %w", err)
137+ }
138+
139+ // Build new cache
140+ newCache := &DmenuFuzzyCache{
141+ version: CacheVersion,
142+ lastModified: time.Now().Unix(),
143+ entryCount: uint32(len(history)),
144+ }
145+
146+ // Convert database entries to compact format
147+ newCache.entries = make([]CompactEntry, len(history))
148+ for i, entry := range history {
149+ title := ""
150+ if entry.Title.Valid {
151+ title = entry.Title.String
152+ }
153+
154+ visitCount := int64(0)
155+ if entry.VisitCount.Valid {
156+ visitCount = entry.VisitCount.Int64
157+ }
158+
159+ lastVisited := time.Now()
160+ if entry.LastVisited.Valid {
161+ lastVisited = entry.LastVisited.Time
162+ }
163+
164+ newCache.entries[i] = NewCompactEntry(
165+ entry.Url,
166+ title,
167+ visitCount,
168+ lastVisited,
169+ )
170+ }
171+
172+ // Build search indices
173+ newCache.buildTrigramIndex()
174+ newCache.buildPrefixTrie()
175+ newCache.buildSortedIndex() // Pre-sort for O(1) getTopEntries
176+
177+ // Update current cache
178+ cm.cache = newCache
179+
180+ // Update DB hash
181+ cm.lastDBHash, _ = cm.calculateDBHash(ctx)
182+
183+ // Save to filesystem in background
184+ go cm.saveToFilesystemAsync()
185+
186+ fmt.Printf("Cache built in %v with %d entries\n", time.Since(startTime), len(history))
187+ return cm.cache, nil
188+}
189+
190+// InvalidateAndRefresh forces cache invalidation and refresh.
191+// This should be called when we know the DB has changed (e.g., after adding history).
192+func (cm *CacheManager) InvalidateAndRefresh(ctx context.Context) {
193+ go func() {
194+ cm.mu.Lock()
195+ defer cm.mu.Unlock()
196+
197+ // Clear current cache to force rebuild
198+ cm.cache = &DmenuFuzzyCache{}
199+ cm.lastDBHash = ""
200+
201+ // Remove cache file
202+ os.Remove(cm.config.CacheFile)
203+
204+ // Build new cache
205+ _, err := cm.buildCacheFromDB(ctx)
206+ if err != nil {
207+ fmt.Printf("Warning: failed to refresh cache: %v\n", err)
208+ }
209+ }()
210+}
211+
212+// OnApplicationExit should be called when the application is about to exit.
213+// This triggers a background cache refresh for the next startup.
214+func (cm *CacheManager) OnApplicationExit(ctx context.Context) {
215+ // Check if cache needs updating
216+ if !cm.isCacheValid(ctx) {
217+ // Refresh cache in background for next startup
218+ go func() {
219+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
220+ defer cancel()
221+
222+ _, err := cm.buildCacheFromDB(ctx)
223+ if err != nil {
224+ fmt.Printf("Warning: failed to refresh cache on exit: %v\n", err)
225+ }
226+ }()
227+ }
228+}
229+
230+// saveToFilesystemAsync saves the cache to filesystem in a background goroutine.
231+func (cm *CacheManager) saveToFilesystemAsync() {
232+ // Create cache directory if it doesn't exist
233+ cacheDir := filepath.Dir(cm.config.CacheFile)
234+ if err := os.MkdirAll(cacheDir, 0755); err != nil {
235+ fmt.Printf("Warning: failed to create cache directory: %v\n", err)
236+ return
237+ }
238+
239+ // Save to temporary file first, then atomic rename
240+ tempFile := cm.config.CacheFile + ".tmp"
241+ if err := cm.cache.SaveToBinary(tempFile); err != nil {
242+ fmt.Printf("Warning: failed to save cache: %v\n", err)
243+ os.Remove(tempFile)
244+ return
245+ }
246+
247+ // Atomic rename
248+ if err := os.Rename(tempFile, cm.config.CacheFile); err != nil {
249+ fmt.Printf("Warning: failed to rename cache file: %v\n", err)
250+ os.Remove(tempFile)
251+ }
252+}
253+
254+// calculateDBHash creates a hash of the current database state to detect changes.
255+func (cm *CacheManager) calculateDBHash(ctx context.Context) (string, error) {
256+ // Get a small sample of recent entries to create a fingerprint
257+ recentHistory, err := cm.queries.GetHistory(ctx, 20) // Just check recent 20 entries
258+ if err != nil {
259+ return "", err
260+ }
261+
262+ // Create hash from URLs, visit counts, and last visited times
263+ hasher := md5.New()
264+ for _, entry := range recentHistory {
265+ hasher.Write([]byte(entry.Url))
266+ if entry.VisitCount.Valid {
267+ hasher.Write([]byte(fmt.Sprintf("%d", entry.VisitCount.Int64)))
268+ }
269+ if entry.LastVisited.Valid {
270+ hasher.Write([]byte(entry.LastVisited.Time.Format(time.RFC3339)))
271+ }
272+ }
273+
274+ return fmt.Sprintf("%x", hasher.Sum(nil)), nil
275+}
276+
277+// Search performs a fuzzy search using the cached data.
278+func (cm *CacheManager) Search(ctx context.Context, query string) (*FuzzyResult, error) {
279+ cache, err := cm.GetCache(ctx)
280+ if err != nil {
281+ return nil, err
282+ }
283+
284+ return cache.Search(query, cm.config), nil
285+}
286+
287+// GetTopEntries returns the top entries without a search query.
288+func (cm *CacheManager) GetTopEntries(ctx context.Context) (*FuzzyResult, error) {
289+ cache, err := cm.GetCache(ctx)
290+ if err != nil {
291+ return nil, err
292+ }
293+
294+ return cache.getTopEntries(cm.config), nil
295+}
296+
297+// Stats returns cache statistics.
298+func (cm *CacheManager) Stats() CacheStats {
299+ cm.mu.RLock()
300+ defer cm.mu.RUnlock()
301+
302+ stats := CacheStats{
303+ EntryCount: int(cm.cache.entryCount),
304+ TrigramCount: len(cm.cache.trigramIndex),
305+ LastModified: time.Unix(cm.cache.lastModified, 0),
306+ }
307+
308+ if info, err := os.Stat(cm.config.CacheFile); err == nil {
309+ stats.FileSize = info.Size()
310+ stats.FileModTime = info.ModTime()
311+ }
312+
313+ return stats
314+}
315+
316+// CacheStats provides information about the cache state.
317+type CacheStats struct {
318+ EntryCount int // Number of cached entries
319+ TrigramCount int // Number of trigrams in index
320+ FileSize int64 // Size of cache file on disk
321+ LastModified time.Time // When cache was last built
322+ FileModTime time.Time // When cache file was last modified
323+}
324+
325+// String returns a string representation of cache stats.
326+func (cs CacheStats) String() string {
327+ return fmt.Sprintf("Entries: %d, Trigrams: %d, File: %d bytes, Modified: %v",
328+ cs.EntryCount, cs.TrigramCount, cs.FileSize, cs.FileModTime.Format("15:04:05"))
329+}
A internal/cache/fuzzy_cache_test.go +671 -0
@@ -0,0 +1,671 @@
1+package cache
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ mock_cache "github.com/bnema/dumber/internal/cache/mocks"
12+ "github.com/bnema/dumber/internal/db"
13+ "github.com/golang/mock/gomock"
14+)
15+
16+func createTestHistory() []db.History {
17+ now := time.Now()
18+ return []db.History{
19+ {
20+ ID: 1,
21+ Url: "https://github.com",
22+ Title: sql.NullString{String: "GitHub", Valid: true},
23+ VisitCount: sql.NullInt64{Int64: 25, Valid: true},
24+ LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true},
25+ },
26+ {
27+ ID: 2,
28+ Url: "https://stackoverflow.com",
29+ Title: sql.NullString{String: "Stack Overflow", Valid: true},
30+ VisitCount: sql.NullInt64{Int64: 15, Valid: true},
31+ LastVisited: sql.NullTime{Time: now.Add(-2 * time.Hour), Valid: true},
32+ },
33+ {
34+ ID: 3,
35+ Url: "https://golang.org",
36+ Title: sql.NullString{String: "The Go Programming Language", Valid: true},
37+ VisitCount: sql.NullInt64{Int64: 8, Valid: true},
38+ LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true},
39+ },
40+ {
41+ ID: 4,
42+ Url: "https://news.ycombinator.com",
43+ Title: sql.NullString{String: "Hacker News", Valid: true},
44+ VisitCount: sql.NullInt64{Int64: 30, Valid: true},
45+ LastVisited: sql.NullTime{Time: now.Add(-30 * time.Minute), Valid: true},
46+ },
47+ {
48+ ID: 5,
49+ Url: "https://youtube.com",
50+ Title: sql.NullString{String: "YouTube", Valid: true},
51+ VisitCount: sql.NullInt64{Int64: 50, Valid: true},
52+ LastVisited: sql.NullTime{Time: now.Add(-10 * time.Minute), Valid: true},
53+ },
54+ }
55+}
56+
57+func TestCacheManager_BuildCache(t *testing.T) {
58+ ctrl := gomock.NewController(t)
59+ defer ctrl.Finish()
60+
61+ mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
62+ history := createTestHistory()
63+
64+ // Mock both calls - one for building cache, one for calculating hash
65+ mockQuerier.EXPECT().GetHistory(gomock.Any(), int64(10000)).Return(history, nil)
66+ mockQuerier.EXPECT().GetHistory(gomock.Any(), int64(20)).Return(history, nil)
67+
68+ tempDir, err := os.MkdirTemp("", "dumber_cache_test")
69+ if err != nil {
70+ t.Fatalf("Failed to create temp dir: %v", err)
71+ }
72+ defer os.RemoveAll(tempDir)
73+
74+ config := DefaultCacheConfig()
75+ config.CacheFile = filepath.Join(tempDir, "test_cache.bin")
76+ config.MaxResults = 10
77+
78+ manager := NewCacheManager(mockQuerier, config)
79+ ctx := context.Background()
80+
81+ // Test cache building
82+ cache, err := manager.GetCache(ctx)
83+ if err != nil {
84+ t.Fatalf("Failed to get cache: %v", err)
85+ }
86+
87+ if cache.entryCount != 5 {
88+ t.Errorf("Expected 5 entries, got %d", cache.entryCount)
89+ }
90+
91+ // Test that indices are built
92+ if len(cache.trigramIndex) == 0 {
93+ t.Error("Trigram index should not be empty")
94+ }
95+
96+ if cache.prefixTrie == nil || cache.prefixTrie.Root == nil {
97+ t.Error("Prefix trie should be initialized")
98+ }
99+}
100+
101+func TestFuzzySearcher_JaroWinklerSimilarity(t *testing.T) {
102+ config := DefaultCacheConfig()
103+ searcher := NewFuzzySearcher(config)
104+
105+ tests := []struct {
106+ name string
107+ s1 string
108+ s2 string
109+ expected float64
110+ delta float64
111+ }{
112+ {"identical strings", "test", "test", 1.0, 0.0},
113+ {"empty strings", "", "", 1.0, 0.0},
114+ {"one empty", "", "test", 0.0, 0.0},
115+ {"similar strings", "github", "gihub", 0.88, 0.1},
116+ {"different strings", "github", "stackoverflow", 0.41, 0.05},
117+ {"prefix match", "programming", "program", 0.9, 0.1},
118+ }
119+
120+ for _, tt := range tests {
121+ t.Run(tt.name, func(t *testing.T) {
122+ result := searcher.jaroWinklerSimilarity(tt.s1, tt.s2)
123+
124+ if result < 0.0 || result > 1.0 {
125+ t.Errorf("Score %f should be between 0.0 and 1.0", result)
126+ }
127+
128+ diff := result - tt.expected
129+ if diff < 0 {
130+ diff = -diff
131+ }
132+ if diff > tt.delta {
133+ t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
134+ }
135+ })
136+ }
137+}
138+
139+func TestFuzzySearcher_SubstringSimilarity(t *testing.T) {
140+ config := DefaultCacheConfig()
141+ searcher := NewFuzzySearcher(config)
142+
143+ tests := []struct {
144+ name string
145+ query string
146+ text string
147+ expected float64
148+ delta float64
149+ }{
150+ {"exact match", "github", "github", 1.0, 0.0},
151+ {"substring at start", "git", "github.com", 0.45, 0.2},
152+ {"substring at end", "com", "github.com", 0.3, 0.1},
153+ {"no match", "xyz", "github.com", 0.0, 0.0},
154+ {"empty query", "", "github.com", 0.0, 0.0},
155+ {"empty text", "git", "", 0.0, 0.0},
156+ }
157+
158+ for _, tt := range tests {
159+ t.Run(tt.name, func(t *testing.T) {
160+ result := searcher.substringSimilarity(tt.query, tt.text)
161+
162+ if result < 0.0 || result > 1.0 {
163+ t.Errorf("Score %f should be between 0.0 and 1.0", result)
164+ }
165+
166+ diff := result - tt.expected
167+ if diff < 0 {
168+ diff = -diff
169+ }
170+ if diff > tt.delta {
171+ t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
172+ }
173+ })
174+ }
175+}
176+
177+func TestFuzzySearcher_TokenizedSimilarity(t *testing.T) {
178+ config := DefaultCacheConfig()
179+ searcher := NewFuzzySearcher(config)
180+
181+ tests := []struct {
182+ name string
183+ query string
184+ text string
185+ expected float64
186+ delta float64
187+ }{
188+ {"exact token match", "go programming", "The Go Programming Language", 0.8, 0.2},
189+ {"partial token match", "stack overflow", "Stack Overflow Questions", 0.8, 0.2},
190+ {"no token match", "python django", "JavaScript React Framework", 0.0, 0.1},
191+ {"single token", "github", "GitHub Repository", 0.78, 0.05},
192+ }
193+
194+ for _, tt := range tests {
195+ t.Run(tt.name, func(t *testing.T) {
196+ result := searcher.tokenizedSimilarity(tt.query, tt.text)
197+
198+ if result < 0.0 || result > 1.0 {
199+ t.Errorf("Score %f should be between 0.0 and 1.0", result)
200+ }
201+
202+ diff := result - tt.expected
203+ if diff < 0 {
204+ diff = -diff
205+ }
206+ if diff > tt.delta {
207+ t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
208+ }
209+ })
210+ }
211+}
212+
213+func TestFuzzySearcher_RecencyScore(t *testing.T) {
214+ config := DefaultCacheConfig()
215+ searcher := NewFuzzySearcher(config)
216+
217+ now := time.Now()
218+ tests := []struct {
219+ name string
220+ lastVisitDays uint32
221+ expected float64
222+ delta float64
223+ }{
224+ {"recent visit", DaysFromTime(now.Add(-1 * time.Hour)), 0.95, 0.1},
225+ {"one day ago", DaysFromTime(now.Add(-24 * time.Hour)), 0.88, 0.1},
226+ {"one week ago", DaysFromTime(now.Add(-7 * 24 * time.Hour)), 0.7, 0.1},
227+ {"one month ago", DaysFromTime(now.Add(-30 * 24 * time.Hour)), 0.37, 0.1},
228+ {"very old", DaysFromTime(now.Add(-365 * 24 * time.Hour)), 0.01, 0.01},
229+ }
230+
231+ for _, tt := range tests {
232+ t.Run(tt.name, func(t *testing.T) {
233+ result := searcher.calculateRecencyScore(tt.lastVisitDays)
234+
235+ if result < 0.0 || result > 1.0 {
236+ t.Errorf("Score %f should be between 0.0 and 1.0", result)
237+ }
238+
239+ diff := result - tt.expected
240+ if diff < 0 {
241+ diff = -diff
242+ }
243+ if diff > tt.delta {
244+ t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
245+ }
246+ })
247+ }
248+}
249+
250+func TestFuzzySearcher_VisitScore(t *testing.T) {
251+ config := DefaultCacheConfig()
252+ searcher := NewFuzzySearcher(config)
253+
254+ tests := []struct {
255+ name string
256+ visitCount uint16
257+ expected float64
258+ delta float64
259+ }{
260+ {"zero visits", 0, 0.0, 0.0},
261+ {"single visit", 1, 0.1, 0.05},
262+ {"moderate visits", 10, 0.35, 0.1},
263+ {"many visits", 100, 0.67, 0.1},
264+ {"max visits", 1000, 1.0, 0.01},
265+ }
266+
267+ for _, tt := range tests {
268+ t.Run(tt.name, func(t *testing.T) {
269+ result := searcher.calculateVisitScore(tt.visitCount)
270+
271+ if result < 0.0 || result > 1.0 {
272+ t.Errorf("Score %f should be between 0.0 and 1.0", result)
273+ }
274+
275+ diff := result - tt.expected
276+ if diff < 0 {
277+ diff = -diff
278+ }
279+ if diff > tt.delta {
280+ t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
281+ }
282+ })
283+ }
284+}
285+
286+func TestTextNormalization(t *testing.T) {
287+ tests := []struct {
288+ name string
289+ input string
290+ expected string
291+ }{
292+ {"basic url", "https://github.com", "github com"},
293+ {"with www", "https://www.google.com", "google com"},
294+ {"with path", "https://stackoverflow.com/questions/123", "stackoverflow com questions 123"},
295+ {"mixed case", "GitHub.COM/User", "github com user"},
296+ {"with special chars", "test-site_name.com", "test site name com"},
297+ {"unicode chars", "café-münü.com", "café münü com"},
298+ }
299+
300+ for _, tt := range tests {
301+ t.Run(tt.name, func(t *testing.T) {
302+ result := normalizeText(tt.input)
303+ if result != tt.expected {
304+ t.Errorf("Expected %q, got %q", tt.expected, result)
305+ }
306+ })
307+ }
308+}
309+
310+func TestTrigramExtraction(t *testing.T) {
311+ tests := []struct {
312+ name string
313+ input string
314+ expected []string
315+ }{
316+ {"short string", "ab", nil},
317+ {"exact length", "abc", []string{"abc"}},
318+ {"longer string", "github", []string{"git", "ith", "thu", "hub"}},
319+ {"with spaces", "go lang", []string{"go ", "o l", " la", "lan", "ang"}},
320+ }
321+
322+ for _, tt := range tests {
323+ t.Run(tt.name, func(t *testing.T) {
324+ result := extractTrigrams(tt.input)
325+ if len(result) != len(tt.expected) {
326+ t.Errorf("Expected %d trigrams, got %d", len(tt.expected), len(result))
327+ return
328+ }
329+ for i, expected := range tt.expected {
330+ if result[i] != expected {
331+ t.Errorf("Expected trigram %q at index %d, got %q", expected, i, result[i])
332+ }
333+ }
334+ })
335+ }
336+}
337+
338+func TestCompactEntry_BaseScore(t *testing.T) {
339+ now := time.Now()
340+
341+ tests := []struct {
342+ name string
343+ visitCount uint16
344+ lastVisit time.Time
345+ expectRange [2]uint16 // min, max expected score
346+ }{
347+ {"high visits, recent", 100, now.Add(-1 * time.Hour), [2]uint16{44000, 65535}},
348+ {"low visits, recent", 5, now.Add(-1 * time.Hour), [2]uint16{35000, 50000}},
349+ {"high visits, old", 100, now.Add(-90 * 24 * time.Hour), [2]uint16{5000, 25000}},
350+ {"low visits, old", 5, now.Add(-90 * 24 * time.Hour), [2]uint16{1000, 15000}},
351+ }
352+
353+ for _, tt := range tests {
354+ t.Run(tt.name, func(t *testing.T) {
355+ entry := CompactEntry{
356+ VisitCount: tt.visitCount,
357+ LastVisit: DaysFromTime(tt.lastVisit),
358+ }
359+
360+ score := entry.calculateBaseScore()
361+
362+ if score < tt.expectRange[0] || score > tt.expectRange[1] {
363+ t.Errorf("Expected score between %d and %d, got %d",
364+ tt.expectRange[0], tt.expectRange[1], score)
365+ }
366+ })
367+ }
368+}
369+
370+func TestFuzzySearch_Integration(t *testing.T) {
371+ ctrl := gomock.NewController(t)
372+ defer ctrl.Finish()
373+
374+ mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
375+ history := createTestHistory()
376+
377+ mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(history, nil).AnyTimes()
378+
379+ tempDir, err := os.MkdirTemp("", "dumber_search_test")
380+ if err != nil {
381+ t.Fatalf("Failed to create temp dir: %v", err)
382+ }
383+ defer os.RemoveAll(tempDir)
384+
385+ config := DefaultCacheConfig()
386+ config.CacheFile = filepath.Join(tempDir, "search_cache.bin")
387+ config.ScoreThreshold = 0.1
388+
389+ manager := NewCacheManager(mockQuerier, config)
390+ ctx := context.Background()
391+
392+ // Critical fuzzy search tests
393+ searchTests := []struct {
394+ name string
395+ query string
396+ expectCount int
397+ expectFirst string
398+ expectMatchType MatchType
399+ }{
400+ {
401+ name: "exact domain match",
402+ query: "github",
403+ expectCount: 1,
404+ expectFirst: "https://github.com",
405+ expectMatchType: MatchTypeExact,
406+ },
407+ {
408+ name: "partial title match",
409+ query: "programming",
410+ expectCount: 1,
411+ expectFirst: "https://golang.org",
412+ expectMatchType: MatchTypeExact,
413+ },
414+ {
415+ name: "fuzzy typo match",
416+ query: "githb", // typo in github
417+ expectCount: 1,
418+ expectFirst: "https://github.com",
419+ expectMatchType: MatchTypeFuzzy,
420+ },
421+ {
422+ name: "prefix match",
423+ query: "you",
424+ expectCount: 1,
425+ expectFirst: "https://youtube.com",
426+ expectMatchType: MatchTypePrefix,
427+ },
428+ {
429+ name: "no matches",
430+ query: "nonexistentsite12345",
431+ expectCount: 0,
432+ },
433+ }
434+
435+ for _, tt := range searchTests {
436+ t.Run(tt.name, func(t *testing.T) {
437+ result, err := manager.Search(ctx, tt.query)
438+ if err != nil {
439+ t.Fatalf("Search failed: %v", err)
440+ }
441+
442+ if len(result.Matches) < tt.expectCount {
443+ t.Errorf("Expected at least %d matches, got %d", tt.expectCount, len(result.Matches))
444+ return
445+ }
446+
447+ if tt.expectCount == 0 {
448+ return // No further checks needed
449+ }
450+
451+ // Check first result
452+ if result.Matches[0].Entry.URL != tt.expectFirst {
453+ t.Errorf("Expected first result %q, got %q",
454+ tt.expectFirst, result.Matches[0].Entry.URL)
455+ }
456+
457+ // Verify results are sorted by score
458+ for i := 1; i < len(result.Matches); i++ {
459+ if result.Matches[i].Score > result.Matches[i-1].Score {
460+ t.Errorf("Results not sorted by score: %f > %f at position %d",
461+ result.Matches[i].Score, result.Matches[i-1].Score, i)
462+ }
463+ }
464+
465+ // Verify all scores meet threshold
466+ for i, match := range result.Matches {
467+ if match.Score < config.ScoreThreshold {
468+ t.Errorf("Match %d score %f below threshold %f",
469+ i, match.Score, config.ScoreThreshold)
470+ }
471+ }
472+
473+ // Verify query time is recorded
474+ if result.QueryTime <= 0 {
475+ t.Error("Query time should be positive")
476+ }
477+ })
478+ }
479+}
480+
481+func TestCacheInvalidation(t *testing.T) {
482+ ctrl := gomock.NewController(t)
483+ defer ctrl.Finish()
484+
485+ mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
486+
487+ // First call returns initial history
488+ firstHistory := createTestHistory()[:3] // Only 3 entries
489+ secondHistory := createTestHistory() // All 5 entries
490+
491+ // Set up multiple expectations for the different calls
492+ gomock.InOrder(
493+ mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(firstHistory, nil).Times(2), // Build + hash
494+ mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(secondHistory, nil).AnyTimes(), // Subsequent calls
495+ )
496+
497+ tempDir, err := os.MkdirTemp("", "dumber_invalidation_test")
498+ if err != nil {
499+ t.Fatalf("Failed to create temp dir: %v", err)
500+ }
501+ defer os.RemoveAll(tempDir)
502+
503+ config := DefaultCacheConfig()
504+ config.CacheFile = filepath.Join(tempDir, "invalidation_cache.bin")
505+
506+ manager := NewCacheManager(mockQuerier, config)
507+ ctx := context.Background()
508+
509+ // Initial cache build
510+ cache1, err := manager.GetCache(ctx)
511+ if err != nil {
512+ t.Fatalf("Failed to get initial cache: %v", err)
513+ }
514+
515+ if cache1.entryCount != 3 {
516+ t.Errorf("Expected 3 entries in initial cache, got %d", cache1.entryCount)
517+ }
518+
519+ // Invalidate and refresh
520+ manager.InvalidateAndRefresh(ctx)
521+
522+ // Wait for background refresh
523+ time.Sleep(100 * time.Millisecond)
524+
525+ // Get updated cache
526+ cache2, err := manager.GetCache(ctx)
527+ if err != nil {
528+ t.Fatalf("Failed to get updated cache: %v", err)
529+ }
530+
531+ if cache2.entryCount != 5 {
532+ t.Errorf("Expected 5 entries in updated cache, got %d", cache2.entryCount)
533+ }
534+}
535+
536+// create40TestEntries creates a realistic dataset with 40 entries for benchmarking
537+func create40TestEntries() []db.History {
538+ now := time.Now()
539+ entries := make([]db.History, 40)
540+
541+ urls := []string{
542+ "https://github.com", "https://stackoverflow.com", "https://golang.org", "https://news.ycombinator.com",
543+ "https://youtube.com", "https://google.com", "https://reddit.com", "https://twitter.com",
544+ "https://linkedin.com", "https://medium.com", "https://dev.to", "https://docs.python.org",
545+ "https://reactjs.org", "https://nodejs.org", "https://kubernetes.io", "https://docker.com",
546+ "https://aws.amazon.com", "https://cloud.google.com", "https://azure.microsoft.com", "https://heroku.com",
547+ "https://netlify.com", "https://vercel.com", "https://stripe.com", "https://shopify.com",
548+ "https://gitlab.com", "https://bitbucket.org", "https://atlassian.com", "https://slack.com",
549+ "https://discord.com", "https://telegram.org", "https://whatsapp.com", "https://facebook.com",
550+ "https://instagram.com", "https://tiktok.com", "https://netflix.com", "https://spotify.com",
551+ "https://apple.com", "https://microsoft.com", "https://amazon.com", "https://wikipedia.org",
552+ }
553+
554+ titles := []string{
555+ "GitHub", "Stack Overflow", "Go Programming Language", "Hacker News",
556+ "YouTube", "Google", "Reddit", "Twitter",
557+ "LinkedIn", "Medium", "Dev.to", "Python Documentation",
558+ "React", "Node.js", "Kubernetes", "Docker",
559+ "AWS", "Google Cloud", "Microsoft Azure", "Heroku",
560+ "Netlify", "Vercel", "Stripe", "Shopify",
561+ "GitLab", "Bitbucket", "Atlassian", "Slack",
562+ "Discord", "Telegram", "WhatsApp", "Facebook",
563+ "Instagram", "TikTok", "Netflix", "Spotify",
564+ "Apple", "Microsoft", "Amazon", "Wikipedia",
565+ }
566+
567+ for i := 0; i < 40; i++ {
568+ entries[i] = db.History{
569+ ID: int64(i + 1),
570+ Url: urls[i],
571+ Title: sql.NullString{String: titles[i], Valid: true},
572+ VisitCount: sql.NullInt64{Int64: int64(40 - i), Valid: true}, // Decreasing visit counts
573+ LastVisited: sql.NullTime{Time: now.Add(-time.Duration(i) * time.Hour), Valid: true},
574+ }
575+ }
576+
577+ return entries
578+}
579+
580+// BenchmarkGetTopEntries benchmarks the critical path for dmenu performance
581+func BenchmarkGetTopEntries(b *testing.B) {
582+ ctrl := gomock.NewController(b)
583+ defer ctrl.Finish()
584+
585+ mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
586+
587+ // Setup 40 test entries
588+ testHistory := create40TestEntries()
589+ mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(testHistory, nil).AnyTimes()
590+
591+ // Create temporary directory for cache file
592+ tempDir, err := os.MkdirTemp("", "cache_benchmark")
593+ if err != nil {
594+ b.Fatalf("Failed to create temp dir: %v", err)
595+ }
596+ defer os.RemoveAll(tempDir)
597+
598+ config := DefaultCacheConfig()
599+ config.CacheFile = filepath.Join(tempDir, "benchmark_cache.bin")
600+ config.MaxResults = 50
601+
602+ manager := NewCacheManager(mockQuerier, config)
603+ ctx := context.Background()
604+
605+ // Pre-build cache once (this isn't part of the benchmark)
606+ _, err = manager.GetCache(ctx)
607+ if err != nil {
608+ b.Fatalf("Failed to pre-build cache: %v", err)
609+ }
610+
611+ b.ResetTimer() // Start timing from here
612+ b.ReportAllocs()
613+
614+ // This is the critical path that needs to be under 2ms
615+ for i := 0; i < b.N; i++ {
616+ result, err := manager.GetTopEntries(ctx)
617+ if err != nil {
618+ b.Fatalf("GetTopEntries failed: %v", err)
619+ }
620+ if len(result.Matches) == 0 {
621+ b.Fatalf("Expected matches, got none")
622+ }
623+ }
624+}
625+
626+// BenchmarkGetTopEntriesParallel benchmarks concurrent access performance
627+func BenchmarkGetTopEntriesParallel(b *testing.B) {
628+ ctrl := gomock.NewController(b)
629+ defer ctrl.Finish()
630+
631+ mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
632+
633+ // Setup 40 test entries
634+ testHistory := create40TestEntries()
635+ mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(testHistory, nil).AnyTimes()
636+
637+ // Create temporary directory for cache file
638+ tempDir, err := os.MkdirTemp("", "cache_benchmark_parallel")
639+ if err != nil {
640+ b.Fatalf("Failed to create temp dir: %v", err)
641+ }
642+ defer os.RemoveAll(tempDir)
643+
644+ config := DefaultCacheConfig()
645+ config.CacheFile = filepath.Join(tempDir, "benchmark_parallel_cache.bin")
646+ config.MaxResults = 50
647+
648+ manager := NewCacheManager(mockQuerier, config)
649+ ctx := context.Background()
650+
651+ // Pre-build cache once (this isn't part of the benchmark)
652+ _, err = manager.GetCache(ctx)
653+ if err != nil {
654+ b.Fatalf("Failed to pre-build cache: %v", err)
655+ }
656+
657+ b.ResetTimer()
658+ b.ReportAllocs()
659+
660+ b.RunParallel(func(pb *testing.PB) {
661+ for pb.Next() {
662+ result, err := manager.GetTopEntries(ctx)
663+ if err != nil {
664+ b.Fatalf("GetTopEntries failed: %v", err)
665+ }
666+ if len(result.Matches) == 0 {
667+ b.Fatalf("Expected matches, got none")
668+ }
669+ }
670+ })
671+}
@@ -0,0 +1,671 @@
1+package cache
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ mock_cache "github.com/bnema/dumber/internal/cache/mocks"
12+ "github.com/bnema/dumber/internal/db"
13+ "github.com/golang/mock/gomock"
14+)
15+
16+func createTestHistory() []db.History {
17+ now := time.Now()
18+ return []db.History{
19+ {
20+ ID: 1,
21+ Url: "https://github.com",
22+ Title: sql.NullString{String: "GitHub", Valid: true},
23+ VisitCount: sql.NullInt64{Int64: 25, Valid: true},
24+ LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true},
25+ },
26+ {
27+ ID: 2,
28+ Url: "https://stackoverflow.com",
29+ Title: sql.NullString{String: "Stack Overflow", Valid: true},
30+ VisitCount: sql.NullInt64{Int64: 15, Valid: true},
31+ LastVisited: sql.NullTime{Time: now.Add(-2 * time.Hour), Valid: true},
32+ },
33+ {
34+ ID: 3,
35+ Url: "https://golang.org",
36+ Title: sql.NullString{String: "The Go Programming Language", Valid: true},
37+ VisitCount: sql.NullInt64{Int64: 8, Valid: true},
38+ LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true},
39+ },
40+ {
41+ ID: 4,
42+ Url: "https://news.ycombinator.com",
43+ Title: sql.NullString{String: "Hacker News", Valid: true},
44+ VisitCount: sql.NullInt64{Int64: 30, Valid: true},
45+ LastVisited: sql.NullTime{Time: now.Add(-30 * time.Minute), Valid: true},
46+ },
47+ {
48+ ID: 5,
49+ Url: "https://youtube.com",
50+ Title: sql.NullString{String: "YouTube", Valid: true},
51+ VisitCount: sql.NullInt64{Int64: 50, Valid: true},
52+ LastVisited: sql.NullTime{Time: now.Add(-10 * time.Minute), Valid: true},
53+ },
54+ }
55+}
56+
57+func TestCacheManager_BuildCache(t *testing.T) {
58+ ctrl := gomock.NewController(t)
59+ defer ctrl.Finish()
60+
61+ mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
62+ history := createTestHistory()
63+
64+ // Mock both calls - one for building cache, one for calculating hash
65+ mockQuerier.EXPECT().GetHistory(gomock.Any(), int64(10000)).Return(history, nil)
66+ mockQuerier.EXPECT().GetHistory(gomock.Any(), int64(20)).Return(history, nil)
67+
68+ tempDir, err := os.MkdirTemp("", "dumber_cache_test")
69+ if err != nil {
70+ t.Fatalf("Failed to create temp dir: %v", err)
71+ }
72+ defer os.RemoveAll(tempDir)
73+
74+ config := DefaultCacheConfig()
75+ config.CacheFile = filepath.Join(tempDir, "test_cache.bin")
76+ config.MaxResults = 10
77+
78+ manager := NewCacheManager(mockQuerier, config)
79+ ctx := context.Background()
80+
81+ // Test cache building
82+ cache, err := manager.GetCache(ctx)
83+ if err != nil {
84+ t.Fatalf("Failed to get cache: %v", err)
85+ }
86+
87+ if cache.entryCount != 5 {
88+ t.Errorf("Expected 5 entries, got %d", cache.entryCount)
89+ }
90+
91+ // Test that indices are built
92+ if len(cache.trigramIndex) == 0 {
93+ t.Error("Trigram index should not be empty")
94+ }
95+
96+ if cache.prefixTrie == nil || cache.prefixTrie.Root == nil {
97+ t.Error("Prefix trie should be initialized")
98+ }
99+}
100+
101+func TestFuzzySearcher_JaroWinklerSimilarity(t *testing.T) {
102+ config := DefaultCacheConfig()
103+ searcher := NewFuzzySearcher(config)
104+
105+ tests := []struct {
106+ name string
107+ s1 string
108+ s2 string
109+ expected float64
110+ delta float64
111+ }{
112+ {"identical strings", "test", "test", 1.0, 0.0},
113+ {"empty strings", "", "", 1.0, 0.0},
114+ {"one empty", "", "test", 0.0, 0.0},
115+ {"similar strings", "github", "gihub", 0.88, 0.1},
116+ {"different strings", "github", "stackoverflow", 0.41, 0.05},
117+ {"prefix match", "programming", "program", 0.9, 0.1},
118+ }
119+
120+ for _, tt := range tests {
121+ t.Run(tt.name, func(t *testing.T) {
122+ result := searcher.jaroWinklerSimilarity(tt.s1, tt.s2)
123+
124+ if result < 0.0 || result > 1.0 {
125+ t.Errorf("Score %f should be between 0.0 and 1.0", result)
126+ }
127+
128+ diff := result - tt.expected
129+ if diff < 0 {
130+ diff = -diff
131+ }
132+ if diff > tt.delta {
133+ t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
134+ }
135+ })
136+ }
137+}
138+
139+func TestFuzzySearcher_SubstringSimilarity(t *testing.T) {
140+ config := DefaultCacheConfig()
141+ searcher := NewFuzzySearcher(config)
142+
143+ tests := []struct {
144+ name string
145+ query string
146+ text string
147+ expected float64
148+ delta float64
149+ }{
150+ {"exact match", "github", "github", 1.0, 0.0},
151+ {"substring at start", "git", "github.com", 0.45, 0.2},
152+ {"substring at end", "com", "github.com", 0.3, 0.1},
153+ {"no match", "xyz", "github.com", 0.0, 0.0},
154+ {"empty query", "", "github.com", 0.0, 0.0},
155+ {"empty text", "git", "", 0.0, 0.0},
156+ }
157+
158+ for _, tt := range tests {
159+ t.Run(tt.name, func(t *testing.T) {
160+ result := searcher.substringSimilarity(tt.query, tt.text)
161+
162+ if result < 0.0 || result > 1.0 {
163+ t.Errorf("Score %f should be between 0.0 and 1.0", result)
164+ }
165+
166+ diff := result - tt.expected
167+ if diff < 0 {
168+ diff = -diff
169+ }
170+ if diff > tt.delta {
171+ t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
172+ }
173+ })
174+ }
175+}
176+
177+func TestFuzzySearcher_TokenizedSimilarity(t *testing.T) {
178+ config := DefaultCacheConfig()
179+ searcher := NewFuzzySearcher(config)
180+
181+ tests := []struct {
182+ name string
183+ query string
184+ text string
185+ expected float64
186+ delta float64
187+ }{
188+ {"exact token match", "go programming", "The Go Programming Language", 0.8, 0.2},
189+ {"partial token match", "stack overflow", "Stack Overflow Questions", 0.8, 0.2},
190+ {"no token match", "python django", "JavaScript React Framework", 0.0, 0.1},
191+ {"single token", "github", "GitHub Repository", 0.78, 0.05},
192+ }
193+
194+ for _, tt := range tests {
195+ t.Run(tt.name, func(t *testing.T) {
196+ result := searcher.tokenizedSimilarity(tt.query, tt.text)
197+
198+ if result < 0.0 || result > 1.0 {
199+ t.Errorf("Score %f should be between 0.0 and 1.0", result)
200+ }
201+
202+ diff := result - tt.expected
203+ if diff < 0 {
204+ diff = -diff
205+ }
206+ if diff > tt.delta {
207+ t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
208+ }
209+ })
210+ }
211+}
212+
213+func TestFuzzySearcher_RecencyScore(t *testing.T) {
214+ config := DefaultCacheConfig()
215+ searcher := NewFuzzySearcher(config)
216+
217+ now := time.Now()
218+ tests := []struct {
219+ name string
220+ lastVisitDays uint32
221+ expected float64
222+ delta float64
223+ }{
224+ {"recent visit", DaysFromTime(now.Add(-1 * time.Hour)), 0.95, 0.1},
225+ {"one day ago", DaysFromTime(now.Add(-24 * time.Hour)), 0.88, 0.1},
226+ {"one week ago", DaysFromTime(now.Add(-7 * 24 * time.Hour)), 0.7, 0.1},
227+ {"one month ago", DaysFromTime(now.Add(-30 * 24 * time.Hour)), 0.37, 0.1},
228+ {"very old", DaysFromTime(now.Add(-365 * 24 * time.Hour)), 0.01, 0.01},
229+ }
230+
231+ for _, tt := range tests {
232+ t.Run(tt.name, func(t *testing.T) {
233+ result := searcher.calculateRecencyScore(tt.lastVisitDays)
234+
235+ if result < 0.0 || result > 1.0 {
236+ t.Errorf("Score %f should be between 0.0 and 1.0", result)
237+ }
238+
239+ diff := result - tt.expected
240+ if diff < 0 {
241+ diff = -diff
242+ }
243+ if diff > tt.delta {
244+ t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
245+ }
246+ })
247+ }
248+}
249+
250+func TestFuzzySearcher_VisitScore(t *testing.T) {
251+ config := DefaultCacheConfig()
252+ searcher := NewFuzzySearcher(config)
253+
254+ tests := []struct {
255+ name string
256+ visitCount uint16
257+ expected float64
258+ delta float64
259+ }{
260+ {"zero visits", 0, 0.0, 0.0},
261+ {"single visit", 1, 0.1, 0.05},
262+ {"moderate visits", 10, 0.35, 0.1},
263+ {"many visits", 100, 0.67, 0.1},
264+ {"max visits", 1000, 1.0, 0.01},
265+ }
266+
267+ for _, tt := range tests {
268+ t.Run(tt.name, func(t *testing.T) {
269+ result := searcher.calculateVisitScore(tt.visitCount)
270+
271+ if result < 0.0 || result > 1.0 {
272+ t.Errorf("Score %f should be between 0.0 and 1.0", result)
273+ }
274+
275+ diff := result - tt.expected
276+ if diff < 0 {
277+ diff = -diff
278+ }
279+ if diff > tt.delta {
280+ t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
281+ }
282+ })
283+ }
284+}
285+
286+func TestTextNormalization(t *testing.T) {
287+ tests := []struct {
288+ name string
289+ input string
290+ expected string
291+ }{
292+ {"basic url", "https://github.com", "github com"},
293+ {"with www", "https://www.google.com", "google com"},
294+ {"with path", "https://stackoverflow.com/questions/123", "stackoverflow com questions 123"},
295+ {"mixed case", "GitHub.COM/User", "github com user"},
296+ {"with special chars", "test-site_name.com", "test site name com"},
297+ {"unicode chars", "café-münü.com", "café münü com"},
298+ }
299+
300+ for _, tt := range tests {
301+ t.Run(tt.name, func(t *testing.T) {
302+ result := normalizeText(tt.input)
303+ if result != tt.expected {
304+ t.Errorf("Expected %q, got %q", tt.expected, result)
305+ }
306+ })
307+ }
308+}
309+
310+func TestTrigramExtraction(t *testing.T) {
311+ tests := []struct {
312+ name string
313+ input string
314+ expected []string
315+ }{
316+ {"short string", "ab", nil},
317+ {"exact length", "abc", []string{"abc"}},
318+ {"longer string", "github", []string{"git", "ith", "thu", "hub"}},
319+ {"with spaces", "go lang", []string{"go ", "o l", " la", "lan", "ang"}},
320+ }
321+
322+ for _, tt := range tests {
323+ t.Run(tt.name, func(t *testing.T) {
324+ result := extractTrigrams(tt.input)
325+ if len(result) != len(tt.expected) {
326+ t.Errorf("Expected %d trigrams, got %d", len(tt.expected), len(result))
327+ return
328+ }
329+ for i, expected := range tt.expected {
330+ if result[i] != expected {
331+ t.Errorf("Expected trigram %q at index %d, got %q", expected, i, result[i])
332+ }
333+ }
334+ })
335+ }
336+}
337+
338+func TestCompactEntry_BaseScore(t *testing.T) {
339+ now := time.Now()
340+
341+ tests := []struct {
342+ name string
343+ visitCount uint16
344+ lastVisit time.Time
345+ expectRange [2]uint16 // min, max expected score
346+ }{
347+ {"high visits, recent", 100, now.Add(-1 * time.Hour), [2]uint16{44000, 65535}},
348+ {"low visits, recent", 5, now.Add(-1 * time.Hour), [2]uint16{35000, 50000}},
349+ {"high visits, old", 100, now.Add(-90 * 24 * time.Hour), [2]uint16{5000, 25000}},
350+ {"low visits, old", 5, now.Add(-90 * 24 * time.Hour), [2]uint16{1000, 15000}},
351+ }
352+
353+ for _, tt := range tests {
354+ t.Run(tt.name, func(t *testing.T) {
355+ entry := CompactEntry{
356+ VisitCount: tt.visitCount,
357+ LastVisit: DaysFromTime(tt.lastVisit),
358+ }
359+
360+ score := entry.calculateBaseScore()
361+
362+ if score < tt.expectRange[0] || score > tt.expectRange[1] {
363+ t.Errorf("Expected score between %d and %d, got %d",
364+ tt.expectRange[0], tt.expectRange[1], score)
365+ }
366+ })
367+ }
368+}
369+
370+func TestFuzzySearch_Integration(t *testing.T) {
371+ ctrl := gomock.NewController(t)
372+ defer ctrl.Finish()
373+
374+ mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
375+ history := createTestHistory()
376+
377+ mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(history, nil).AnyTimes()
378+
379+ tempDir, err := os.MkdirTemp("", "dumber_search_test")
380+ if err != nil {
381+ t.Fatalf("Failed to create temp dir: %v", err)
382+ }
383+ defer os.RemoveAll(tempDir)
384+
385+ config := DefaultCacheConfig()
386+ config.CacheFile = filepath.Join(tempDir, "search_cache.bin")
387+ config.ScoreThreshold = 0.1
388+
389+ manager := NewCacheManager(mockQuerier, config)
390+ ctx := context.Background()
391+
392+ // Critical fuzzy search tests
393+ searchTests := []struct {
394+ name string
395+ query string
396+ expectCount int
397+ expectFirst string
398+ expectMatchType MatchType
399+ }{
400+ {
401+ name: "exact domain match",
402+ query: "github",
403+ expectCount: 1,
404+ expectFirst: "https://github.com",
405+ expectMatchType: MatchTypeExact,
406+ },
407+ {
408+ name: "partial title match",
409+ query: "programming",
410+ expectCount: 1,
411+ expectFirst: "https://golang.org",
412+ expectMatchType: MatchTypeExact,
413+ },
414+ {
415+ name: "fuzzy typo match",
416+ query: "githb", // typo in github
417+ expectCount: 1,
418+ expectFirst: "https://github.com",
419+ expectMatchType: MatchTypeFuzzy,
420+ },
421+ {
422+ name: "prefix match",
423+ query: "you",
424+ expectCount: 1,
425+ expectFirst: "https://youtube.com",
426+ expectMatchType: MatchTypePrefix,
427+ },
428+ {
429+ name: "no matches",
430+ query: "nonexistentsite12345",
431+ expectCount: 0,
432+ },
433+ }
434+
435+ for _, tt := range searchTests {
436+ t.Run(tt.name, func(t *testing.T) {
437+ result, err := manager.Search(ctx, tt.query)
438+ if err != nil {
439+ t.Fatalf("Search failed: %v", err)
440+ }
441+
442+ if len(result.Matches) < tt.expectCount {
443+ t.Errorf("Expected at least %d matches, got %d", tt.expectCount, len(result.Matches))
444+ return
445+ }
446+
447+ if tt.expectCount == 0 {
448+ return // No further checks needed
449+ }
450+
451+ // Check first result
452+ if result.Matches[0].Entry.URL != tt.expectFirst {
453+ t.Errorf("Expected first result %q, got %q",
454+ tt.expectFirst, result.Matches[0].Entry.URL)
455+ }
456+
457+ // Verify results are sorted by score
458+ for i := 1; i < len(result.Matches); i++ {
459+ if result.Matches[i].Score > result.Matches[i-1].Score {
460+ t.Errorf("Results not sorted by score: %f > %f at position %d",
461+ result.Matches[i].Score, result.Matches[i-1].Score, i)
462+ }
463+ }
464+
465+ // Verify all scores meet threshold
466+ for i, match := range result.Matches {
467+ if match.Score < config.ScoreThreshold {
468+ t.Errorf("Match %d score %f below threshold %f",
469+ i, match.Score, config.ScoreThreshold)
470+ }
471+ }
472+
473+ // Verify query time is recorded
474+ if result.QueryTime <= 0 {
475+ t.Error("Query time should be positive")
476+ }
477+ })
478+ }
479+}
480+
481+func TestCacheInvalidation(t *testing.T) {
482+ ctrl := gomock.NewController(t)
483+ defer ctrl.Finish()
484+
485+ mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
486+
487+ // First call returns initial history
488+ firstHistory := createTestHistory()[:3] // Only 3 entries
489+ secondHistory := createTestHistory() // All 5 entries
490+
491+ // Set up multiple expectations for the different calls
492+ gomock.InOrder(
493+ mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(firstHistory, nil).Times(2), // Build + hash
494+ mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(secondHistory, nil).AnyTimes(), // Subsequent calls
495+ )
496+
497+ tempDir, err := os.MkdirTemp("", "dumber_invalidation_test")
498+ if err != nil {
499+ t.Fatalf("Failed to create temp dir: %v", err)
500+ }
501+ defer os.RemoveAll(tempDir)
502+
503+ config := DefaultCacheConfig()
504+ config.CacheFile = filepath.Join(tempDir, "invalidation_cache.bin")
505+
506+ manager := NewCacheManager(mockQuerier, config)
507+ ctx := context.Background()
508+
509+ // Initial cache build
510+ cache1, err := manager.GetCache(ctx)
511+ if err != nil {
512+ t.Fatalf("Failed to get initial cache: %v", err)
513+ }
514+
515+ if cache1.entryCount != 3 {
516+ t.Errorf("Expected 3 entries in initial cache, got %d", cache1.entryCount)
517+ }
518+
519+ // Invalidate and refresh
520+ manager.InvalidateAndRefresh(ctx)
521+
522+ // Wait for background refresh
523+ time.Sleep(100 * time.Millisecond)
524+
525+ // Get updated cache
526+ cache2, err := manager.GetCache(ctx)
527+ if err != nil {
528+ t.Fatalf("Failed to get updated cache: %v", err)
529+ }
530+
531+ if cache2.entryCount != 5 {
532+ t.Errorf("Expected 5 entries in updated cache, got %d", cache2.entryCount)
533+ }
534+}
535+
536+// create40TestEntries creates a realistic dataset with 40 entries for benchmarking
537+func create40TestEntries() []db.History {
538+ now := time.Now()
539+ entries := make([]db.History, 40)
540+
541+ urls := []string{
542+ "https://github.com", "https://stackoverflow.com", "https://golang.org", "https://news.ycombinator.com",
543+ "https://youtube.com", "https://google.com", "https://reddit.com", "https://twitter.com",
544+ "https://linkedin.com", "https://medium.com", "https://dev.to", "https://docs.python.org",
545+ "https://reactjs.org", "https://nodejs.org", "https://kubernetes.io", "https://docker.com",
546+ "https://aws.amazon.com", "https://cloud.google.com", "https://azure.microsoft.com", "https://heroku.com",
547+ "https://netlify.com", "https://vercel.com", "https://stripe.com", "https://shopify.com",
548+ "https://gitlab.com", "https://bitbucket.org", "https://atlassian.com", "https://slack.com",
549+ "https://discord.com", "https://telegram.org", "https://whatsapp.com", "https://facebook.com",
550+ "https://instagram.com", "https://tiktok.com", "https://netflix.com", "https://spotify.com",
551+ "https://apple.com", "https://microsoft.com", "https://amazon.com", "https://wikipedia.org",
552+ }
553+
554+ titles := []string{
555+ "GitHub", "Stack Overflow", "Go Programming Language", "Hacker News",
556+ "YouTube", "Google", "Reddit", "Twitter",
557+ "LinkedIn", "Medium", "Dev.to", "Python Documentation",
558+ "React", "Node.js", "Kubernetes", "Docker",
559+ "AWS", "Google Cloud", "Microsoft Azure", "Heroku",
560+ "Netlify", "Vercel", "Stripe", "Shopify",
561+ "GitLab", "Bitbucket", "Atlassian", "Slack",
562+ "Discord", "Telegram", "WhatsApp", "Facebook",
563+ "Instagram", "TikTok", "Netflix", "Spotify",
564+ "Apple", "Microsoft", "Amazon", "Wikipedia",
565+ }
566+
567+ for i := 0; i < 40; i++ {
568+ entries[i] = db.History{
569+ ID: int64(i + 1),
570+ Url: urls[i],
571+ Title: sql.NullString{String: titles[i], Valid: true},
572+ VisitCount: sql.NullInt64{Int64: int64(40 - i), Valid: true}, // Decreasing visit counts
573+ LastVisited: sql.NullTime{Time: now.Add(-time.Duration(i) * time.Hour), Valid: true},
574+ }
575+ }
576+
577+ return entries
578+}
579+
580+// BenchmarkGetTopEntries benchmarks the critical path for dmenu performance
581+func BenchmarkGetTopEntries(b *testing.B) {
582+ ctrl := gomock.NewController(b)
583+ defer ctrl.Finish()
584+
585+ mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
586+
587+ // Setup 40 test entries
588+ testHistory := create40TestEntries()
589+ mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(testHistory, nil).AnyTimes()
590+
591+ // Create temporary directory for cache file
592+ tempDir, err := os.MkdirTemp("", "cache_benchmark")
593+ if err != nil {
594+ b.Fatalf("Failed to create temp dir: %v", err)
595+ }
596+ defer os.RemoveAll(tempDir)
597+
598+ config := DefaultCacheConfig()
599+ config.CacheFile = filepath.Join(tempDir, "benchmark_cache.bin")
600+ config.MaxResults = 50
601+
602+ manager := NewCacheManager(mockQuerier, config)
603+ ctx := context.Background()
604+
605+ // Pre-build cache once (this isn't part of the benchmark)
606+ _, err = manager.GetCache(ctx)
607+ if err != nil {
608+ b.Fatalf("Failed to pre-build cache: %v", err)
609+ }
610+
611+ b.ResetTimer() // Start timing from here
612+ b.ReportAllocs()
613+
614+ // This is the critical path that needs to be under 2ms
615+ for i := 0; i < b.N; i++ {
616+ result, err := manager.GetTopEntries(ctx)
617+ if err != nil {
618+ b.Fatalf("GetTopEntries failed: %v", err)
619+ }
620+ if len(result.Matches) == 0 {
621+ b.Fatalf("Expected matches, got none")
622+ }
623+ }
624+}
625+
626+// BenchmarkGetTopEntriesParallel benchmarks concurrent access performance
627+func BenchmarkGetTopEntriesParallel(b *testing.B) {
628+ ctrl := gomock.NewController(b)
629+ defer ctrl.Finish()
630+
631+ mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
632+
633+ // Setup 40 test entries
634+ testHistory := create40TestEntries()
635+ mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(testHistory, nil).AnyTimes()
636+
637+ // Create temporary directory for cache file
638+ tempDir, err := os.MkdirTemp("", "cache_benchmark_parallel")
639+ if err != nil {
640+ b.Fatalf("Failed to create temp dir: %v", err)
641+ }
642+ defer os.RemoveAll(tempDir)
643+
644+ config := DefaultCacheConfig()
645+ config.CacheFile = filepath.Join(tempDir, "benchmark_parallel_cache.bin")
646+ config.MaxResults = 50
647+
648+ manager := NewCacheManager(mockQuerier, config)
649+ ctx := context.Background()
650+
651+ // Pre-build cache once (this isn't part of the benchmark)
652+ _, err = manager.GetCache(ctx)
653+ if err != nil {
654+ b.Fatalf("Failed to pre-build cache: %v", err)
655+ }
656+
657+ b.ResetTimer()
658+ b.ReportAllocs()
659+
660+ b.RunParallel(func(pb *testing.PB) {
661+ for pb.Next() {
662+ result, err := manager.GetTopEntries(ctx)
663+ if err != nil {
664+ b.Fatalf("GetTopEntries failed: %v", err)
665+ }
666+ if len(result.Matches) == 0 {
667+ b.Fatalf("Expected matches, got none")
668+ }
669+ }
670+ })
671+}
A internal/cache/fuzzy_search.go +391 -0
@@ -0,0 +1,391 @@
1+package cache
2+
3+import (
4+ "math"
5+ "sort"
6+ "strings"
7+ "sync"
8+ "time"
9+)
10+
11+// FuzzySearcher provides high-performance fuzzy search capabilities.
12+type FuzzySearcher struct {
13+ config *CacheConfig
14+ resultPool sync.Pool
15+}
16+
17+// NewFuzzySearcher creates a new fuzzy searcher with the given config.
18+func NewFuzzySearcher(config *CacheConfig) *FuzzySearcher {
19+ fs := &FuzzySearcher{
20+ config: config,
21+ }
22+
23+ // Initialize result pool for zero-allocation queries
24+ fs.resultPool = sync.Pool{
25+ New: func() interface{} {
26+ return &FuzzyResult{
27+ Matches: make([]FuzzyMatch, 0, config.MaxResults),
28+ }
29+ },
30+ }
31+
32+ return fs
33+}
34+
35+// Search performs a fuzzy search on the cache and returns ranked results.
36+func (c *DmenuFuzzyCache) Search(query string, config *CacheConfig) *FuzzyResult {
37+ if len(query) == 0 {
38+ return c.getTopEntries(config)
39+ }
40+
41+ startTime := time.Now()
42+
43+ // Get result object from pool
44+ searcher := NewFuzzySearcher(config)
45+ result := searcher.resultPool.Get().(*FuzzyResult)
46+ result.Reset()
47+
48+ c.mu.RLock()
49+ defer c.mu.RUnlock()
50+
51+ // Phase 1: Fast candidate selection using indices
52+ candidates := c.getAllCandidates(query)
53+
54+ // Phase 2: Score candidates using fuzzy algorithms
55+ matches := make([]FuzzyMatch, 0, len(candidates))
56+
57+ if len(candidates) == 0 {
58+ // Fallback: search all entries (should be rare)
59+ candidates = make([]uint32, len(c.entries))
60+ for i := range c.entries {
61+ candidates[i] = uint32(i)
62+ }
63+ }
64+
65+ // Limit candidates to avoid excessive computation
66+ if len(candidates) > 1000 {
67+ candidates = candidates[:1000]
68+ }
69+
70+ for _, entryID := range candidates {
71+ if int(entryID) >= len(c.entries) {
72+ continue
73+ }
74+
75+ match := searcher.scoreEntry(query, &c.entries[entryID])
76+ if match.Score >= config.ScoreThreshold {
77+ matches = append(matches, match)
78+ }
79+ }
80+
81+ // Phase 3: Sort by score and apply limits
82+ sort.Slice(matches, func(i, j int) bool {
83+ return matches[i].Score > matches[j].Score
84+ })
85+
86+ if len(matches) > config.MaxResults {
87+ matches = matches[:config.MaxResults]
88+ }
89+
90+ result.Matches = matches
91+ result.QueryTime = time.Since(startTime)
92+
93+ return result
94+}
95+
96+// getTopEntries returns the top entries when no query is provided.
97+func (c *DmenuFuzzyCache) getTopEntries(config *CacheConfig) *FuzzyResult {
98+ c.mu.RLock()
99+ defer c.mu.RUnlock()
100+
101+ startTime := time.Now()
102+
103+ // Use pre-sorted index for O(1) access to top entries
104+ maxResults := config.MaxResults
105+ if maxResults > len(c.sortedIndex) {
106+ maxResults = len(c.sortedIndex)
107+ }
108+
109+ matches := make([]FuzzyMatch, maxResults)
110+ for i := 0; i < maxResults; i++ {
111+ entryID := c.sortedIndex[i]
112+ matches[i] = FuzzyMatch{
113+ Entry: &c.entries[entryID],
114+ Score: float64(c.entries[entryID].Score) / 65535.0, // Normalize to 0-1
115+ RecencyScore: 1.0, // No query, so full recency weight
116+ VisitScore: 1.0, // No query, so full visit weight
117+ MatchType: MatchTypeExact,
118+ }
119+ }
120+
121+ return &FuzzyResult{
122+ Matches: matches,
123+ QueryTime: time.Since(startTime),
124+ }
125+}
126+
127+// scoreEntry calculates the fuzzy match score for a single entry.
128+func (fs *FuzzySearcher) scoreEntry(query string, entry *CompactEntry) FuzzyMatch {
129+ match := FuzzyMatch{
130+ Entry: entry,
131+ }
132+
133+ queryNorm := normalizeText(query)
134+ urlNorm := normalizeText(entry.URL)
135+ titleNorm := normalizeText(entry.Title)
136+
137+ // Calculate different types of similarity
138+ match.URLScore = fs.calculateTextSimilarity(queryNorm, urlNorm)
139+ match.TitleScore = fs.calculateTextSimilarity(queryNorm, titleNorm)
140+
141+ // Boost exact matches
142+ if strings.Contains(urlNorm, queryNorm) {
143+ match.URLScore = math.Max(match.URLScore, 0.95)
144+ match.MatchType = MatchTypeExact
145+ }
146+ if strings.Contains(titleNorm, queryNorm) {
147+ match.TitleScore = math.Max(match.TitleScore, 0.95)
148+ match.MatchType = MatchTypeExact
149+ }
150+
151+ // Boost prefix matches
152+ if strings.HasPrefix(urlNorm, queryNorm) {
153+ match.URLScore = math.Max(match.URLScore, 0.9)
154+ if match.MatchType != MatchTypeExact {
155+ match.MatchType = MatchTypePrefix
156+ }
157+ }
158+ if strings.HasPrefix(titleNorm, queryNorm) {
159+ match.TitleScore = math.Max(match.TitleScore, 0.9)
160+ if match.MatchType != MatchTypeExact {
161+ match.MatchType = MatchTypePrefix
162+ }
163+ }
164+
165+ // Calculate recency and visit scores
166+ match.RecencyScore = fs.calculateRecencyScore(entry.LastVisit)
167+ match.VisitScore = fs.calculateVisitScore(entry.VisitCount)
168+
169+ // Determine final match type if not already set
170+ if match.MatchType == 0 {
171+ if match.URLScore > 0.7 || match.TitleScore > 0.7 {
172+ match.MatchType = MatchTypeFuzzy
173+ } else {
174+ match.MatchType = MatchTypeTrigram
175+ }
176+ }
177+
178+ // Calculate weighted final score
179+ match.Score = fs.config.URLWeight*match.URLScore +
180+ fs.config.TitleWeight*match.TitleScore +
181+ fs.config.RecencyWeight*match.RecencyScore +
182+ fs.config.VisitWeight*match.VisitScore
183+
184+ return match
185+}
186+
187+// calculateTextSimilarity computes similarity between query and text using multiple algorithms.
188+func (fs *FuzzySearcher) calculateTextSimilarity(query, text string) float64 {
189+ if query == text {
190+ return 1.0
191+ }
192+
193+ if len(query) == 0 || len(text) == 0 {
194+ return 0.0
195+ }
196+
197+ // Use different algorithms based on text length for optimal performance
198+ if len(query) <= 3 {
199+ // For short queries, use substring matching
200+ return fs.substringSimilarity(query, text)
201+ } else if len(query) <= 10 {
202+ // For medium queries, use Jaro-Winkler
203+ return fs.jaroWinklerSimilarity(query, text)
204+ } else {
205+ // For long queries, use tokenized matching
206+ return fs.tokenizedSimilarity(query, text)
207+ }
208+}
209+
210+// substringSimilarity calculates similarity based on substring matches with position weighting.
211+func (fs *FuzzySearcher) substringSimilarity(query, text string) float64 {
212+ index := strings.Index(text, query)
213+ if index == -1 {
214+ return 0.0
215+ }
216+
217+ // Base score
218+ baseScore := float64(len(query)) / float64(len(text))
219+
220+ // Position bonus (earlier matches are better)
221+ positionBonus := 1.0
222+ if index == 0 {
223+ positionBonus = 1.5 // Start of string
224+ } else if index < len(text)/3 {
225+ positionBonus = 1.2 // First third
226+ }
227+
228+ score := baseScore * positionBonus
229+ if score > 1.0 {
230+ score = 1.0
231+ }
232+
233+ return score
234+}
235+
236+// jaroWinklerSimilarity calculates Jaro-Winkler similarity (optimized version).
237+func (fs *FuzzySearcher) jaroWinklerSimilarity(s1, s2 string) float64 {
238+ if s1 == s2 {
239+ return 1.0
240+ }
241+
242+ len1, len2 := len(s1), len(s2)
243+ if len1 == 0 || len2 == 0 {
244+ return 0.0
245+ }
246+
247+ // Calculate match window
248+ matchWindow := (max(len1, len2) / 2) - 1
249+ if matchWindow < 0 {
250+ matchWindow = 0
251+ }
252+
253+ matches1 := make([]bool, len1)
254+ matches2 := make([]bool, len2)
255+ matches := 0
256+
257+ // Find matches
258+ for i := 0; i < len1; i++ {
259+ start := max(0, i-matchWindow)
260+ end := min(i+matchWindow+1, len2)
261+
262+ for j := start; j < end; j++ {
263+ if matches2[j] || s1[i] != s2[j] {
264+ continue
265+ }
266+ matches1[i] = true
267+ matches2[j] = true
268+ matches++
269+ break
270+ }
271+ }
272+
273+ if matches == 0 {
274+ return 0.0
275+ }
276+
277+ // Count transpositions
278+ transpositions := 0
279+ k := 0
280+ for i := 0; i < len1; i++ {
281+ if !matches1[i] {
282+ continue
283+ }
284+ for !matches2[k] {
285+ k++
286+ }
287+ if s1[i] != s2[k] {
288+ transpositions++
289+ }
290+ k++
291+ }
292+
293+ // Calculate Jaro similarity
294+ jaro := (float64(matches)/float64(len1) +
295+ float64(matches)/float64(len2) +
296+ float64(matches-transpositions/2)/float64(matches)) / 3.0
297+
298+ // Jaro-Winkler modification
299+ if jaro < 0.7 {
300+ return jaro
301+ }
302+
303+ // Common prefix (up to 4 characters)
304+ prefix := 0
305+ for i := 0; i < min(min(len1, len2), 4); i++ {
306+ if s1[i] == s2[i] {
307+ prefix++
308+ } else {
309+ break
310+ }
311+ }
312+
313+ return jaro + (0.1 * float64(prefix) * (1 - jaro))
314+}
315+
316+// tokenizedSimilarity performs token-based matching for longer texts.
317+func (fs *FuzzySearcher) tokenizedSimilarity(query, text string) float64 {
318+ queryTokens := strings.Fields(query)
319+ textTokens := strings.Fields(text)
320+
321+ if len(queryTokens) == 0 || len(textTokens) == 0 {
322+ return 0.0
323+ }
324+
325+ totalScore := 0.0
326+ matchedTokens := 0
327+
328+ for _, queryToken := range queryTokens {
329+ bestScore := 0.0
330+ for _, textToken := range textTokens {
331+ score := fs.jaroWinklerSimilarity(queryToken, textToken)
332+ if score > bestScore {
333+ bestScore = score
334+ }
335+ }
336+ if bestScore >= 0.6 { // Token match threshold
337+ totalScore += bestScore
338+ matchedTokens++
339+ }
340+ }
341+
342+ if matchedTokens == 0 {
343+ return 0.0
344+ }
345+
346+ // Average score weighted by matched token ratio
347+ avgScore := totalScore / float64(matchedTokens)
348+ tokenRatio := float64(matchedTokens) / float64(len(queryTokens))
349+
350+ return avgScore * tokenRatio
351+}
352+
353+// calculateRecencyScore calculates score based on how recently the entry was visited.
354+func (fs *FuzzySearcher) calculateRecencyScore(lastVisitDays uint32) float64 {
355+ now := time.Now()
356+ lastVisit := TimeFromDays(lastVisitDays)
357+ daysSince := now.Sub(lastVisit).Hours() / 24
358+
359+ // Exponential decay over 30 days
360+ return math.Exp(-daysSince / 30.0)
361+}
362+
363+// calculateVisitScore calculates score based on visit count with logarithmic scaling.
364+func (fs *FuzzySearcher) calculateVisitScore(visitCount uint16) float64 {
365+ if visitCount == 0 {
366+ return 0.0
367+ }
368+
369+ // Logarithmic scaling to prevent highly visited sites from dominating
370+ score := math.Log1p(float64(visitCount)) / math.Log1p(1000.0)
371+ if score > 1.0 {
372+ score = 1.0
373+ }
374+
375+ return score
376+}
377+
378+// Helper functions
379+func max(a, b int) int {
380+ if a > b {
381+ return a
382+ }
383+ return b
384+}
385+
386+func min(a, b int) int {
387+ if a < b {
388+ return a
389+ }
390+ return b
391+}
@@ -0,0 +1,391 @@
1+package cache
2+
3+import (
4+ "math"
5+ "sort"
6+ "strings"
7+ "sync"
8+ "time"
9+)
10+
11+// FuzzySearcher provides high-performance fuzzy search capabilities.
12+type FuzzySearcher struct {
13+ config *CacheConfig
14+ resultPool sync.Pool
15+}
16+
17+// NewFuzzySearcher creates a new fuzzy searcher with the given config.
18+func NewFuzzySearcher(config *CacheConfig) *FuzzySearcher {
19+ fs := &FuzzySearcher{
20+ config: config,
21+ }
22+
23+ // Initialize result pool for zero-allocation queries
24+ fs.resultPool = sync.Pool{
25+ New: func() interface{} {
26+ return &FuzzyResult{
27+ Matches: make([]FuzzyMatch, 0, config.MaxResults),
28+ }
29+ },
30+ }
31+
32+ return fs
33+}
34+
35+// Search performs a fuzzy search on the cache and returns ranked results.
36+func (c *DmenuFuzzyCache) Search(query string, config *CacheConfig) *FuzzyResult {
37+ if len(query) == 0 {
38+ return c.getTopEntries(config)
39+ }
40+
41+ startTime := time.Now()
42+
43+ // Get result object from pool
44+ searcher := NewFuzzySearcher(config)
45+ result := searcher.resultPool.Get().(*FuzzyResult)
46+ result.Reset()
47+
48+ c.mu.RLock()
49+ defer c.mu.RUnlock()
50+
51+ // Phase 1: Fast candidate selection using indices
52+ candidates := c.getAllCandidates(query)
53+
54+ // Phase 2: Score candidates using fuzzy algorithms
55+ matches := make([]FuzzyMatch, 0, len(candidates))
56+
57+ if len(candidates) == 0 {
58+ // Fallback: search all entries (should be rare)
59+ candidates = make([]uint32, len(c.entries))
60+ for i := range c.entries {
61+ candidates[i] = uint32(i)
62+ }
63+ }
64+
65+ // Limit candidates to avoid excessive computation
66+ if len(candidates) > 1000 {
67+ candidates = candidates[:1000]
68+ }
69+
70+ for _, entryID := range candidates {
71+ if int(entryID) >= len(c.entries) {
72+ continue
73+ }
74+
75+ match := searcher.scoreEntry(query, &c.entries[entryID])
76+ if match.Score >= config.ScoreThreshold {
77+ matches = append(matches, match)
78+ }
79+ }
80+
81+ // Phase 3: Sort by score and apply limits
82+ sort.Slice(matches, func(i, j int) bool {
83+ return matches[i].Score > matches[j].Score
84+ })
85+
86+ if len(matches) > config.MaxResults {
87+ matches = matches[:config.MaxResults]
88+ }
89+
90+ result.Matches = matches
91+ result.QueryTime = time.Since(startTime)
92+
93+ return result
94+}
95+
96+// getTopEntries returns the top entries when no query is provided.
97+func (c *DmenuFuzzyCache) getTopEntries(config *CacheConfig) *FuzzyResult {
98+ c.mu.RLock()
99+ defer c.mu.RUnlock()
100+
101+ startTime := time.Now()
102+
103+ // Use pre-sorted index for O(1) access to top entries
104+ maxResults := config.MaxResults
105+ if maxResults > len(c.sortedIndex) {
106+ maxResults = len(c.sortedIndex)
107+ }
108+
109+ matches := make([]FuzzyMatch, maxResults)
110+ for i := 0; i < maxResults; i++ {
111+ entryID := c.sortedIndex[i]
112+ matches[i] = FuzzyMatch{
113+ Entry: &c.entries[entryID],
114+ Score: float64(c.entries[entryID].Score) / 65535.0, // Normalize to 0-1
115+ RecencyScore: 1.0, // No query, so full recency weight
116+ VisitScore: 1.0, // No query, so full visit weight
117+ MatchType: MatchTypeExact,
118+ }
119+ }
120+
121+ return &FuzzyResult{
122+ Matches: matches,
123+ QueryTime: time.Since(startTime),
124+ }
125+}
126+
127+// scoreEntry calculates the fuzzy match score for a single entry.
128+func (fs *FuzzySearcher) scoreEntry(query string, entry *CompactEntry) FuzzyMatch {
129+ match := FuzzyMatch{
130+ Entry: entry,
131+ }
132+
133+ queryNorm := normalizeText(query)
134+ urlNorm := normalizeText(entry.URL)
135+ titleNorm := normalizeText(entry.Title)
136+
137+ // Calculate different types of similarity
138+ match.URLScore = fs.calculateTextSimilarity(queryNorm, urlNorm)
139+ match.TitleScore = fs.calculateTextSimilarity(queryNorm, titleNorm)
140+
141+ // Boost exact matches
142+ if strings.Contains(urlNorm, queryNorm) {
143+ match.URLScore = math.Max(match.URLScore, 0.95)
144+ match.MatchType = MatchTypeExact
145+ }
146+ if strings.Contains(titleNorm, queryNorm) {
147+ match.TitleScore = math.Max(match.TitleScore, 0.95)
148+ match.MatchType = MatchTypeExact
149+ }
150+
151+ // Boost prefix matches
152+ if strings.HasPrefix(urlNorm, queryNorm) {
153+ match.URLScore = math.Max(match.URLScore, 0.9)
154+ if match.MatchType != MatchTypeExact {
155+ match.MatchType = MatchTypePrefix
156+ }
157+ }
158+ if strings.HasPrefix(titleNorm, queryNorm) {
159+ match.TitleScore = math.Max(match.TitleScore, 0.9)
160+ if match.MatchType != MatchTypeExact {
161+ match.MatchType = MatchTypePrefix
162+ }
163+ }
164+
165+ // Calculate recency and visit scores
166+ match.RecencyScore = fs.calculateRecencyScore(entry.LastVisit)
167+ match.VisitScore = fs.calculateVisitScore(entry.VisitCount)
168+
169+ // Determine final match type if not already set
170+ if match.MatchType == 0 {
171+ if match.URLScore > 0.7 || match.TitleScore > 0.7 {
172+ match.MatchType = MatchTypeFuzzy
173+ } else {
174+ match.MatchType = MatchTypeTrigram
175+ }
176+ }
177+
178+ // Calculate weighted final score
179+ match.Score = fs.config.URLWeight*match.URLScore +
180+ fs.config.TitleWeight*match.TitleScore +
181+ fs.config.RecencyWeight*match.RecencyScore +
182+ fs.config.VisitWeight*match.VisitScore
183+
184+ return match
185+}
186+
187+// calculateTextSimilarity computes similarity between query and text using multiple algorithms.
188+func (fs *FuzzySearcher) calculateTextSimilarity(query, text string) float64 {
189+ if query == text {
190+ return 1.0
191+ }
192+
193+ if len(query) == 0 || len(text) == 0 {
194+ return 0.0
195+ }
196+
197+ // Use different algorithms based on text length for optimal performance
198+ if len(query) <= 3 {
199+ // For short queries, use substring matching
200+ return fs.substringSimilarity(query, text)
201+ } else if len(query) <= 10 {
202+ // For medium queries, use Jaro-Winkler
203+ return fs.jaroWinklerSimilarity(query, text)
204+ } else {
205+ // For long queries, use tokenized matching
206+ return fs.tokenizedSimilarity(query, text)
207+ }
208+}
209+
210+// substringSimilarity calculates similarity based on substring matches with position weighting.
211+func (fs *FuzzySearcher) substringSimilarity(query, text string) float64 {
212+ index := strings.Index(text, query)
213+ if index == -1 {
214+ return 0.0
215+ }
216+
217+ // Base score
218+ baseScore := float64(len(query)) / float64(len(text))
219+
220+ // Position bonus (earlier matches are better)
221+ positionBonus := 1.0
222+ if index == 0 {
223+ positionBonus = 1.5 // Start of string
224+ } else if index < len(text)/3 {
225+ positionBonus = 1.2 // First third
226+ }
227+
228+ score := baseScore * positionBonus
229+ if score > 1.0 {
230+ score = 1.0
231+ }
232+
233+ return score
234+}
235+
236+// jaroWinklerSimilarity calculates Jaro-Winkler similarity (optimized version).
237+func (fs *FuzzySearcher) jaroWinklerSimilarity(s1, s2 string) float64 {
238+ if s1 == s2 {
239+ return 1.0
240+ }
241+
242+ len1, len2 := len(s1), len(s2)
243+ if len1 == 0 || len2 == 0 {
244+ return 0.0
245+ }
246+
247+ // Calculate match window
248+ matchWindow := (max(len1, len2) / 2) - 1
249+ if matchWindow < 0 {
250+ matchWindow = 0
251+ }
252+
253+ matches1 := make([]bool, len1)
254+ matches2 := make([]bool, len2)
255+ matches := 0
256+
257+ // Find matches
258+ for i := 0; i < len1; i++ {
259+ start := max(0, i-matchWindow)
260+ end := min(i+matchWindow+1, len2)
261+
262+ for j := start; j < end; j++ {
263+ if matches2[j] || s1[i] != s2[j] {
264+ continue
265+ }
266+ matches1[i] = true
267+ matches2[j] = true
268+ matches++
269+ break
270+ }
271+ }
272+
273+ if matches == 0 {
274+ return 0.0
275+ }
276+
277+ // Count transpositions
278+ transpositions := 0
279+ k := 0
280+ for i := 0; i < len1; i++ {
281+ if !matches1[i] {
282+ continue
283+ }
284+ for !matches2[k] {
285+ k++
286+ }
287+ if s1[i] != s2[k] {
288+ transpositions++
289+ }
290+ k++
291+ }
292+
293+ // Calculate Jaro similarity
294+ jaro := (float64(matches)/float64(len1) +
295+ float64(matches)/float64(len2) +
296+ float64(matches-transpositions/2)/float64(matches)) / 3.0
297+
298+ // Jaro-Winkler modification
299+ if jaro < 0.7 {
300+ return jaro
301+ }
302+
303+ // Common prefix (up to 4 characters)
304+ prefix := 0
305+ for i := 0; i < min(min(len1, len2), 4); i++ {
306+ if s1[i] == s2[i] {
307+ prefix++
308+ } else {
309+ break
310+ }
311+ }
312+
313+ return jaro + (0.1 * float64(prefix) * (1 - jaro))
314+}
315+
316+// tokenizedSimilarity performs token-based matching for longer texts.
317+func (fs *FuzzySearcher) tokenizedSimilarity(query, text string) float64 {
318+ queryTokens := strings.Fields(query)
319+ textTokens := strings.Fields(text)
320+
321+ if len(queryTokens) == 0 || len(textTokens) == 0 {
322+ return 0.0
323+ }
324+
325+ totalScore := 0.0
326+ matchedTokens := 0
327+
328+ for _, queryToken := range queryTokens {
329+ bestScore := 0.0
330+ for _, textToken := range textTokens {
331+ score := fs.jaroWinklerSimilarity(queryToken, textToken)
332+ if score > bestScore {
333+ bestScore = score
334+ }
335+ }
336+ if bestScore >= 0.6 { // Token match threshold
337+ totalScore += bestScore
338+ matchedTokens++
339+ }
340+ }
341+
342+ if matchedTokens == 0 {
343+ return 0.0
344+ }
345+
346+ // Average score weighted by matched token ratio
347+ avgScore := totalScore / float64(matchedTokens)
348+ tokenRatio := float64(matchedTokens) / float64(len(queryTokens))
349+
350+ return avgScore * tokenRatio
351+}
352+
353+// calculateRecencyScore calculates score based on how recently the entry was visited.
354+func (fs *FuzzySearcher) calculateRecencyScore(lastVisitDays uint32) float64 {
355+ now := time.Now()
356+ lastVisit := TimeFromDays(lastVisitDays)
357+ daysSince := now.Sub(lastVisit).Hours() / 24
358+
359+ // Exponential decay over 30 days
360+ return math.Exp(-daysSince / 30.0)
361+}
362+
363+// calculateVisitScore calculates score based on visit count with logarithmic scaling.
364+func (fs *FuzzySearcher) calculateVisitScore(visitCount uint16) float64 {
365+ if visitCount == 0 {
366+ return 0.0
367+ }
368+
369+ // Logarithmic scaling to prevent highly visited sites from dominating
370+ score := math.Log1p(float64(visitCount)) / math.Log1p(1000.0)
371+ if score > 1.0 {
372+ score = 1.0
373+ }
374+
375+ return score
376+}
377+
378+// Helper functions
379+func max(a, b int) int {
380+ if a > b {
381+ return a
382+ }
383+ return b
384+}
385+
386+func min(a, b int) int {
387+ if a < b {
388+ return a
389+ }
390+ return b
391+}
A internal/cache/interfaces.go +15 -0
@@ -0,0 +1,15 @@
1+package cache
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/db"
7+)
8+
9+//go:generate mockgen -source=interfaces.go -destination=mocks/mock_querier.go
10+
11+// HistoryQuerier defines the interface for querying history data.
12+type HistoryQuerier interface {
13+ GetHistory(ctx context.Context, limit int64) ([]db.History, error)
14+ GetShortcuts(ctx context.Context) ([]db.Shortcut, error)
15+}
@@ -0,0 +1,15 @@
1+package cache
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/db"
7+)
8+
9+//go:generate mockgen -source=interfaces.go -destination=mocks/mock_querier.go
10+
11+// HistoryQuerier defines the interface for querying history data.
12+type HistoryQuerier interface {
13+ GetHistory(ctx context.Context, limit int64) ([]db.History, error)
14+ GetShortcuts(ctx context.Context) ([]db.Shortcut, error)
15+}
A internal/cache/mocks/mock_querier.go +66 -0
@@ -0,0 +1,66 @@
1+// Code generated by MockGen. DO NOT EDIT.
2+// Source: interfaces.go
3+
4+// Package mock_cache is a generated GoMock package.
5+package mock_cache
6+
7+import (
8+ context "context"
9+ reflect "reflect"
10+
11+ db "github.com/bnema/dumber/internal/db"
12+ gomock "github.com/golang/mock/gomock"
13+)
14+
15+// MockHistoryQuerier is a mock of HistoryQuerier interface.
16+type MockHistoryQuerier struct {
17+ ctrl *gomock.Controller
18+ recorder *MockHistoryQuerierMockRecorder
19+}
20+
21+// MockHistoryQuerierMockRecorder is the mock recorder for MockHistoryQuerier.
22+type MockHistoryQuerierMockRecorder struct {
23+ mock *MockHistoryQuerier
24+}
25+
26+// NewMockHistoryQuerier creates a new mock instance.
27+func NewMockHistoryQuerier(ctrl *gomock.Controller) *MockHistoryQuerier {
28+ mock := &MockHistoryQuerier{ctrl: ctrl}
29+ mock.recorder = &MockHistoryQuerierMockRecorder{mock}
30+ return mock
31+}
32+
33+// EXPECT returns an object that allows the caller to indicate expected use.
34+func (m *MockHistoryQuerier) EXPECT() *MockHistoryQuerierMockRecorder {
35+ return m.recorder
36+}
37+
38+// GetHistory mocks base method.
39+func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) {
40+ m.ctrl.T.Helper()
41+ ret := m.ctrl.Call(m, "GetHistory", ctx, limit)
42+ ret0, _ := ret[0].([]db.History)
43+ ret1, _ := ret[1].(error)
44+ return ret0, ret1
45+}
46+
47+// GetHistory indicates an expected call of GetHistory.
48+func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit interface{}) *gomock.Call {
49+ mr.mock.ctrl.T.Helper()
50+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistory), ctx, limit)
51+}
52+
53+// GetShortcuts mocks base method.
54+func (m *MockHistoryQuerier) GetShortcuts(ctx context.Context) ([]db.Shortcut, error) {
55+ m.ctrl.T.Helper()
56+ ret := m.ctrl.Call(m, "GetShortcuts", ctx)
57+ ret0, _ := ret[0].([]db.Shortcut)
58+ ret1, _ := ret[1].(error)
59+ return ret0, ret1
60+}
61+
62+// GetShortcuts indicates an expected call of GetShortcuts.
63+func (mr *MockHistoryQuerierMockRecorder) GetShortcuts(ctx interface{}) *gomock.Call {
64+ mr.mock.ctrl.T.Helper()
65+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetShortcuts", reflect.TypeOf((*MockHistoryQuerier)(nil).GetShortcuts), ctx)
66+}
@@ -0,0 +1,66 @@
1+// Code generated by MockGen. DO NOT EDIT.
2+// Source: interfaces.go
3+
4+// Package mock_cache is a generated GoMock package.
5+package mock_cache
6+
7+import (
8+ context "context"
9+ reflect "reflect"
10+
11+ db "github.com/bnema/dumber/internal/db"
12+ gomock "github.com/golang/mock/gomock"
13+)
14+
15+// MockHistoryQuerier is a mock of HistoryQuerier interface.
16+type MockHistoryQuerier struct {
17+ ctrl *gomock.Controller
18+ recorder *MockHistoryQuerierMockRecorder
19+}
20+
21+// MockHistoryQuerierMockRecorder is the mock recorder for MockHistoryQuerier.
22+type MockHistoryQuerierMockRecorder struct {
23+ mock *MockHistoryQuerier
24+}
25+
26+// NewMockHistoryQuerier creates a new mock instance.
27+func NewMockHistoryQuerier(ctrl *gomock.Controller) *MockHistoryQuerier {
28+ mock := &MockHistoryQuerier{ctrl: ctrl}
29+ mock.recorder = &MockHistoryQuerierMockRecorder{mock}
30+ return mock
31+}
32+
33+// EXPECT returns an object that allows the caller to indicate expected use.
34+func (m *MockHistoryQuerier) EXPECT() *MockHistoryQuerierMockRecorder {
35+ return m.recorder
36+}
37+
38+// GetHistory mocks base method.
39+func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) {
40+ m.ctrl.T.Helper()
41+ ret := m.ctrl.Call(m, "GetHistory", ctx, limit)
42+ ret0, _ := ret[0].([]db.History)
43+ ret1, _ := ret[1].(error)
44+ return ret0, ret1
45+}
46+
47+// GetHistory indicates an expected call of GetHistory.
48+func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit interface{}) *gomock.Call {
49+ mr.mock.ctrl.T.Helper()
50+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistory), ctx, limit)
51+}
52+
53+// GetShortcuts mocks base method.
54+func (m *MockHistoryQuerier) GetShortcuts(ctx context.Context) ([]db.Shortcut, error) {
55+ m.ctrl.T.Helper()
56+ ret := m.ctrl.Call(m, "GetShortcuts", ctx)
57+ ret0, _ := ret[0].([]db.Shortcut)
58+ ret1, _ := ret[1].(error)
59+ return ret0, ret1
60+}
61+
62+// GetShortcuts indicates an expected call of GetShortcuts.
63+func (mr *MockHistoryQuerierMockRecorder) GetShortcuts(ctx interface{}) *gomock.Call {
64+ mr.mock.ctrl.T.Helper()
65+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetShortcuts", reflect.TypeOf((*MockHistoryQuerier)(nil).GetShortcuts), ctx)
66+}
A internal/cache/trie.go +331 -0
@@ -0,0 +1,331 @@
1+package cache
2+
3+import (
4+ "sort"
5+ "strings"
6+ "unicode"
7+)
8+
9+// NewTrie creates a new empty trie.
10+func NewTrie() *Trie {
11+ return &Trie{
12+ Root: &TrieNode{
13+ Children: make(map[rune]*TrieNode),
14+ Entries: make([]uint32, 0),
15+ },
16+ }
17+}
18+
19+// Insert adds an entry to the trie for all its prefixes.
20+func (t *Trie) Insert(text string, entryID uint32) {
21+ text = normalizeText(text)
22+
23+ // Insert all meaningful prefixes
24+ words := strings.Fields(text)
25+ for _, word := range words {
26+ if len(word) < 2 { // Skip single characters
27+ continue
28+ }
29+ t.insertWord(word, entryID)
30+
31+ // Also insert domain parts for URLs
32+ if strings.Contains(word, ".") {
33+ parts := strings.Split(word, ".")
34+ for _, part := range parts {
35+ if len(part) >= 2 {
36+ t.insertWord(part, entryID)
37+ }
38+ }
39+ }
40+ }
41+}
42+
43+// insertWord inserts a single word and its prefixes into the trie.
44+func (t *Trie) insertWord(word string, entryID uint32) {
45+ node := t.Root
46+
47+ // Insert the full word
48+ for _, char := range word {
49+ if node.Children[char] == nil {
50+ node.Children[char] = &TrieNode{
51+ Children: make(map[rune]*TrieNode),
52+ Entries: make([]uint32, 0),
53+ }
54+ }
55+ node = node.Children[char]
56+ }
57+
58+ // Add the entry ID to all nodes in the path
59+ current := t.Root
60+ for _, char := range word {
61+ current = current.Children[char]
62+ if !containsUint32(current.Entries, entryID) {
63+ current.Entries = append(current.Entries, entryID)
64+ }
65+ }
66+ node.IsEnd = true
67+}
68+
69+// Search finds all entries that match the given prefix.
70+func (t *Trie) Search(prefix string) []uint32 {
71+ if len(prefix) == 0 {
72+ return nil
73+ }
74+
75+ prefix = normalizeText(prefix)
76+ node := t.Root
77+
78+ // Navigate to the prefix node
79+ for _, char := range prefix {
80+ if node.Children[char] == nil {
81+ return nil // Prefix not found
82+ }
83+ node = node.Children[char]
84+ }
85+
86+ // Return all entries at this node
87+ result := make([]uint32, len(node.Entries))
88+ copy(result, node.Entries)
89+ return result
90+}
91+
92+// PrefixSearch finds all entries that start with any word in the query.
93+func (t *Trie) PrefixSearch(query string) []uint32 {
94+ query = normalizeText(query)
95+ words := strings.Fields(query)
96+
97+ entrySet := make(map[uint32]struct{})
98+
99+ for _, word := range words {
100+ if len(word) < 2 {
101+ continue
102+ }
103+
104+ entries := t.Search(word)
105+ for _, entryID := range entries {
106+ entrySet[entryID] = struct{}{}
107+ }
108+ }
109+
110+ // Convert set back to slice
111+ result := make([]uint32, 0, len(entrySet))
112+ for entryID := range entrySet {
113+ result = append(result, entryID)
114+ }
115+
116+ return result
117+}
118+
119+// buildTrigramIndex creates a trigram index from the cache entries.
120+func (c *DmenuFuzzyCache) buildTrigramIndex() {
121+ c.trigramIndex = make(map[string][]uint32)
122+
123+ for i, entry := range c.entries {
124+ entryID := uint32(i)
125+
126+ // Build trigrams from URL and title
127+ text := normalizeText(entry.URL + " " + entry.Title)
128+ trigrams := extractTrigrams(text)
129+
130+ for _, trigram := range trigrams {
131+ if !containsUint32(c.trigramIndex[trigram], entryID) {
132+ c.trigramIndex[trigram] = append(c.trigramIndex[trigram], entryID)
133+ }
134+ }
135+ }
136+}
137+
138+// buildPrefixTrie creates a prefix trie from the cache entries.
139+func (c *DmenuFuzzyCache) buildPrefixTrie() {
140+ c.prefixTrie = NewTrie()
141+
142+ for i, entry := range c.entries {
143+ entryID := uint32(i)
144+
145+ // Add URL and title to trie
146+ c.prefixTrie.Insert(entry.URL, entryID)
147+ if entry.Title != "" {
148+ c.prefixTrie.Insert(entry.Title, entryID)
149+ }
150+ }
151+}
152+
153+// extractTrigrams extracts all 3-character sequences from text.
154+func extractTrigrams(text string) []string {
155+ if len(text) < 3 {
156+ return nil
157+ }
158+
159+ trigrams := make([]string, 0, len(text)-2)
160+ runes := []rune(text)
161+
162+ for i := 0; i <= len(runes)-3; i++ {
163+ trigram := string(runes[i : i+3])
164+ trigrams = append(trigrams, trigram)
165+ }
166+
167+ return trigrams
168+}
169+
170+// normalizeText normalizes text for consistent matching.
171+func normalizeText(text string) string {
172+ // Convert to lowercase and remove extra whitespace
173+ text = strings.ToLower(text)
174+
175+ // Remove protocol prefixes for URLs
176+ text = strings.TrimPrefix(text, "https://")
177+ text = strings.TrimPrefix(text, "http://")
178+ text = strings.TrimPrefix(text, "www.")
179+
180+ // Replace non-alphanumeric characters with spaces
181+ var result strings.Builder
182+ result.Grow(len(text))
183+
184+ for _, r := range text {
185+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
186+ result.WriteRune(r)
187+ } else {
188+ result.WriteRune(' ')
189+ }
190+ }
191+
192+ // Normalize whitespace
193+ return strings.Join(strings.Fields(result.String()), " ")
194+}
195+
196+// containsUint32 checks if a slice contains a uint32 value.
197+func containsUint32(slice []uint32, value uint32) bool {
198+ for _, v := range slice {
199+ if v == value {
200+ return true
201+ }
202+ }
203+ return false
204+}
205+
206+// intersectUint32 finds the intersection of two uint32 slices.
207+func intersectUint32(a, b []uint32) []uint32 {
208+ if len(a) == 0 || len(b) == 0 {
209+ return nil
210+ }
211+
212+ // Use the smaller slice for the map to save memory
213+ if len(a) > len(b) {
214+ a, b = b, a
215+ }
216+
217+ // Create a set from the smaller slice
218+ set := make(map[uint32]struct{}, len(a))
219+ for _, v := range a {
220+ set[v] = struct{}{}
221+ }
222+
223+ // Find intersection
224+ result := make([]uint32, 0, len(a))
225+ for _, v := range b {
226+ if _, exists := set[v]; exists {
227+ result = append(result, v)
228+ delete(set, v) // Avoid duplicates
229+ }
230+ }
231+
232+ return result
233+}
234+
235+// unionUint32 finds the union of multiple uint32 slices.
236+func unionUint32(slices ...[]uint32) []uint32 {
237+ if len(slices) == 0 {
238+ return nil
239+ }
240+
241+ // Estimate capacity
242+ capacity := 0
243+ for _, slice := range slices {
244+ capacity += len(slice)
245+ }
246+
247+ set := make(map[uint32]struct{}, capacity)
248+ for _, slice := range slices {
249+ for _, v := range slice {
250+ set[v] = struct{}{}
251+ }
252+ }
253+
254+ result := make([]uint32, 0, len(set))
255+ for v := range set {
256+ result = append(result, v)
257+ }
258+
259+ return result
260+}
261+
262+// getTrigramCandidates finds entry candidates using trigram matching.
263+func (c *DmenuFuzzyCache) getTrigramCandidates(query string) []uint32 {
264+ query = normalizeText(query)
265+ trigrams := extractTrigrams(query)
266+
267+ if len(trigrams) == 0 {
268+ return nil
269+ }
270+
271+ // Get candidates from first trigram
272+ candidates := c.trigramIndex[trigrams[0]]
273+ if len(candidates) == 0 {
274+ return nil
275+ }
276+
277+ // Intersect with other trigrams for more accuracy
278+ for i := 1; i < len(trigrams) && len(candidates) > 0; i++ {
279+ nextCandidates := c.trigramIndex[trigrams[i]]
280+ if len(nextCandidates) == 0 {
281+ continue // Skip empty trigrams
282+ }
283+ candidates = intersectUint32(candidates, nextCandidates)
284+ }
285+
286+ return candidates
287+}
288+
289+// getPrefixCandidates finds entry candidates using prefix matching.
290+func (c *DmenuFuzzyCache) getPrefixCandidates(query string) []uint32 {
291+ if c.prefixTrie == nil {
292+ return nil
293+ }
294+
295+ return c.prefixTrie.PrefixSearch(query)
296+}
297+
298+// getAllCandidates combines trigram and prefix matching for comprehensive candidate selection.
299+func (c *DmenuFuzzyCache) getAllCandidates(query string) []uint32 {
300+ trigramCandidates := c.getTrigramCandidates(query)
301+ prefixCandidates := c.getPrefixCandidates(query)
302+
303+ if len(trigramCandidates) == 0 && len(prefixCandidates) == 0 {
304+ return nil
305+ }
306+
307+ if len(trigramCandidates) == 0 {
308+ return prefixCandidates
309+ }
310+
311+ if len(prefixCandidates) == 0 {
312+ return trigramCandidates
313+ }
314+
315+ // Combine both candidate sets
316+ return unionUint32(trigramCandidates, prefixCandidates)
317+}
318+
319+// buildSortedIndex creates a pre-sorted index of entries by score for O(1) top entries retrieval.
320+func (c *DmenuFuzzyCache) buildSortedIndex() {
321+ // Create index array
322+ c.sortedIndex = make([]uint32, len(c.entries))
323+ for i := range c.sortedIndex {
324+ c.sortedIndex[i] = uint32(i)
325+ }
326+
327+ // Sort indices by entry scores (highest first)
328+ sort.Slice(c.sortedIndex, func(i, j int) bool {
329+ return c.entries[c.sortedIndex[i]].Score > c.entries[c.sortedIndex[j]].Score
330+ })
331+}
@@ -0,0 +1,331 @@
1+package cache
2+
3+import (
4+ "sort"
5+ "strings"
6+ "unicode"
7+)
8+
9+// NewTrie creates a new empty trie.
10+func NewTrie() *Trie {
11+ return &Trie{
12+ Root: &TrieNode{
13+ Children: make(map[rune]*TrieNode),
14+ Entries: make([]uint32, 0),
15+ },
16+ }
17+}
18+
19+// Insert adds an entry to the trie for all its prefixes.
20+func (t *Trie) Insert(text string, entryID uint32) {
21+ text = normalizeText(text)
22+
23+ // Insert all meaningful prefixes
24+ words := strings.Fields(text)
25+ for _, word := range words {
26+ if len(word) < 2 { // Skip single characters
27+ continue
28+ }
29+ t.insertWord(word, entryID)
30+
31+ // Also insert domain parts for URLs
32+ if strings.Contains(word, ".") {
33+ parts := strings.Split(word, ".")
34+ for _, part := range parts {
35+ if len(part) >= 2 {
36+ t.insertWord(part, entryID)
37+ }
38+ }
39+ }
40+ }
41+}
42+
43+// insertWord inserts a single word and its prefixes into the trie.
44+func (t *Trie) insertWord(word string, entryID uint32) {
45+ node := t.Root
46+
47+ // Insert the full word
48+ for _, char := range word {
49+ if node.Children[char] == nil {
50+ node.Children[char] = &TrieNode{
51+ Children: make(map[rune]*TrieNode),
52+ Entries: make([]uint32, 0),
53+ }
54+ }
55+ node = node.Children[char]
56+ }
57+
58+ // Add the entry ID to all nodes in the path
59+ current := t.Root
60+ for _, char := range word {
61+ current = current.Children[char]
62+ if !containsUint32(current.Entries, entryID) {
63+ current.Entries = append(current.Entries, entryID)
64+ }
65+ }
66+ node.IsEnd = true
67+}
68+
69+// Search finds all entries that match the given prefix.
70+func (t *Trie) Search(prefix string) []uint32 {
71+ if len(prefix) == 0 {
72+ return nil
73+ }
74+
75+ prefix = normalizeText(prefix)
76+ node := t.Root
77+
78+ // Navigate to the prefix node
79+ for _, char := range prefix {
80+ if node.Children[char] == nil {
81+ return nil // Prefix not found
82+ }
83+ node = node.Children[char]
84+ }
85+
86+ // Return all entries at this node
87+ result := make([]uint32, len(node.Entries))
88+ copy(result, node.Entries)
89+ return result
90+}
91+
92+// PrefixSearch finds all entries that start with any word in the query.
93+func (t *Trie) PrefixSearch(query string) []uint32 {
94+ query = normalizeText(query)
95+ words := strings.Fields(query)
96+
97+ entrySet := make(map[uint32]struct{})
98+
99+ for _, word := range words {
100+ if len(word) < 2 {
101+ continue
102+ }
103+
104+ entries := t.Search(word)
105+ for _, entryID := range entries {
106+ entrySet[entryID] = struct{}{}
107+ }
108+ }
109+
110+ // Convert set back to slice
111+ result := make([]uint32, 0, len(entrySet))
112+ for entryID := range entrySet {
113+ result = append(result, entryID)
114+ }
115+
116+ return result
117+}
118+
119+// buildTrigramIndex creates a trigram index from the cache entries.
120+func (c *DmenuFuzzyCache) buildTrigramIndex() {
121+ c.trigramIndex = make(map[string][]uint32)
122+
123+ for i, entry := range c.entries {
124+ entryID := uint32(i)
125+
126+ // Build trigrams from URL and title
127+ text := normalizeText(entry.URL + " " + entry.Title)
128+ trigrams := extractTrigrams(text)
129+
130+ for _, trigram := range trigrams {
131+ if !containsUint32(c.trigramIndex[trigram], entryID) {
132+ c.trigramIndex[trigram] = append(c.trigramIndex[trigram], entryID)
133+ }
134+ }
135+ }
136+}
137+
138+// buildPrefixTrie creates a prefix trie from the cache entries.
139+func (c *DmenuFuzzyCache) buildPrefixTrie() {
140+ c.prefixTrie = NewTrie()
141+
142+ for i, entry := range c.entries {
143+ entryID := uint32(i)
144+
145+ // Add URL and title to trie
146+ c.prefixTrie.Insert(entry.URL, entryID)
147+ if entry.Title != "" {
148+ c.prefixTrie.Insert(entry.Title, entryID)
149+ }
150+ }
151+}
152+
153+// extractTrigrams extracts all 3-character sequences from text.
154+func extractTrigrams(text string) []string {
155+ if len(text) < 3 {
156+ return nil
157+ }
158+
159+ trigrams := make([]string, 0, len(text)-2)
160+ runes := []rune(text)
161+
162+ for i := 0; i <= len(runes)-3; i++ {
163+ trigram := string(runes[i : i+3])
164+ trigrams = append(trigrams, trigram)
165+ }
166+
167+ return trigrams
168+}
169+
170+// normalizeText normalizes text for consistent matching.
171+func normalizeText(text string) string {
172+ // Convert to lowercase and remove extra whitespace
173+ text = strings.ToLower(text)
174+
175+ // Remove protocol prefixes for URLs
176+ text = strings.TrimPrefix(text, "https://")
177+ text = strings.TrimPrefix(text, "http://")
178+ text = strings.TrimPrefix(text, "www.")
179+
180+ // Replace non-alphanumeric characters with spaces
181+ var result strings.Builder
182+ result.Grow(len(text))
183+
184+ for _, r := range text {
185+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
186+ result.WriteRune(r)
187+ } else {
188+ result.WriteRune(' ')
189+ }
190+ }
191+
192+ // Normalize whitespace
193+ return strings.Join(strings.Fields(result.String()), " ")
194+}
195+
196+// containsUint32 checks if a slice contains a uint32 value.
197+func containsUint32(slice []uint32, value uint32) bool {
198+ for _, v := range slice {
199+ if v == value {
200+ return true
201+ }
202+ }
203+ return false
204+}
205+
206+// intersectUint32 finds the intersection of two uint32 slices.
207+func intersectUint32(a, b []uint32) []uint32 {
208+ if len(a) == 0 || len(b) == 0 {
209+ return nil
210+ }
211+
212+ // Use the smaller slice for the map to save memory
213+ if len(a) > len(b) {
214+ a, b = b, a
215+ }
216+
217+ // Create a set from the smaller slice
218+ set := make(map[uint32]struct{}, len(a))
219+ for _, v := range a {
220+ set[v] = struct{}{}
221+ }
222+
223+ // Find intersection
224+ result := make([]uint32, 0, len(a))
225+ for _, v := range b {
226+ if _, exists := set[v]; exists {
227+ result = append(result, v)
228+ delete(set, v) // Avoid duplicates
229+ }
230+ }
231+
232+ return result
233+}
234+
235+// unionUint32 finds the union of multiple uint32 slices.
236+func unionUint32(slices ...[]uint32) []uint32 {
237+ if len(slices) == 0 {
238+ return nil
239+ }
240+
241+ // Estimate capacity
242+ capacity := 0
243+ for _, slice := range slices {
244+ capacity += len(slice)
245+ }
246+
247+ set := make(map[uint32]struct{}, capacity)
248+ for _, slice := range slices {
249+ for _, v := range slice {
250+ set[v] = struct{}{}
251+ }
252+ }
253+
254+ result := make([]uint32, 0, len(set))
255+ for v := range set {
256+ result = append(result, v)
257+ }
258+
259+ return result
260+}
261+
262+// getTrigramCandidates finds entry candidates using trigram matching.
263+func (c *DmenuFuzzyCache) getTrigramCandidates(query string) []uint32 {
264+ query = normalizeText(query)
265+ trigrams := extractTrigrams(query)
266+
267+ if len(trigrams) == 0 {
268+ return nil
269+ }
270+
271+ // Get candidates from first trigram
272+ candidates := c.trigramIndex[trigrams[0]]
273+ if len(candidates) == 0 {
274+ return nil
275+ }
276+
277+ // Intersect with other trigrams for more accuracy
278+ for i := 1; i < len(trigrams) && len(candidates) > 0; i++ {
279+ nextCandidates := c.trigramIndex[trigrams[i]]
280+ if len(nextCandidates) == 0 {
281+ continue // Skip empty trigrams
282+ }
283+ candidates = intersectUint32(candidates, nextCandidates)
284+ }
285+
286+ return candidates
287+}
288+
289+// getPrefixCandidates finds entry candidates using prefix matching.
290+func (c *DmenuFuzzyCache) getPrefixCandidates(query string) []uint32 {
291+ if c.prefixTrie == nil {
292+ return nil
293+ }
294+
295+ return c.prefixTrie.PrefixSearch(query)
296+}
297+
298+// getAllCandidates combines trigram and prefix matching for comprehensive candidate selection.
299+func (c *DmenuFuzzyCache) getAllCandidates(query string) []uint32 {
300+ trigramCandidates := c.getTrigramCandidates(query)
301+ prefixCandidates := c.getPrefixCandidates(query)
302+
303+ if len(trigramCandidates) == 0 && len(prefixCandidates) == 0 {
304+ return nil
305+ }
306+
307+ if len(trigramCandidates) == 0 {
308+ return prefixCandidates
309+ }
310+
311+ if len(prefixCandidates) == 0 {
312+ return trigramCandidates
313+ }
314+
315+ // Combine both candidate sets
316+ return unionUint32(trigramCandidates, prefixCandidates)
317+}
318+
319+// buildSortedIndex creates a pre-sorted index of entries by score for O(1) top entries retrieval.
320+func (c *DmenuFuzzyCache) buildSortedIndex() {
321+ // Create index array
322+ c.sortedIndex = make([]uint32, len(c.entries))
323+ for i := range c.sortedIndex {
324+ c.sortedIndex[i] = uint32(i)
325+ }
326+
327+ // Sort indices by entry scores (highest first)
328+ sort.Slice(c.sortedIndex, func(i, j int) bool {
329+ return c.entries[c.sortedIndex[i]].Score > c.entries[c.sortedIndex[j]].Score
330+ })
331+}
A internal/cache/types.go +202 -0
@@ -0,0 +1,202 @@
1+package cache
2+
3+import (
4+ "sync"
5+ "time"
6+ "unsafe"
7+
8+ "github.com/bnema/dumber/internal/config"
9+)
10+
11+// DmenuFuzzyCache provides high-performance fuzzy search for dmenu history.
12+type DmenuFuzzyCache struct {
13+ // Pre-computed search indices
14+ trigramIndex map[string][]uint32 // 3-char sequences -> entry IDs
15+ prefixTrie *Trie // Compressed trie for prefix matching
16+ sortedIndex []uint32 // Pre-sorted entry indices by score (highest first)
17+
18+ // Compact history storage
19+ entries []CompactEntry // Minimal memory footprint
20+
21+ // Metadata
22+ version uint32 // Cache format version
23+ lastModified int64 // Unix timestamp
24+ entryCount uint32 // Number of entries
25+
26+ // Concurrency control
27+ mu sync.RWMutex
28+ data unsafe.Pointer // Atomic pointer to cache data
29+}
30+
31+// CompactEntry represents a history entry optimized for memory usage.
32+type CompactEntry struct {
33+ URL string // Original URL
34+ Title string // Page title
35+ VisitCount uint16 // Max 65535 visits (sufficient for most use cases)
36+ LastVisit uint32 // Days since Unix epoch (saves 4 bytes vs time.Time)
37+ Score uint16 // Pre-computed base score (0-65535)
38+}
39+
40+// FuzzyResult contains the results of a fuzzy search query.
41+type FuzzyResult struct {
42+ Matches []FuzzyMatch // Matching entries
43+ QueryTime time.Duration // Time taken to execute query
44+}
45+
46+// FuzzyMatch represents a single fuzzy search match.
47+type FuzzyMatch struct {
48+ Entry *CompactEntry // Matched entry
49+ Score float64 // Final fuzzy match score (0.0-1.0)
50+ URLScore float64 // URL similarity score
51+ TitleScore float64 // Title similarity score
52+ RecencyScore float64 // Recency boost score
53+ VisitScore float64 // Visit count boost score
54+ MatchType MatchType // Type of match found
55+}
56+
57+// MatchType indicates how the query matched the entry.
58+type MatchType uint8
59+
60+const (
61+ MatchTypeExact MatchType = iota // Exact substring match
62+ MatchTypePrefix // Prefix match
63+ MatchTypeFuzzy // Fuzzy similarity match
64+ MatchTypeTrigram // Trigram-based match
65+)
66+
67+// CacheConfig holds configuration for the fuzzy cache.
68+type CacheConfig struct {
69+ // File paths
70+ CacheFile string // Path to binary cache file
71+
72+ // Performance tuning
73+ MaxEntries uint32 // Maximum entries to cache
74+ TrigramThreshold uint32 // Minimum trigram matches required
75+ ScoreThreshold float64 // Minimum score for results
76+ MaxResults int // Maximum results to return
77+
78+ // Scoring weights
79+ URLWeight float64 // Weight for URL matches
80+ TitleWeight float64 // Weight for title matches
81+ RecencyWeight float64 // Weight for recency
82+ VisitWeight float64 // Weight for visit count
83+
84+ // Cache behavior
85+ TTL time.Duration // Cache time-to-live
86+ WarmupEnabled bool // Enable background cache warming
87+}
88+
89+// DefaultCacheConfig returns sensible default configuration.
90+func DefaultCacheConfig() *CacheConfig {
91+ return &CacheConfig{
92+ MaxEntries: 10000,
93+ TrigramThreshold: 2,
94+ ScoreThreshold: 0.2,
95+ MaxResults: 50,
96+ URLWeight: 0.4,
97+ TitleWeight: 0.3,
98+ RecencyWeight: 0.2,
99+ VisitWeight: 0.1,
100+ TTL: 30 * time.Minute,
101+ WarmupEnabled: true,
102+ }
103+}
104+
105+// GetStateDir returns the state directory using the config package.
106+func (c *CacheConfig) GetStateDir() (string, error) {
107+ return config.GetStateDir()
108+}
109+
110+// Trie represents a compressed prefix tree for fast prefix matching.
111+type Trie struct {
112+ Root *TrieNode
113+}
114+
115+// TrieNode represents a node in the prefix trie.
116+type TrieNode struct {
117+ Children map[rune]*TrieNode // Child nodes
118+ Entries []uint32 // Entry IDs that end at this node
119+ IsEnd bool // Marks end of a prefix
120+}
121+
122+// Reset clears the FuzzyResult for reuse.
123+func (r *FuzzyResult) Reset() {
124+ r.Matches = r.Matches[:0]
125+ r.QueryTime = 0
126+}
127+
128+// String returns a string representation of the match type.
129+func (mt MatchType) String() string {
130+ switch mt {
131+ case MatchTypeExact:
132+ return "exact"
133+ case MatchTypePrefix:
134+ return "prefix"
135+ case MatchTypeFuzzy:
136+ return "fuzzy"
137+ case MatchTypeTrigram:
138+ return "trigram"
139+ default:
140+ return "unknown"
141+ }
142+}
143+
144+// DaysFromTime converts a time.Time to days since Unix epoch.
145+func DaysFromTime(t time.Time) uint32 {
146+ return uint32(t.Unix() / 86400) // 86400 seconds per day
147+}
148+
149+// TimeFromDays converts days since Unix epoch back to time.Time.
150+func TimeFromDays(days uint32) time.Time {
151+ return time.Unix(int64(days)*86400, 0)
152+}
153+
154+// NewCompactEntry creates a CompactEntry from database values.
155+func NewCompactEntry(url, title string, visitCount int64, lastVisit time.Time) CompactEntry {
156+ entry := CompactEntry{
157+ URL: url,
158+ Title: title,
159+ }
160+
161+ // Clamp visit count to uint16 max
162+ if visitCount > 65535 {
163+ entry.VisitCount = 65535
164+ } else if visitCount < 0 {
165+ entry.VisitCount = 0
166+ } else {
167+ entry.VisitCount = uint16(visitCount)
168+ }
169+
170+ entry.LastVisit = DaysFromTime(lastVisit)
171+
172+ // Pre-compute a base score for sorting
173+ entry.Score = entry.calculateBaseScore()
174+
175+ return entry
176+}
177+
178+// calculateBaseScore computes a base score for the entry based on visits and recency.
179+func (e *CompactEntry) calculateBaseScore() uint16 {
180+ visitScore := float64(e.VisitCount) / 65535.0 // Normalize to 0-1
181+
182+ now := time.Now()
183+ lastVisit := TimeFromDays(e.LastVisit)
184+ daysSince := now.Sub(lastVisit).Hours() / 24
185+ recencyScore := 1.0 / (1.0 + daysSince/30.0) // Decay over 30 days
186+
187+ // Weighted combination
188+ baseScore := (visitScore*0.3 + recencyScore*0.7) * 65535.0
189+
190+ if baseScore > 65535 {
191+ return 65535
192+ }
193+ return uint16(baseScore)
194+}
195+
196+// Display returns a formatted string for dmenu display.
197+func (e *CompactEntry) Display() string {
198+ if e.Title != "" && e.Title != e.URL {
199+ return e.Title
200+ }
201+ return e.URL
202+}
@@ -0,0 +1,202 @@
1+package cache
2+
3+import (
4+ "sync"
5+ "time"
6+ "unsafe"
7+
8+ "github.com/bnema/dumber/internal/config"
9+)
10+
11+// DmenuFuzzyCache provides high-performance fuzzy search for dmenu history.
12+type DmenuFuzzyCache struct {
13+ // Pre-computed search indices
14+ trigramIndex map[string][]uint32 // 3-char sequences -> entry IDs
15+ prefixTrie *Trie // Compressed trie for prefix matching
16+ sortedIndex []uint32 // Pre-sorted entry indices by score (highest first)
17+
18+ // Compact history storage
19+ entries []CompactEntry // Minimal memory footprint
20+
21+ // Metadata
22+ version uint32 // Cache format version
23+ lastModified int64 // Unix timestamp
24+ entryCount uint32 // Number of entries
25+
26+ // Concurrency control
27+ mu sync.RWMutex
28+ data unsafe.Pointer // Atomic pointer to cache data
29+}
30+
31+// CompactEntry represents a history entry optimized for memory usage.
32+type CompactEntry struct {
33+ URL string // Original URL
34+ Title string // Page title
35+ VisitCount uint16 // Max 65535 visits (sufficient for most use cases)
36+ LastVisit uint32 // Days since Unix epoch (saves 4 bytes vs time.Time)
37+ Score uint16 // Pre-computed base score (0-65535)
38+}
39+
40+// FuzzyResult contains the results of a fuzzy search query.
41+type FuzzyResult struct {
42+ Matches []FuzzyMatch // Matching entries
43+ QueryTime time.Duration // Time taken to execute query
44+}
45+
46+// FuzzyMatch represents a single fuzzy search match.
47+type FuzzyMatch struct {
48+ Entry *CompactEntry // Matched entry
49+ Score float64 // Final fuzzy match score (0.0-1.0)
50+ URLScore float64 // URL similarity score
51+ TitleScore float64 // Title similarity score
52+ RecencyScore float64 // Recency boost score
53+ VisitScore float64 // Visit count boost score
54+ MatchType MatchType // Type of match found
55+}
56+
57+// MatchType indicates how the query matched the entry.
58+type MatchType uint8
59+
60+const (
61+ MatchTypeExact MatchType = iota // Exact substring match
62+ MatchTypePrefix // Prefix match
63+ MatchTypeFuzzy // Fuzzy similarity match
64+ MatchTypeTrigram // Trigram-based match
65+)
66+
67+// CacheConfig holds configuration for the fuzzy cache.
68+type CacheConfig struct {
69+ // File paths
70+ CacheFile string // Path to binary cache file
71+
72+ // Performance tuning
73+ MaxEntries uint32 // Maximum entries to cache
74+ TrigramThreshold uint32 // Minimum trigram matches required
75+ ScoreThreshold float64 // Minimum score for results
76+ MaxResults int // Maximum results to return
77+
78+ // Scoring weights
79+ URLWeight float64 // Weight for URL matches
80+ TitleWeight float64 // Weight for title matches
81+ RecencyWeight float64 // Weight for recency
82+ VisitWeight float64 // Weight for visit count
83+
84+ // Cache behavior
85+ TTL time.Duration // Cache time-to-live
86+ WarmupEnabled bool // Enable background cache warming
87+}
88+
89+// DefaultCacheConfig returns sensible default configuration.
90+func DefaultCacheConfig() *CacheConfig {
91+ return &CacheConfig{
92+ MaxEntries: 10000,
93+ TrigramThreshold: 2,
94+ ScoreThreshold: 0.2,
95+ MaxResults: 50,
96+ URLWeight: 0.4,
97+ TitleWeight: 0.3,
98+ RecencyWeight: 0.2,
99+ VisitWeight: 0.1,
100+ TTL: 30 * time.Minute,
101+ WarmupEnabled: true,
102+ }
103+}
104+
105+// GetStateDir returns the state directory using the config package.
106+func (c *CacheConfig) GetStateDir() (string, error) {
107+ return config.GetStateDir()
108+}
109+
110+// Trie represents a compressed prefix tree for fast prefix matching.
111+type Trie struct {
112+ Root *TrieNode
113+}
114+
115+// TrieNode represents a node in the prefix trie.
116+type TrieNode struct {
117+ Children map[rune]*TrieNode // Child nodes
118+ Entries []uint32 // Entry IDs that end at this node
119+ IsEnd bool // Marks end of a prefix
120+}
121+
122+// Reset clears the FuzzyResult for reuse.
123+func (r *FuzzyResult) Reset() {
124+ r.Matches = r.Matches[:0]
125+ r.QueryTime = 0
126+}
127+
128+// String returns a string representation of the match type.
129+func (mt MatchType) String() string {
130+ switch mt {
131+ case MatchTypeExact:
132+ return "exact"
133+ case MatchTypePrefix:
134+ return "prefix"
135+ case MatchTypeFuzzy:
136+ return "fuzzy"
137+ case MatchTypeTrigram:
138+ return "trigram"
139+ default:
140+ return "unknown"
141+ }
142+}
143+
144+// DaysFromTime converts a time.Time to days since Unix epoch.
145+func DaysFromTime(t time.Time) uint32 {
146+ return uint32(t.Unix() / 86400) // 86400 seconds per day
147+}
148+
149+// TimeFromDays converts days since Unix epoch back to time.Time.
150+func TimeFromDays(days uint32) time.Time {
151+ return time.Unix(int64(days)*86400, 0)
152+}
153+
154+// NewCompactEntry creates a CompactEntry from database values.
155+func NewCompactEntry(url, title string, visitCount int64, lastVisit time.Time) CompactEntry {
156+ entry := CompactEntry{
157+ URL: url,
158+ Title: title,
159+ }
160+
161+ // Clamp visit count to uint16 max
162+ if visitCount > 65535 {
163+ entry.VisitCount = 65535
164+ } else if visitCount < 0 {
165+ entry.VisitCount = 0
166+ } else {
167+ entry.VisitCount = uint16(visitCount)
168+ }
169+
170+ entry.LastVisit = DaysFromTime(lastVisit)
171+
172+ // Pre-compute a base score for sorting
173+ entry.Score = entry.calculateBaseScore()
174+
175+ return entry
176+}
177+
178+// calculateBaseScore computes a base score for the entry based on visits and recency.
179+func (e *CompactEntry) calculateBaseScore() uint16 {
180+ visitScore := float64(e.VisitCount) / 65535.0 // Normalize to 0-1
181+
182+ now := time.Now()
183+ lastVisit := TimeFromDays(e.LastVisit)
184+ daysSince := now.Sub(lastVisit).Hours() / 24
185+ recencyScore := 1.0 / (1.0 + daysSince/30.0) // Decay over 30 days
186+
187+ // Weighted combination
188+ baseScore := (visitScore*0.3 + recencyScore*0.7) * 65535.0
189+
190+ if baseScore > 65535 {
191+ return 65535
192+ }
193+ return uint16(baseScore)
194+}
195+
196+// Display returns a formatted string for dmenu display.
197+func (e *CompactEntry) Display() string {
198+ if e.Title != "" && e.Title != e.URL {
199+ return e.Title
200+ }
201+ return e.URL
202+}
M internal/cli/dmenu.go +97 -1
@@ -8,6 +8,7 @@ import (
88 "sort"
99 "strings"
1010
11+ "github.com/bnema/dumber/internal/cache"
1112 "github.com/spf13/cobra"
1213 )
1314
@@ -48,10 +49,105 @@ Usage with dmenu:
4849 return cmd
4950 }
5051
51-// generateOptions outputs all available options for the launcher
52+// generateOptions outputs all available options for the launcher using fuzzy cache
5253 func generateOptions(cli *CLI) error {
5354 ctx := context.Background()
5455
56+ // Initialize cache manager
57+ cacheConfig := cache.DefaultCacheConfig()
58+ cacheConfig.MaxResults = 50 // Match the old maxHistoryEntries
59+ cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)
60+
61+ // Get top entries from cache (this is blazingly fast!)
62+ result, err := cacheManager.GetTopEntries(ctx)
63+ if err != nil {
64+ // Fallback to old method if cache fails
65+ return generateOptionsFallback(cli)
66+ }
67+
68+ // Get shortcuts for display
69+ shortcuts, err := cli.Queries.GetShortcuts(ctx)
70+ if err != nil {
71+ return fmt.Errorf("failed to get shortcuts: %w", err)
72+ }
73+
74+ // Generate options
75+ options := make([]DmenuOption, 0, len(result.Matches)+len(shortcuts)+1)
76+
77+ // Add direct URL input option
78+ options = append(options, DmenuOption{
79+ Display: "🌐 Enter URL or search query...",
80+ Value: "",
81+ Type: "input",
82+ Description: "Type any URL or search term",
83+ })
84+
85+ // Add shortcuts with examples
86+ for _, shortcut := range shortcuts {
87+ desc := shortcut.Description.String
88+ if !shortcut.Description.Valid {
89+ desc = "Custom shortcut"
90+ }
91+
92+ options = append(options, DmenuOption{
93+ Display: fmt.Sprintf("🔍 %s: (%s)", shortcut.Shortcut, desc),
94+ Value: shortcut.Shortcut + ":",
95+ Type: "shortcut",
96+ Description: desc,
97+ })
98+ }
99+
100+ // Add history entries from cache (already sorted by relevance!)
101+ for _, match := range result.Matches {
102+ entry := match.Entry
103+ title := entry.URL
104+ if entry.Title != "" {
105+ title = entry.Title
106+ }
107+
108+ const maxDisplayLength = 80
109+ // Truncate long titles/URLs for display
110+ display := truncateString(title, maxDisplayLength)
111+
112+ options = append(options, DmenuOption{
113+ Display: fmt.Sprintf("🕒 %s", display),
114+ Value: entry.URL,
115+ Type: "history",
116+ Description: entry.URL,
117+ })
118+ }
119+
120+ // Sort options: input first, then shortcuts, then history (already sorted by cache)
121+ sort.Slice(options, func(i, j int) bool {
122+ if options[i].Type != options[j].Type {
123+ // Order: input, shortcut, history
124+ typeOrder := map[string]int{"input": 0, "shortcut": 1, "history": 2} //nolint:mnd
125+ return typeOrder[options[i].Type] < typeOrder[options[j].Type]
126+ }
127+
128+ if options[i].Type == "history" && options[j].Type == "history" {
129+ // History already sorted by cache relevance score
130+ return i < j
131+ }
132+
133+ return options[i].Display < options[j].Display
134+ })
135+
136+ // Output options to stdout
137+ for _, option := range options {
138+ fmt.Println(option.Display)
139+ }
140+
141+ // Trigger background cache refresh for next time if needed
142+ defer cacheManager.OnApplicationExit(ctx)
143+
144+ return nil
145+}
146+
147+// generateOptionsFallback is the old method used as a fallback if cache fails
148+func generateOptionsFallback(cli *CLI) error {
149+ ctx := context.Background()
150+
55151 const maxHistoryEntries = 50
56152 // Get history entries
57153 history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries)
@@ -8,6 +8,7 @@ import (
8 "sort"8 "sort"
9 "strings"9 "strings"
10 10
11+ "github.com/bnema/dumber/internal/cache"
11 "github.com/spf13/cobra"12 "github.com/spf13/cobra"
12 )13 )
13 14
@@ -48,10 +49,105 @@ Usage with dmenu:
48 return cmd49 return cmd
49 }50 }
50 51
51-// generateOptions outputs all available options for the launcher52+// generateOptions outputs all available options for the launcher using fuzzy cache
52 func generateOptions(cli *CLI) error {53 func generateOptions(cli *CLI) error {
53 ctx := context.Background()54 ctx := context.Background()
54 55
56+ // Initialize cache manager
57+ cacheConfig := cache.DefaultCacheConfig()
58+ cacheConfig.MaxResults = 50 // Match the old maxHistoryEntries
59+ cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)
60+
61+ // Get top entries from cache (this is blazingly fast!)
62+ result, err := cacheManager.GetTopEntries(ctx)
63+ if err != nil {
64+ // Fallback to old method if cache fails
65+ return generateOptionsFallback(cli)
66+ }
67+
68+ // Get shortcuts for display
69+ shortcuts, err := cli.Queries.GetShortcuts(ctx)
70+ if err != nil {
71+ return fmt.Errorf("failed to get shortcuts: %w", err)
72+ }
73+
74+ // Generate options
75+ options := make([]DmenuOption, 0, len(result.Matches)+len(shortcuts)+1)
76+
77+ // Add direct URL input option
78+ options = append(options, DmenuOption{
79+ Display: "🌐 Enter URL or search query...",
80+ Value: "",
81+ Type: "input",
82+ Description: "Type any URL or search term",
83+ })
84+
85+ // Add shortcuts with examples
86+ for _, shortcut := range shortcuts {
87+ desc := shortcut.Description.String
88+ if !shortcut.Description.Valid {
89+ desc = "Custom shortcut"
90+ }
91+
92+ options = append(options, DmenuOption{
93+ Display: fmt.Sprintf("🔍 %s: (%s)", shortcut.Shortcut, desc),
94+ Value: shortcut.Shortcut + ":",
95+ Type: "shortcut",
96+ Description: desc,
97+ })
98+ }
99+
100+ // Add history entries from cache (already sorted by relevance!)
101+ for _, match := range result.Matches {
102+ entry := match.Entry
103+ title := entry.URL
104+ if entry.Title != "" {
105+ title = entry.Title
106+ }
107+
108+ const maxDisplayLength = 80
109+ // Truncate long titles/URLs for display
110+ display := truncateString(title, maxDisplayLength)
111+
112+ options = append(options, DmenuOption{
113+ Display: fmt.Sprintf("🕒 %s", display),
114+ Value: entry.URL,
115+ Type: "history",
116+ Description: entry.URL,
117+ })
118+ }
119+
120+ // Sort options: input first, then shortcuts, then history (already sorted by cache)
121+ sort.Slice(options, func(i, j int) bool {
122+ if options[i].Type != options[j].Type {
123+ // Order: input, shortcut, history
124+ typeOrder := map[string]int{"input": 0, "shortcut": 1, "history": 2} //nolint:mnd
125+ return typeOrder[options[i].Type] < typeOrder[options[j].Type]
126+ }
127+
128+ if options[i].Type == "history" && options[j].Type == "history" {
129+ // History already sorted by cache relevance score
130+ return i < j
131+ }
132+
133+ return options[i].Display < options[j].Display
134+ })
135+
136+ // Output options to stdout
137+ for _, option := range options {
138+ fmt.Println(option.Display)
139+ }
140+
141+ // Trigger background cache refresh for next time if needed
142+ defer cacheManager.OnApplicationExit(ctx)
143+
144+ return nil
145+}
146+
147+// generateOptionsFallback is the old method used as a fallback if cache fails
148+func generateOptionsFallback(cli *CLI) error {
149+ ctx := context.Background()
150+
55 const maxHistoryEntries = 50151 const maxHistoryEntries = 50
56 // Get history entries152 // Get history entries
57 history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries)153 history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries)
M internal/config/xdg.go +1 -0
@@ -81,6 +81,7 @@ func GetStateDir() (string, error) {
8181 return dirs.StateHome, nil
8282 }
8383
84+
8485 // GetConfigFile returns the path to the main configuration file.
8586 func GetConfigFile() (string, error) {
8687 configDir, err := GetConfigDir()
@@ -81,6 +81,7 @@ func GetStateDir() (string, error) {
81 return dirs.StateHome, nil81 return dirs.StateHome, nil
82 }82 }
83 83
84+
84 // GetConfigFile returns the path to the main configuration file.85 // GetConfigFile returns the path to the main configuration file.
85 func GetConfigFile() (string, error) {86 func GetConfigFile() (string, error) {
86 configDir, err := GetConfigDir()87 configDir, err := GetConfigDir()

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