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 ( | |||
| 8 | 8 | "sort" | |
| 9 | 9 | "strings" | |
| 10 | 10 | ||
| 11 | + | "github.com/bnema/dumber/internal/cache" | |
| 11 | 12 | "github.com/spf13/cobra" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| @@ -48,10 +49,105 @@ Usage with dmenu: | |||
| 48 | 49 | return cmd | |
| 49 | 50 | } | |
| 50 | 51 | ||
| 51 | - | // generateOptions outputs all available options for the launcher | |
| 52 | + | // generateOptions outputs all available options for the launcher using fuzzy cache | |
| 52 | 53 | func generateOptions(cli *CLI) error { | |
| 53 | 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 | 151 | const maxHistoryEntries = 50 | |
| 56 | 152 | // Get history entries | |
| 57 | 153 | 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 cmd | 49 | return cmd | ||
| 49 | } | 50 | } | ||
| 50 | 51 | ||||
| 51 | - | // generateOptions outputs all available options for the launcher | 52 | + | // 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 = 50 | 151 | const maxHistoryEntries = 50 | ||
| 56 | // Get history entries | 152 | // 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) { | |||
| 81 | 81 | return dirs.StateHome, nil | |
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | + | ||
| 84 | 85 | // GetConfigFile returns the path to the main configuration file. | |
| 85 | 86 | func GetConfigFile() (string, error) { | |
| 86 | 87 | configDir, err := GetConfigDir() | |
| @@ -81,6 +81,7 @@ func GetStateDir() (string, error) { | |||||
| 81 | return dirs.StateHome, nil | 81 | 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() | ||