Skip to main content

fff_search/dbs/
query_tracker.rs

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