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#[derive(Debug, Serialize, Deserialize, Clone)]
15pub struct QueryMatchEntry {
16 pub file_path: PathBuf, pub open_count: u32, pub last_opened: u64, }
20
21#[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 query_file_db: Database<Bytes, SerdeBincode<QueryMatchEntry>>,
33 query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
35 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 const MAP_SIZE: usize = 10 * 1024 * 1024;
65 const MAX_DBS: u32 = 16;
66 const SIZE_CAP_BYTES: u64 = 4 * 1024 * 1024;
69}
70
71impl QueryTracker {
72 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 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 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 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 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 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 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 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), }
307 }
308
309 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 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 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 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 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 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 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 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 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 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 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}