Skip to main content

fff_search/dbs/
query_tracker.rs

1use super::db_healthcheck::DbHealthChecker;
2use super::lmdb::{DbHealth, LmdbStore, is_map_full};
3use crate::error::Error;
4use heed::types::{Bytes, SerdeBincode};
5use heed::{Database, Env};
6use serde::{Deserialize, Serialize};
7use std::collections::VecDeque;
8use std::path::{Path, PathBuf};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11const MAX_HISTORY_ENTRIES: usize = 128;
12
13/// Simplified QueryFileEntry without redundant fields
14#[derive(Debug, Serialize, Deserialize, Clone)]
15pub struct QueryMatchEntry {
16    pub file_path: PathBuf, // File that was actually opened
17    pub open_count: u32,    // Number of times opened with this query
18    pub last_opened: u64,   // Unix timestamp
19}
20
21/// Entry for query history tracking
22#[derive(Debug, Serialize, Deserialize, Clone)]
23struct HistoryEntry {
24    query: String,
25    timestamp: u64,
26}
27
28#[derive(Debug)]
29pub struct QueryTracker {
30    env: Env,
31    // Database for (project_path, query) -> QueryMatchEntry mappings
32    query_file_db: Database<Bytes, SerdeBincode<QueryMatchEntry>>,
33    // Database for project_path -> VecDeque<HistoryEntry> mappings (file picker)
34    query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
35    // Database for project_path -> VecDeque<HistoryEntry> mappings (grep)
36    grep_query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
37    health: DbHealth,
38}
39
40impl DbHealthChecker for QueryTracker {
41    fn get_env(&self) -> &Env {
42        &self.env
43    }
44
45    fn is_healthy(&self) -> bool {
46        self.health.is_healthy()
47    }
48
49    fn count_entries(&self) -> Result<Vec<(&'static str, u64)>, Error> {
50        let rtxn = self
51            .env
52            .read_txn()
53            .map_err(|source| Error::DbStartReadTxn {
54                db: Self::LABEL,
55                source,
56            })?;
57
58        let count_queries = self
59            .query_file_db
60            .len(&rtxn)
61            .map_err(|source| Error::DbRead {
62                db: Self::LABEL,
63                source,
64            })?;
65        let count_histories = self
66            .query_history_db
67            .len(&rtxn)
68            .map_err(|source| Error::DbRead {
69                db: Self::LABEL,
70                source,
71            })?;
72        let count_grep_histories =
73            self.grep_query_history_db
74                .len(&rtxn)
75                .map_err(|source| Error::DbRead {
76                    db: Self::LABEL,
77                    source,
78                })?;
79
80        Ok(vec![
81            ("query_file_entries", count_queries),
82            ("query_history_entries", count_histories),
83            ("grep_query_history_entries", count_grep_histories),
84        ])
85    }
86}
87
88impl LmdbStore for QueryTracker {
89    const LABEL: &'static str = "query";
90    // 10 MiB hard ceiling. Same reasoning as FrecencyTracker (GH issue #437).
91    const MAP_SIZE: usize = 10 * 1024 * 1024;
92    const MAX_DBS: u32 = 16;
93    const SIZE_CAP_BYTES: u64 = 8 * 1024 * 1024;
94
95    fn env(&self) -> &Env {
96        &self.env
97    }
98
99    fn health(&self) -> &DbHealth {
100        &self.health
101    }
102}
103
104impl QueryTracker {
105    /// Returns the on-disk path of the LMDB environment directory.
106    pub fn db_path(&self) -> &Path {
107        self.env.path()
108    }
109
110    pub fn open(db_path: impl AsRef<Path>) -> Result<Self, Error> {
111        let db_path = db_path.as_ref();
112        let (env, health) = Self::open_env(db_path)?;
113
114        let query_file_db = Self::open_database_safe(&env, Some("query_file_associations"))?;
115        let query_history_db = Self::open_database_safe(&env, Some("query_history"))?;
116        let grep_query_history_db = Self::open_database_safe(&env, Some("grep_query_history"))?;
117
118        Ok(QueryTracker {
119            env,
120            query_file_db,
121            query_history_db,
122            grep_query_history_db,
123            health,
124        })
125    }
126
127    #[deprecated(
128        since = "0.7.0",
129        note = "LMDB unsafe no-lock mode is no longer supported; use `QueryTracker::open` instead. \
130                The `_use_unsafe_no_lock` argument is ignored."
131    )]
132    pub fn new(db_path: impl AsRef<Path>, _use_unsafe_no_lock: bool) -> Result<Self, Error> {
133        Self::open(db_path)
134    }
135
136    fn get_now(&self) -> u64 {
137        SystemTime::now()
138            .duration_since(UNIX_EPOCH)
139            .unwrap()
140            .as_secs()
141    }
142
143    fn create_query_key(project_path: &Path, query: &str) -> Result<[u8; 32], Error> {
144        let project_str = project_path
145            .to_str()
146            .ok_or_else(|| Error::InvalidPath(project_path.to_path_buf()))?;
147
148        let mut hasher = blake3::Hasher::default();
149        hasher.update(project_str.as_bytes());
150        hasher.update(b"::");
151        hasher.update(query.as_bytes());
152
153        Ok(*hasher.finalize().as_bytes())
154    }
155
156    fn create_project_key(project_path: &Path) -> Result<[u8; 32], Error> {
157        let project_str = project_path
158            .to_str()
159            .ok_or_else(|| Error::InvalidPath(project_path.to_path_buf()))?;
160
161        Ok(*blake3::hash(project_str.as_bytes()).as_bytes())
162    }
163
164    /// Append a query to a history database within an existing write transaction.
165    fn append_to_history(
166        db: &Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
167        wtxn: &mut heed::RwTxn,
168        project_key: &[u8; 32],
169        query: &str,
170        now: u64,
171    ) -> Result<(), Error> {
172        let mut history = db
173            .get(wtxn, project_key)
174            .map_err(|source| Error::DbRead {
175                db: Self::LABEL,
176                source,
177            })?
178            .unwrap_or_default();
179
180        history.push_back(HistoryEntry {
181            query: query.to_string(),
182            timestamp: now,
183        });
184        while history.len() > MAX_HISTORY_ENTRIES {
185            history.pop_front();
186        }
187
188        db.put(wtxn, project_key, &history)
189            .map_err(|source| Error::DbWrite {
190                db: Self::LABEL,
191                source,
192            })?;
193        Ok(())
194    }
195
196    /// Read a query from a history database at a specific offset.
197    /// offset=0 returns most recent, offset=1 returns 2nd most recent, etc.
198    fn read_history_at_offset(
199        db: &Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
200        env: &Env,
201        project_key: &[u8; 32],
202        offset: usize,
203    ) -> Result<Option<String>, Error> {
204        let rtxn = env.read_txn().map_err(|source| Error::DbStartReadTxn {
205            db: Self::LABEL,
206            source,
207        })?;
208
209        let mut history = db
210            .get(&rtxn, project_key)
211            .map_err(|source| Error::DbRead {
212                db: Self::LABEL,
213                source,
214            })?
215            .unwrap_or_default();
216
217        // history is FIFO, last element is most recent
218        if history.len() > offset {
219            let index = history.len() - 1 - offset;
220            let record = history.remove(index);
221            Ok(record.map(|r| r.query))
222        } else {
223            Ok(None)
224        }
225    }
226
227    pub fn track_query_completion(
228        &mut self,
229        query: &str,
230        project_path: &Path,
231        file_path: &Path,
232    ) -> Result<(), Error> {
233        let now = self.get_now();
234        let file_path_buf = file_path.to_path_buf();
235
236        let query_key = Self::create_query_key(project_path, query)?;
237        let mut wtxn = self
238            .env
239            .write_txn()
240            .map_err(|source| Error::DbStartWriteTxn {
241                db: Self::LABEL,
242                source,
243            })?;
244
245        let mut entry = self
246            .query_file_db
247            .get(&wtxn, &query_key)
248            .map_err(|source| Error::DbRead {
249                db: Self::LABEL,
250                source,
251            })?
252            .unwrap_or_else(|| QueryMatchEntry {
253                file_path: file_path_buf.clone(),
254                open_count: 0,
255                last_opened: now,
256            });
257
258        if entry.file_path == file_path_buf {
259            tracing::debug!(
260                ?query,
261                ?file_path,
262                "Query completed for same file as last time"
263            );
264
265            // Same file - just increment count
266            entry.open_count += 1;
267        } else {
268            tracing::debug!(
269                ?query,
270                ?file_path,
271                "Query completed for different file than last time"
272            );
273
274            // Different file - replace and reset count to 1
275            entry.file_path = file_path_buf;
276            entry.open_count = 1;
277        }
278
279        entry.last_opened = now;
280
281        if let Err(e) = self.query_file_db.put(&mut wtxn, &query_key, &entry) {
282            if is_map_full(&e) {
283                self.health.mark_unhealthy("MDB_MAP_FULL on put");
284                tracing::error!(
285                    ?query,
286                    "Query tracker DB hit MDB_MAP_FULL; dropping write — db will \
287                     be erased on next open"
288                );
289                return Ok(());
290            }
291            return Err(Error::DbWrite {
292                db: Self::LABEL,
293                source: e,
294            });
295        }
296
297        // Update query history database
298        let project_key = Self::create_project_key(project_path)?;
299        if let Err(e) =
300            Self::append_to_history(&self.query_history_db, &mut wtxn, &project_key, query, now)
301        {
302            if let Error::DbWrite {
303                source: ref inner, ..
304            } = e
305                && is_map_full(inner)
306            {
307                self.health.mark_unhealthy("MDB_MAP_FULL on history append");
308                tracing::error!(?query, "Query tracker DB map full while appending history");
309                return Ok(());
310            }
311            return Err(e);
312        }
313
314        if let Err(e) = wtxn.commit() {
315            if is_map_full(&e) {
316                self.health.mark_unhealthy("MDB_MAP_FULL on commit");
317                tracing::error!(?query, "Query tracker DB map full on commit");
318                return Ok(());
319            }
320            return Err(Error::DbCommit {
321                db: Self::LABEL,
322                source: e,
323            });
324        }
325
326        tracing::debug!(?query, ?file_path, "Tracked query completion");
327        Ok(())
328    }
329
330    pub fn get_last_query_entry(
331        &self,
332        query: &str,
333        project_path: &Path,
334        min_combo_count: u32,
335    ) -> Result<Option<QueryMatchEntry>, Error> {
336        let query_key = Self::create_query_key(project_path, query)?;
337        let rtxn = self
338            .env
339            .read_txn()
340            .map_err(|source| Error::DbStartReadTxn {
341                db: Self::LABEL,
342                source,
343            })?;
344
345        let last_match = self
346            .query_file_db
347            .get(&rtxn, &query_key)
348            .map_err(|source| Error::DbRead {
349                db: Self::LABEL,
350                source,
351            })?;
352
353        Ok(last_match.filter(|entry| entry.open_count >= min_combo_count))
354    }
355
356    pub fn get_last_query_path(
357        &self,
358        query: &str,
359        project_path: &Path,
360        file_path: &Path,
361        combo_boost: i32,
362    ) -> Result<i32, Error> {
363        let query_key = Self::create_query_key(project_path, query)?;
364        tracing::debug!(?query_key, "HASH");
365        let rtxn = self
366            .env
367            .read_txn()
368            .map_err(|source| Error::DbStartReadTxn {
369                db: Self::LABEL,
370                source,
371            })?;
372
373        match self
374            .query_file_db
375            .get(&rtxn, &query_key)
376            .map_err(|source| Error::DbRead {
377                db: Self::LABEL,
378                source,
379            })? {
380            Some(entry) => {
381                // Check if the file path matches and return boost
382                if entry.file_path == file_path && entry.open_count >= 2 {
383                    Ok(combo_boost)
384                } else {
385                    Ok(0)
386                }
387            }
388            None => Ok(0), // Query not found
389        }
390    }
391
392    /// Get query from file picker history at a specific offset.
393    /// offset=0 returns most recent query, offset=1 returns 2nd most recent, etc.
394    pub fn get_historical_query(
395        &self,
396        project_path: &Path,
397        offset: usize,
398    ) -> Result<Option<String>, Error> {
399        let project_key = Self::create_project_key(project_path)?;
400        Self::read_history_at_offset(&self.query_history_db, &self.env, &project_key, offset)
401    }
402
403    /// Track a grep query in the grep-specific history.
404    /// Only records query history (no file association tracking needed for grep).
405    pub fn track_grep_query(&mut self, query: &str, project_path: &Path) -> Result<(), Error> {
406        let now = self.get_now();
407        let project_key = Self::create_project_key(project_path)?;
408        let mut wtxn = self
409            .env
410            .write_txn()
411            .map_err(|source| Error::DbStartWriteTxn {
412                db: Self::LABEL,
413                source,
414            })?;
415
416        if let Err(e) = Self::append_to_history(
417            &self.grep_query_history_db,
418            &mut wtxn,
419            &project_key,
420            query,
421            now,
422        ) {
423            if let Error::DbWrite {
424                source: ref inner, ..
425            } = e
426                && is_map_full(inner)
427            {
428                self.health
429                    .mark_unhealthy("MDB_MAP_FULL on grep history append");
430                tracing::error!(?query, "Grep query history DB map full; dropping write");
431                return Ok(());
432            }
433            return Err(e);
434        }
435
436        if let Err(e) = wtxn.commit() {
437            if is_map_full(&e) {
438                self.health.mark_unhealthy("MDB_MAP_FULL on commit");
439                tracing::error!(?query, "Grep query history DB map full on commit");
440                return Ok(());
441            }
442            return Err(Error::DbCommit {
443                db: Self::LABEL,
444                source: e,
445            });
446        }
447
448        tracing::debug!(?query, "Tracked grep query");
449        Ok(())
450    }
451
452    /// Get grep query from history at a specific offset.
453    /// offset=0 returns most recent grep query, offset=1 returns 2nd most recent, etc.
454    pub fn get_historical_grep_query(
455        &self,
456        project_path: &Path,
457        offset: usize,
458    ) -> Result<Option<String>, Error> {
459        let project_key = Self::create_project_key(project_path)?;
460        Self::read_history_at_offset(&self.grep_query_history_db, &self.env, &project_key, offset)
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use std::env;
468
469    #[test]
470    fn test_query_tracking() {
471        let temp_dir = env::temp_dir().join("fff_test_query_tracking_new");
472        let _ = std::fs::remove_dir_all(&temp_dir);
473
474        let mut tracker = QueryTracker::open(temp_dir.to_str().unwrap()).unwrap();
475
476        let project_path = PathBuf::from("/test/project");
477        let file_path = PathBuf::from("/test/project/src/main.rs");
478
479        // First completion
480        tracker
481            .track_query_completion("main", &project_path, &file_path)
482            .unwrap();
483        let boost = tracker
484            .get_last_query_path("main", &project_path, &file_path, 10000)
485            .unwrap();
486        assert_eq!(boost, 0, "First completion should not boost");
487
488        // Second completion - should boost now
489        tracker
490            .track_query_completion("main", &project_path, &file_path)
491            .unwrap();
492        let boost = tracker
493            .get_last_query_path("main", &project_path, &file_path, 10000)
494            .unwrap();
495        assert_eq!(boost, 10000, "Second completion should boost");
496
497        // Different file for same query - should reset count and no boost
498        let other_file = PathBuf::from("/test/project/src/lib.rs");
499        tracker
500            .track_query_completion("main", &project_path, &other_file)
501            .unwrap();
502        let boost = tracker
503            .get_last_query_path("main", &project_path, &other_file, 10000)
504            .unwrap();
505        assert_eq!(boost, 0, "Different file should reset boost");
506
507        // Original file should no longer get boost (replaced by new file)
508        let boost = tracker
509            .get_last_query_path("main", &project_path, &file_path, 10000)
510            .unwrap();
511        assert_eq!(boost, 0, "Original file should not boost after replacement");
512
513        let _ = std::fs::remove_dir_all(&temp_dir);
514    }
515
516    #[test]
517    fn test_hashing_functions() {
518        let project_path = PathBuf::from("/test/project");
519
520        // Test project key hashing
521        let key1 = QueryTracker::create_project_key(&project_path).unwrap();
522        let key2 = QueryTracker::create_project_key(&project_path).unwrap();
523        assert_eq!(key1, key2, "Same project should hash to same key");
524
525        // Test query key hashing
526        let query_key1 = QueryTracker::create_query_key(&project_path, "test").unwrap();
527        let query_key2 = QueryTracker::create_query_key(&project_path, "test").unwrap();
528        assert_eq!(
529            query_key1, query_key2,
530            "Same project+query should hash to same key"
531        );
532
533        // Different queries should hash differently
534        let query_key3 = QueryTracker::create_query_key(&project_path, "different").unwrap();
535        assert_ne!(
536            query_key1, query_key3,
537            "Different queries should hash to different keys"
538        );
539
540        // Different projects should hash differently
541        let other_project = PathBuf::from("/other/project");
542        let query_key4 = QueryTracker::create_query_key(&other_project, "test").unwrap();
543        assert_ne!(
544            query_key1, query_key4,
545            "Different projects should hash to different keys"
546        );
547    }
548}