1use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19use std::sync::{Arc, Mutex, OnceLock};
20
21use anyhow::{Context, Result};
22
23use crate::cache::CacheManager;
24use crate::content_store::ContentReader;
25use crate::errors::ReflexError;
26use crate::trigram::TrigramIndex;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30struct FileStamp {
31 dev: u64,
32 ino: u64,
33 size: u64,
34 mtime: Option<std::time::SystemTime>,
35}
36
37impl FileStamp {
38 fn of(path: &Path) -> std::io::Result<Self> {
39 let md = std::fs::metadata(path)?;
40 #[cfg(unix)]
41 let (dev, ino) = {
42 use std::os::unix::fs::MetadataExt;
43 (md.dev(), md.ino())
44 };
45 #[cfg(not(unix))]
46 let (dev, ino) = (0u64, 0u64);
47 Ok(Self {
48 dev,
49 ino,
50 size: md.len(),
51 mtime: md.modified().ok(),
52 })
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
61struct Fingerprint {
62 content: FileStamp,
63 trigrams: Option<FileStamp>,
64}
65
66impl Fingerprint {
67 fn current(cache_dir: &Path) -> std::io::Result<Self> {
68 let content = FileStamp::of(&cache_dir.join("content.bin"))?;
69 let trigrams = FileStamp::of(&cache_dir.join("trigrams.bin")).ok();
70 Ok(Self { content, trigrams })
71 }
72}
73
74pub struct OpenIndex {
76 cache_dir: PathBuf,
77 pub content: ContentReader,
79 pub trigrams: TrigramIndex,
81 path_to_id: OnceLock<HashMap<String, u32>>,
84 pool: OnceLock<rayon::ThreadPool>,
88 threads: usize,
89 posting_cap: usize,
91 fingerprint: Fingerprint,
92 meta: OnceLock<Mutex<rusqlite::Connection>>,
97 fingerprints: OnceLock<Arc<HashMap<String, crate::cache::FileFingerprint>>>,
100}
101
102impl std::fmt::Debug for OpenIndex {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 f.debug_struct("OpenIndex")
105 .field("cache_dir", &self.cache_dir)
106 .field("files", &self.content.file_count())
107 .field("threads", &self.threads)
108 .finish()
109 }
110}
111
112const QUERY_AUTO_THREAD_CAP: usize = 32;
118
119impl OpenIndex {
120 fn open(cache: &CacheManager, fingerprint: Fingerprint) -> Result<Self> {
121 let cache_dir = cache.path().to_path_buf();
122
123 let content = ContentReader::open(cache_dir.join("content.bin"))
124 .map_err(|e| ReflexError::CacheCorrupted(format!("content.bin: {e:#}")))?;
125
126 let trigrams_path = cache_dir.join("trigrams.bin");
127 let trigrams = if trigrams_path.exists() {
128 match TrigramIndex::load(&trigrams_path) {
129 Ok(index) => index,
130 Err(e) if e.to_string().contains("Unsupported trigrams.bin version") => {
135 log::warn!("{}; rebuilding trigram index in memory for this process", e);
136 super::result::rebuild_trigram_index(&content)?
137 }
138 Err(e) => {
139 return Err(ReflexError::CacheCorrupted(format!("trigrams.bin: {e:#}")).into());
140 }
141 }
142 } else {
143 log::debug!("trigrams.bin not found, rebuilding from content store");
144 super::result::rebuild_trigram_index(&content)?
145 };
146
147 if trigrams.file_count() != content.file_count() {
148 return Err(ReflexError::CacheCorrupted(format!(
149 "trigrams.bin lists {} files but content.bin holds {} (index written by two runs?)",
150 trigrams.file_count(),
151 content.file_count()
152 ))
153 .into());
154 }
155
156 let config = cache.load_index_config().unwrap_or_else(|e| {
157 log::debug!("Using default index config for query pool: {}", e);
158 crate::models::IndexConfig::default()
159 });
160 let threads =
161 crate::models::resolve_thread_count(config.parallel_threads, QUERY_AUTO_THREAD_CAP);
162
163 log::debug!(
164 "Opened index {}: {} files, {} trigrams, {} query threads",
165 cache_dir.display(),
166 content.file_count(),
167 trigrams.trigram_count(),
168 threads
169 );
170
171 Ok(Self {
172 cache_dir,
173 content,
174 trigrams,
175 path_to_id: OnceLock::new(),
176 pool: OnceLock::new(),
177 threads,
178 posting_cap: config.max_posting_list_entries,
179 fingerprint,
180 meta: OnceLock::new(),
181 fingerprints: OnceLock::new(),
182 })
183 }
184
185 pub fn fingerprints(
187 &self,
188 cache: &CacheManager,
189 ) -> Result<Arc<HashMap<String, crate::cache::FileFingerprint>>> {
190 if let Some(fp) = self.fingerprints.get() {
191 return Ok(Arc::clone(fp));
192 }
193 let loaded = Arc::new(cache.load_fingerprints()?);
194 let _ = self.fingerprints.set(Arc::clone(&loaded));
196 Ok(Arc::clone(self.fingerprints.get().expect("set above")))
197 }
198
199 pub fn meta_conn(&self) -> Result<std::sync::MutexGuard<'_, rusqlite::Connection>> {
206 if self.meta.get().is_none() {
207 let conn = crate::cache::open_meta_db(self.cache_dir.join(crate::cache::META_DB))
208 .context("Failed to open meta.db")?;
209 crate::symbol_cache::SymbolCache::ensure_schema(&conn)
210 .context("Failed to initialise the symbol cache schema")?;
211 let _ = self.meta.set(Mutex::new(conn));
213 }
214 let m = self.meta.get().expect("set above");
215 Ok(m.lock().unwrap_or_else(|poisoned| poisoned.into_inner()))
216 }
217
218 pub fn file_count(&self) -> usize {
220 self.content.file_count()
221 }
222
223 pub fn file_id_for(&self, path: &str) -> Option<u32> {
225 let normalized = path.strip_prefix("./").unwrap_or(path);
226 let map = self.path_to_id.get_or_init(|| {
227 let mut map = HashMap::with_capacity(self.content.file_count());
228 for id in 0..self.content.file_count() {
229 if let Some(p) = self
230 .content
231 .get_file_path(id as u32)
232 .and_then(|p| p.to_str())
233 {
234 map.entry(p.strip_prefix("./").unwrap_or(p).to_string())
235 .or_insert(id as u32);
236 }
237 }
238 map
239 });
240 map.get(normalized).copied()
241 }
242
243 pub fn path_of(&self, file_id: u32) -> Option<&str> {
245 self.content
246 .get_file_path(file_id)
247 .and_then(|p| p.to_str())
248 .map(|p| p.strip_prefix("./").unwrap_or(p))
249 }
250
251 pub fn pool(&self) -> &rayon::ThreadPool {
253 self.pool.get_or_init(|| {
254 rayon::ThreadPoolBuilder::new()
255 .num_threads(self.threads)
256 .thread_name(|i| format!("rfx-query-{i}"))
257 .build()
258 .unwrap_or_else(|e| {
259 log::warn!("Failed to create query thread pool: {}; using default", e);
262 rayon::ThreadPoolBuilder::new()
263 .num_threads(1)
264 .build()
265 .expect("a single-thread pool")
266 })
267 })
268 }
269
270 pub fn posting_cap(&self) -> usize {
272 self.posting_cap
273 }
274
275 pub fn cache_dir(&self) -> &Path {
277 &self.cache_dir
278 }
279}
280
281fn registry() -> &'static Mutex<HashMap<PathBuf, Arc<OpenIndex>>> {
282 static REGISTRY: OnceLock<Mutex<HashMap<PathBuf, Arc<OpenIndex>>>> = OnceLock::new();
283 REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
284}
285
286fn registry_key(cache_dir: &Path) -> PathBuf {
287 cache_dir
288 .canonicalize()
289 .unwrap_or_else(|_| cache_dir.to_path_buf())
290}
291
292pub fn get_or_open(cache: &CacheManager) -> Result<Arc<OpenIndex>> {
297 let cache_dir = cache.path();
298 let key = registry_key(cache_dir);
299
300 let fingerprint = match Fingerprint::current(cache_dir) {
301 Ok(fp) => fp,
302 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
303 return Err(ReflexError::CacheCorrupted(format!(
304 "content.bin: missing from {}",
305 cache_dir.display()
306 ))
307 .into());
308 }
309 Err(e) => return Err(e).context("Failed to stat index files"),
310 };
311
312 if let Ok(map) = registry().lock()
313 && let Some(existing) = map.get(&key)
314 && existing.fingerprint == fingerprint
315 {
316 return Ok(Arc::clone(existing));
317 }
318
319 let opened = Arc::new(OpenIndex::open(cache, fingerprint)?);
320 if let Ok(mut map) = registry().lock() {
321 map.insert(key, Arc::clone(&opened));
322 }
323 Ok(opened)
324}
325
326pub fn invalidate(cache_dir: &Path) {
330 let key = registry_key(cache_dir);
331 if let Ok(mut map) = registry().lock() {
332 map.remove(&key);
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use crate::indexer::Indexer;
340 use crate::models::IndexConfig;
341 use tempfile::TempDir;
342
343 fn indexed_project() -> TempDir {
344 let temp = TempDir::new().unwrap();
345 std::fs::write(temp.path().join("a.rs"), "fn alpha() {}\n").unwrap();
346 std::fs::write(temp.path().join("b.rs"), "fn beta() { alpha() }\n").unwrap();
347 Indexer::new(CacheManager::new(temp.path()), IndexConfig::default())
348 .index(temp.path(), false)
349 .unwrap();
350 temp
351 }
352
353 #[test]
354 fn second_lookup_returns_same_handle() {
355 let temp = indexed_project();
356 let cache = CacheManager::new(temp.path());
357 let first = get_or_open(&cache).unwrap();
358 let second = get_or_open(&cache).unwrap();
359 assert!(Arc::ptr_eq(&first, &second));
360 }
361
362 #[test]
363 fn file_id_lookup_ignores_dot_slash() {
364 let temp = indexed_project();
365 let open = get_or_open(&CacheManager::new(temp.path())).unwrap();
366 let id = open.file_id_for("a.rs").expect("a.rs is indexed");
367 assert_eq!(open.file_id_for("./a.rs"), Some(id));
368 assert_eq!(open.path_of(id), Some("a.rs"));
369 assert_eq!(open.file_id_for("missing.rs"), None);
370 }
371
372 #[test]
373 fn reindex_yields_new_handle() {
374 let temp = indexed_project();
375 let cache = CacheManager::new(temp.path());
376 let first = get_or_open(&cache).unwrap();
377 assert_eq!(first.file_count(), 2);
378
379 std::fs::write(temp.path().join("c.rs"), "fn gamma() {}\n").unwrap();
380 Indexer::new(CacheManager::new(temp.path()), IndexConfig::default())
381 .index(temp.path(), false)
382 .unwrap();
383
384 let second = get_or_open(&cache).unwrap();
385 assert!(!Arc::ptr_eq(&first, &second));
386 assert_eq!(second.file_count(), 3);
387 }
388
389 #[test]
390 fn truncated_content_bin_is_reported_as_corruption() {
391 let temp = indexed_project();
392 let content = temp.path().join(".reflex/content.bin");
393 std::fs::OpenOptions::new()
394 .write(true)
395 .open(&content)
396 .unwrap()
397 .set_len(2)
398 .unwrap();
399
400 let err = get_or_open(&CacheManager::new(temp.path())).unwrap_err();
401 let typed = err
402 .downcast_ref::<ReflexError>()
403 .expect("typed CacheCorrupted");
404 assert!(matches!(typed, ReflexError::CacheCorrupted(_)));
405 assert!(err.to_string().contains("content.bin"), "{err}");
406 }
407}