1package cache
2
3import (
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.
15type 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.
25func 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.
51func (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.
66func (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.
90func (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.
110func (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.
120func (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).
192func (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.
214func (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.
231func (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.
255func (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.
278func (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.
288func (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.
298func (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.
317type 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.
326func (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}