Skip to main content

ffs_search/dbs/
query_tracker.rs

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