Skip to main content

fff_search/
types.rs

1use std::io::Read;
2use std::path::{Path, PathBuf};
3#[cfg(not(target_os = "windows"))]
4use std::sync::atomic::AtomicPtr;
5use std::sync::atomic::{AtomicI32, AtomicU8, AtomicU64, AtomicUsize, Ordering};
6
7#[cfg(not(target_os = "windows"))]
8use crate::constants::{FRESH_MMAP_THRESHOLD, MMAP_THRESHOLD};
9use crate::constants::{MAX_CACHED_CONTENT_BYTES, MAX_FFFILE_SIZE, PATH_BUF_SIZE};
10use crate::index::constraints::Constrainable;
11use crate::query_tracker::QueryMatchEntry;
12use crate::simd_path::ArenaPtr;
13use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
14
15/// Different sources of the string storage used by FFF
16/// implements as a deduplicated 16-bytes alined heap
17/// can be stored in RAM or on disk
18pub trait FFFStringStorage {
19    /// Resolve the arena for a [`FileItem`] (handles base vs overflow split).
20    fn arena_for(&self, file: &FileItem) -> ArenaPtr;
21
22    /// The base arena (scan-time paths).
23    fn base_arena(&self) -> ArenaPtr;
24    /// The overflow arena (paths added after the last full scan).
25    fn overflow_arena(&self) -> ArenaPtr;
26}
27
28impl FFFStringStorage for ArenaPtr {
29    #[inline]
30    fn arena_for(&self, _file: &FileItem) -> ArenaPtr {
31        *self
32    }
33
34    #[inline]
35    fn base_arena(&self) -> ArenaPtr {
36        *self
37    }
38
39    #[inline]
40    fn overflow_arena(&self) -> ArenaPtr {
41        *self
42    }
43}
44
45pub trait FileSliceExt {
46    fn live_count(&self) -> usize;
47}
48
49impl FileSliceExt for [FileItem] {
50    #[inline]
51    fn live_count(&self) -> usize {
52        self.iter().filter(|f| !f.is_deleted()).count()
53    }
54}
55
56pub struct FileItemFlags;
57
58impl FileItemFlags {
59    pub const BINARY: u8 = 1 << 0;
60    /// Tombstone — file was deleted but index slot is preserved so
61    /// bigram indices for other files stay valid.
62    pub const DELETED: u8 = 1 << 1;
63    /// File was added after the last full reindex; its indices point
64    /// into the overflow builder arena, not the base arena.
65    pub const OVERFLOW: u8 = 1 << 2;
66}
67
68pub struct DirFlags;
69
70impl DirFlags {
71    pub const OVERFLOW: u8 = 1 << 0;
72    pub const DELETED: u8 = 1 << 1;
73}
74
75/// A directory in the file index. Shares chunk arena with file paths.
76#[derive(Debug)]
77pub struct DirItem {
78    flags: u8,
79    pub(crate) path: crate::simd_path::ChunkedString,
80    /// Byte offset where the last path segment begins (e.g. for `src/components/`
81    /// this is 4, pointing to `components/`). Used for dirname-bonus scoring.
82    last_segment_offset: u16,
83    /// Maximum `access_frecency_score` among direct child files.
84    /// Atomic so parallel frecency updates can write directly without juggling.
85    max_access_frecency: AtomicI32,
86}
87
88impl Clone for DirItem {
89    fn clone(&self) -> Self {
90        Self {
91            flags: self.flags,
92            path: self.path.clone(),
93            last_segment_offset: self.last_segment_offset,
94            max_access_frecency: AtomicI32::new(self.max_access_frecency()),
95        }
96    }
97}
98
99impl DirItem {
100    #[inline(always)]
101    pub fn is_overflow(&self) -> bool {
102        self.flags & DirFlags::OVERFLOW != 0
103    }
104
105    #[inline(always)]
106    pub fn is_deleted(&self) -> bool {
107        self.flags & DirFlags::DELETED != 0
108    }
109
110    /// Marks the dir deleted/restored. Returns `true` when the state changed.
111    pub(crate) fn set_deleted(&mut self, deleted: bool) -> bool {
112        if self.is_deleted() == deleted {
113            return false;
114        }
115        if deleted {
116            self.flags |= DirFlags::DELETED;
117        } else {
118            self.flags &= !DirFlags::DELETED;
119        }
120        true
121    }
122
123    pub(crate) fn new(path: crate::simd_path::ChunkedString, last_segment_offset: u16) -> Self {
124        Self {
125            path,
126            flags: 0,
127            last_segment_offset,
128            max_access_frecency: AtomicI32::new(0),
129        }
130    }
131
132    /// A dir appended after the initial scan; its path lives in the overflow arena.
133    pub(crate) fn new_overflow(
134        path: crate::simd_path::ChunkedString,
135        last_segment_offset: u16,
136    ) -> Self {
137        Self {
138            path,
139            flags: DirFlags::OVERFLOW,
140            last_segment_offset,
141            max_access_frecency: AtomicI32::new(0),
142        }
143    }
144
145    /// Byte offset of the last path segment within the directory path.
146    #[inline]
147    pub fn last_segment_offset(&self) -> u16 {
148        self.last_segment_offset
149    }
150
151    /// Current max access frecency score.
152    #[inline]
153    pub fn max_access_frecency(&self) -> i32 {
154        self.max_access_frecency.load(Ordering::Relaxed)
155    }
156
157    /// Atomically update the directory's frecency score if the given score is larger.
158    /// Safe to call from parallel threads.
159    #[inline]
160    pub fn update_frecency_if_larger(&self, score: i32) {
161        self.max_access_frecency.fetch_max(score, Ordering::Relaxed);
162    }
163
164    /// Reset frecency to zero (used before full recomputation).
165    #[inline]
166    pub fn reset_frecency(&self) {
167        self.max_access_frecency.store(0, Ordering::Relaxed);
168    }
169
170    pub(crate) fn read_relative_path<'a>(&self, arena: ArenaPtr, buf: &'a mut [u8]) -> &'a str {
171        self.path.read_to_buf(arena, buf)
172    }
173
174    /// Relative dir path as owned String (cold path).
175    pub fn relative_path(&self, arena: impl FFFStringStorage) -> String {
176        let mut out = String::new();
177        let ptr = if self.is_overflow() {
178            arena.overflow_arena()
179        } else {
180            arena.base_arena()
181        };
182
183        self.path.write_to_string(ptr, &mut out);
184        out
185    }
186
187    /// Write the last segment (dirname) of this directory path to `out`.
188    pub fn write_dir_name(&self, arena: ArenaPtr, out: &mut String) {
189        out.clear();
190        let total = self.path.byte_len as usize;
191        let offset = self.last_segment_offset as usize;
192        if offset >= total {
193            return;
194        }
195        // Read the full path, then slice from last_segment_offset
196        let mut buf = [0u8; PATH_BUF_SIZE];
197        let full = self.path.read_to_buf(arena, &mut buf);
198        out.push_str(&full[offset..]);
199    }
200
201    /// The dirname (last segment) as an owned String. Cold path.
202    pub fn dir_name(&self, arena: impl FFFStringStorage) -> String {
203        let mut out = String::new();
204        let ptr = if self.is_overflow() {
205            arena.overflow_arena()
206        } else {
207            arena.base_arena()
208        };
209        self.write_dir_name(ptr, &mut out);
210        out
211    }
212
213    /// A path = base_path + "/" + relative. Cold path, allocates.
214    pub fn absolute_path(&self, arena: impl FFFStringStorage, base_path: &Path) -> PathBuf {
215        let rel = self.relative_path(arena);
216        if rel.is_empty() {
217            base_path.to_path_buf()
218        } else {
219            base_path.join(&rel)
220        }
221    }
222}
223
224impl Constrainable for DirItem {
225    #[inline]
226    fn write_file_name(&self, arena: ArenaPtr, out: &mut String) {
227        // For dirs, the "file name" equivalent is the last path segment
228        self.write_dir_name(arena, out);
229    }
230
231    #[inline]
232    fn write_relative_path(&self, arena: ArenaPtr, out: &mut String) {
233        self.path.write_to_string(arena, out);
234    }
235
236    #[inline]
237    fn git_status(&self) -> Option<git2::Status> {
238        None
239    }
240
241    #[inline]
242    fn is_overflow(&self) -> bool {
243        DirItem::is_overflow(self)
244    }
245}
246
247#[derive(Debug)]
248pub struct FileItem {
249    pub size: u64,
250    pub modified: u64,
251    pub access_frecency_score: i16,
252    pub modification_frecency_score: i16,
253    pub git_recency_score: i16,
254    pub git_status: Option<git2::Status>,
255    pub(crate) path: crate::simd_path::ChunkedString,
256    pub(crate) parent_dir_index: u32,
257    flags: AtomicU8,
258    /// Lazy mmap cache (boxed, set once). Only populated by the actual file
259    /// read, controlled by the budget. Null while empty.
260    #[cfg(not(target_os = "windows"))]
261    content: AtomicPtr<memmap2::Mmap>,
262}
263
264#[cfg(not(target_os = "windows"))]
265impl Drop for FileItem {
266    fn drop(&mut self) {
267        self.take_content();
268    }
269}
270
271impl Clone for FileItem {
272    fn clone(&self) -> Self {
273        Self {
274            path: self.path.clone(),
275            parent_dir_index: self.parent_dir_index,
276            size: self.size,
277            modified: self.modified,
278            access_frecency_score: self.access_frecency_score,
279            modification_frecency_score: self.modification_frecency_score,
280            git_recency_score: self.git_recency_score,
281            git_status: self.git_status,
282            flags: AtomicU8::new(self.flags.load(Ordering::Relaxed)),
283            // on clone we have to reset the content cache
284            #[cfg(not(target_os = "windows"))]
285            content: AtomicPtr::new(std::ptr::null_mut()),
286        }
287    }
288}
289
290/// Single-block read used by the binary classifier. Most binaries reveal a
291/// NUL byte within the first filesystem block, so 16 KB lets one read settle
292/// the classification for typical files while keeping the scratch buffer
293/// small enough to live on the stack.
294pub const BINARY_CLASSIFICATION_CHUNK_SIZE: usize = 16 * 1024;
295
296/// A file is treated as binary if any NUL byte appears in the scanned prefix.
297#[inline]
298pub(crate) fn detect_binary_content(content: &[u8]) -> bool {
299    memchr::memchr(0, content).is_some()
300}
301
302impl FileItem {
303    pub fn new_raw(
304        filename_start: u16,
305        size: u64,
306        modified: u64,
307        git_status: Option<git2::Status>,
308        is_binary: bool,
309    ) -> Self {
310        let mut flags = 0u8;
311        if is_binary {
312            flags |= FileItemFlags::BINARY;
313        }
314
315        let mut path = crate::simd_path::ChunkedString::empty();
316        path.filename_offset = filename_start;
317
318        Self {
319            path,
320            parent_dir_index: u32::MAX,
321            size,
322            modified,
323            access_frecency_score: 0,
324            modification_frecency_score: 0,
325            git_recency_score: 0,
326            git_status,
327            flags: AtomicU8::new(flags),
328            #[cfg(not(target_os = "windows"))]
329            content: AtomicPtr::new(std::ptr::null_mut()),
330        }
331    }
332
333    /// Returns an absolute path of the file
334    pub fn absolute_path(&self, arena: impl FFFStringStorage, base_path: &Path) -> PathBuf {
335        let mut buf = [0u8; PATH_BUF_SIZE];
336        let rel = self.path.read_to_buf(arena.arena_for(self), &mut buf);
337        base_path.join(rel)
338    }
339
340    pub(crate) fn set_path(&mut self, path: crate::simd_path::ChunkedString) {
341        self.path = path;
342    }
343
344    pub fn dir_str(&self, arena: impl FFFStringStorage) -> String {
345        let mut s = String::with_capacity(64);
346        self.path.write_dir_to(arena.arena_for(self), &mut s);
347        s
348    }
349
350    pub(crate) fn write_dir_str(&self, arena: ArenaPtr, out: &mut String) {
351        self.path.write_dir_to(arena, out);
352    }
353
354    pub fn file_name(&self, arena: impl FFFStringStorage) -> String {
355        let mut s = String::with_capacity(32);
356        self.path.write_filename_to(arena.arena_for(self), &mut s);
357        s
358    }
359
360    pub(crate) fn write_file_name_from_arena(&self, arena: ArenaPtr, out: &mut String) {
361        self.path.write_filename_to(arena, out);
362    }
363
364    pub fn relative_path(&self, arena: impl FFFStringStorage) -> String {
365        let mut s = String::with_capacity(64);
366        self.path.write_to_string(arena.arena_for(self), &mut s);
367        s
368    }
369
370    pub(crate) fn write_relative_path_from_arena(&self, arena: ArenaPtr, out: &mut String) {
371        self.path.write_to_string(arena, out);
372    }
373
374    pub fn relative_path_len(&self) -> usize {
375        self.path.byte_len as usize
376    }
377
378    pub fn filename_offset_in_relative_path(&self) -> usize {
379        self.path.filename_offset as usize
380    }
381
382    pub(crate) fn relative_path_eq(&self, arena: ArenaPtr, other: &str) -> bool {
383        if other.len() != self.path.byte_len as usize {
384            return false;
385        }
386        let mut buf = [0u8; PATH_BUF_SIZE];
387        let mine = self.path.read_to_buf(arena, &mut buf);
388        mine == other
389    }
390
391    pub(crate) fn relative_path_starts_with(&self, arena: ArenaPtr, prefix: &str) -> bool {
392        let mut buf = [0u8; PATH_BUF_SIZE];
393        let path = self.path.read_to_buf(arena, &mut buf);
394        path.starts_with(prefix)
395    }
396
397    /// Write `base_path + '/' + relative_path` into `buf` and return it
398    /// as `&Path`. Takes a fixed-size array so the buffer can live on
399    /// the stack (no heap allocation, no bounds checks in the hot loop).
400    pub(crate) fn write_absolute_path<'a>(
401        &self,
402        arena: ArenaPtr,
403        base_path: &Path,
404        buf: &'a mut [u8; PATH_BUF_SIZE],
405    ) -> &'a Path {
406        let base = base_path.as_os_str().as_encoded_bytes();
407        let base_len = base.len();
408        buf[..base_len].copy_from_slice(base);
409        let sep_len = if base_len > 0 && base[base_len - 1] != std::path::MAIN_SEPARATOR as u8 {
410            buf[base_len] = std::path::MAIN_SEPARATOR as u8;
411            1
412        } else {
413            0
414        };
415
416        let base_end_idx = base_len + sep_len;
417        let relative_portion_str = self.path.read_to_buf(arena, &mut buf[base_end_idx..]);
418        let rel_len = relative_portion_str.len();
419        let total = base_end_idx + rel_len;
420        // Stored relative paths are '/'-canonical; rewrite to the OS-native
421        // separator so the result matches git-cache keys, the frecency DB, and
422        // Win32 file APIs. No-op off Windows.
423        crate::path_utils::nativize_slashes_in_place(&mut buf[base_end_idx..total]);
424        Path::new(unsafe { std::str::from_utf8_unchecked(&buf[..total]) })
425    }
426
427    /// Write the relative path into `buf` and NUL-terminate, returning
428    /// a `&CStr`. Fixed-size array so the buffer is stack-allocatable.
429    ///
430    /// Paired with a parent-directory fd this eliminates the per-file
431    /// absolute-path memcpy: `openat(dir_fd, cstr.as_ptr(), O_RDONLY)`
432    /// resolves the name relative to `dir_fd`. Unix-only.
433    #[cfg(unix)]
434    pub(crate) fn write_relative_cstr<'a>(
435        &self,
436        arena: ArenaPtr,
437        buf: &'a mut [u8; PATH_BUF_SIZE],
438    ) -> &'a std::ffi::CStr {
439        // Reserve the last byte for the NUL terminator.
440        let rel = self.path.read_to_buf(arena, &mut buf[..PATH_BUF_SIZE - 1]);
441        let n = rel.len();
442        buf[n] = 0;
443        // SAFETY: `buf[..=n]` ends with the NUL we just wrote and
444        // filesystem paths never contain interior NULs.
445        unsafe { std::ffi::CStr::from_bytes_with_nul_unchecked(&buf[..=n]) }
446    }
447
448    #[inline]
449    pub fn total_frecency_score(&self) -> i32 {
450        self.access_frecency_score as i32 + self.modification_frecency_score as i32
451    }
452
453    #[allow(dead_code)]
454    #[inline]
455    pub(crate) fn is_likely_hot(&self) -> bool {
456        self.access_frecency_score > 0 || self.git_status.is_some()
457    }
458
459    /// Reads a fixed bytes count from the file optimized for quick speed of opening
460    #[inline]
461    pub(crate) fn read_trimmed_into_buf(
462        &self,
463        base_fd: i32,
464        base_path: &Path,
465        arena: ArenaPtr,
466        path_buf: &mut [u8; PATH_BUF_SIZE],
467        buf: &mut [u8],
468    ) -> usize {
469        #[cfg(unix)]
470        {
471            self.read_into_buf_unix(base_fd, base_path, arena, path_buf, buf)
472        }
473        #[cfg(not(unix))]
474        {
475            let _ = base_fd;
476            self.read_into_buf_std(base_path, arena, path_buf, buf)
477        }
478    }
479
480    #[cfg(unix)]
481    fn read_into_buf_unix(
482        &self,
483        base_fd: libc::c_int,
484        base_path: &Path,
485        arena: ArenaPtr,
486        path_buf: &mut [u8; PATH_BUF_SIZE],
487        buf: &mut [u8],
488    ) -> usize {
489        let fd = if base_fd >= 0 {
490            let relative_path = self.write_relative_cstr(arena, path_buf);
491            // SAFETY: `relative_path` is NUL-terminated, `base_fd` is a
492            // valid directory descriptor owned by the caller.
493            unsafe { libc::openat(base_fd, relative_path.as_ptr(), libc::O_RDONLY) }
494        } else {
495            use std::os::unix::io::IntoRawFd;
496            let abs = self.write_absolute_path(arena, base_path, path_buf);
497            match std::fs::File::open(abs) {
498                Ok(f) => f.into_raw_fd(),
499                Err(e) => {
500                    tracing::error!(?e, "Failed to fopen file");
501                    return 0;
502                }
503            }
504        };
505        if fd < 0 {
506            return 0;
507        }
508
509        let mut filled = 0usize;
510        while filled < buf.len() {
511            // SAFETY: `fd` is an owned descriptor, `buf[filled..]` is a
512            // valid writable slice for `buf.len() - filled` bytes.
513            let n = unsafe {
514                libc::read(
515                    fd,
516                    buf[filled..].as_mut_ptr() as *mut libc::c_void,
517                    (buf.len() - filled) as libc::size_t,
518                )
519            };
520            if n <= 0 {
521                break;
522            }
523            filled += n as usize;
524        }
525
526        // SAFETY: matching close for the owned descriptor.
527        unsafe { libc::close(fd) };
528        filled
529    }
530
531    #[cfg(not(unix))]
532    fn read_into_buf_std(
533        &self,
534        base_path: &Path,
535        arena: ArenaPtr,
536        path_buf: &mut [u8; PATH_BUF_SIZE],
537        buf: &mut [u8],
538    ) -> usize {
539        let abs = self.write_absolute_path(arena, base_path, path_buf);
540        let Ok(mut f) = std::fs::File::open(abs) else {
541            return 0;
542        };
543        let mut filled = 0usize;
544        while filled < buf.len() {
545            match f.read(&mut buf[filled..]) {
546                Ok(0) => break,
547                Ok(n) => filled += n,
548                Err(_) => return 0,
549            }
550        }
551        filled
552    }
553
554    #[inline]
555    pub fn is_binary(&self) -> bool {
556        self.flags.load(Ordering::Relaxed) & FileItemFlags::BINARY != 0
557    }
558
559    #[inline]
560    pub fn set_binary(&self, val: bool) {
561        if val {
562            self.flags
563                .fetch_or(FileItemFlags::BINARY, Ordering::Relaxed);
564        } else {
565            self.flags
566                .fetch_and(!FileItemFlags::BINARY, Ordering::Relaxed);
567        }
568    }
569
570    /// Chunked classifier of the binary content of the file chunk by chunk
571    /// accepts path which to reuse the allocated buffer for absolute path read
572    pub(crate) fn detect_binary_per_byte(&self, path: &Path, chunk: &mut [u8]) {
573        if self.size == 0 {
574            return;
575        }
576
577        let Ok(mut file) = std::fs::OpenOptions::new()
578            .write(false)
579            .read(true)
580            .open(path)
581        else {
582            tracing::error!(path = ?path.display(), "Failed to open indexed file");
583            return;
584        };
585
586        loop {
587            match file.read(chunk) {
588                Ok(0) => break,
589                Err(e) => {
590                    tracing::error!(?e, "Failed to read file chunk");
591                    break;
592                }
593                Ok(n) => {
594                    if detect_binary_content(&chunk[..n]) {
595                        self.set_binary(true);
596                    }
597                }
598            }
599        }
600    }
601
602    #[inline]
603    pub fn is_deleted(&self) -> bool {
604        self.flags.load(Ordering::Relaxed) & FileItemFlags::DELETED != 0
605    }
606
607    #[inline]
608    #[doc(hidden)]
609    /// Don't use it, use FilePicker::delete_file
610    pub fn set_deleted(&self, val: bool) {
611        if val {
612            self.flags
613                .fetch_or(FileItemFlags::DELETED, Ordering::Relaxed);
614        } else {
615            self.flags
616                .fetch_and(!FileItemFlags::DELETED, Ordering::Relaxed);
617        }
618    }
619
620    #[inline]
621    pub fn is_overflow(&self) -> bool {
622        self.flags.load(Ordering::Relaxed) & FileItemFlags::OVERFLOW != 0
623    }
624
625    #[inline]
626    pub fn set_overflow(&self, val: bool) {
627        if val {
628            self.flags
629                .fetch_or(FileItemFlags::OVERFLOW, Ordering::Relaxed);
630        } else {
631            self.flags
632                .fetch_and(!FileItemFlags::OVERFLOW, Ordering::Relaxed);
633        }
634    }
635}
636
637impl FileItem {
638    /// Invalidate the cached mmap content, has to be called every time the file is updated.
639    ///
640    /// Call this when the background watcher detects that the file has been modified.
641    /// On Unix, a file that is truncated while mapped can cause SIGBUS. On Windows,
642    /// the stale buffer simply won't reflect the new contents. In both cases,
643    /// invalidating ensures a fresh read on the next access.
644    #[cfg(not(target_os = "windows"))]
645    pub fn invalidate_mmap(&mut self, budget: &ContentCacheBudget) {
646        if self.take_content().is_some() {
647            budget.cached_count.fetch_sub(1, Ordering::Relaxed);
648            budget.cached_bytes.fetch_sub(self.size, Ordering::Relaxed);
649        }
650    }
651
652    #[cfg(not(target_os = "windows"))]
653    #[inline]
654    fn cached_content(&self) -> Option<&[u8]> {
655        let ptr = self.content.load(Ordering::Acquire);
656        // SAFETY: a non-null pointer is a leaked Box owned by this item until `take_content`.
657        (!ptr.is_null()).then(|| unsafe { (&*ptr).as_ref() })
658    }
659
660    #[cfg(not(target_os = "windows"))]
661    fn take_content(&mut self) -> Option<Box<memmap2::Mmap>> {
662        let ptr = self.content.swap(std::ptr::null_mut(), Ordering::AcqRel);
663        // SAFETY: non-null pointers always come from `Box::into_raw` in `get_cached_content`.
664        (!ptr.is_null()).then(|| unsafe { Box::from_raw(ptr) })
665    }
666
667    #[cfg(target_os = "windows")]
668    pub fn invalidate_mmap(&mut self, _: &ContentCacheBudget) {}
669
670    pub fn update_metadata(
671        &mut self,
672        budget: &ContentCacheBudget,
673        modified_secs: Option<u64>,
674        new_size: Option<u64>,
675    ) {
676        if let Some(modified) = modified_secs
677            && self.modified < modified
678        {
679            self.modified = modified;
680        }
681
682        self.invalidate_mmap(budget);
683
684        if let Some(size) = new_size {
685            self.size = size;
686        }
687    }
688
689    /// Get the cached file contents or lazily load and cache them.
690    ///
691    /// Returns `None` if the file is too large, empty, can't be opened, **or
692    /// the cache budget is exhausted**. Callers that need content regardless
693    /// of the budget should use [`get_content_for_search`].
694    ///
695    /// After the first call, this is lock-free (just an atomic load + pointer deref).
696    ///
697    /// On Windows we never back this cache — `memmap2` would require a full
698    /// `std::fs::read` heap copy and the OS page cache already absorbs repeat
699    /// reopens. Returning `None` keeps callers on the scratch-read slow path
700    /// and avoids duplicating every indexed file on the heap.
701    #[cfg(target_os = "windows")]
702    pub(crate) fn get_cached_content(
703        &self,
704        _arena: ArenaPtr,
705        _base_path: &Path,
706        _budget: &ContentCacheBudget,
707    ) -> Option<&[u8]> {
708        None
709    }
710
711    /// Returns a reference to a cached mmap of the file's contents.
712    ///
713    /// SAFETY-CRITICAL: callers must hold the picker read lock for as long as the returned slice is in use.
714    #[cfg(not(target_os = "windows"))]
715    pub(crate) fn get_cached_content(
716        &self,
717        arena: ArenaPtr,
718        base_path: &Path,
719        budget: &ContentCacheBudget,
720    ) -> Option<&[u8]> {
721        if let Some(content) = self.cached_content() {
722            return Some(content);
723        }
724
725        if self.size < MMAP_THRESHOLD || self.size > budget.max_file_size {
726            return None;
727        }
728
729        // Check cache budget before creating a new persistent cache entry.
730        let count = budget.cached_count.load(Ordering::Relaxed);
731        let bytes = budget.cached_bytes.load(Ordering::Relaxed);
732        let max_files = budget.max_files;
733        let max_bytes = budget.max_bytes;
734        if count >= max_files || bytes + self.size > max_bytes {
735            return None;
736        }
737
738        let path = self.absolute_path(arena, base_path);
739        let file = std::fs::File::open(&path).ok()?;
740        // SAFETY: the mmap is backed by the kernel page cache and reflects
741        // file updates; the only risk is SIGBUS on a concurrent truncate,
742        // which the watcher mitigates by invalidating on modification.
743        let mmap = unsafe { memmap2::Mmap::map(&file) }.ok()?;
744        let fresh = Box::into_raw(Box::new(mmap));
745        match self.content.compare_exchange(
746            std::ptr::null_mut(),
747            fresh,
748            Ordering::AcqRel,
749            Ordering::Acquire,
750        ) {
751            Ok(_) => {
752                budget.cached_count.fetch_add(1, Ordering::Relaxed);
753                budget.cached_bytes.fetch_add(self.size, Ordering::Relaxed);
754            }
755            // Lost the race: another thread cached first, keep theirs.
756            Err(_) => drop(unsafe { Box::from_raw(fresh) }),
757        }
758
759        self.cached_content()
760    }
761
762    /// Get file content for searching — **always returns content** for eligible
763    /// files, even when the persistent cache budget is exhausted.
764    ///
765    /// The caller provides a reusable `path_buf` (pre-filled with `base_path/`)
766    /// and its `base_len` to avoid allocations when constructing the absolute path.
767    #[inline]
768    pub(crate) fn get_content_for_search<'a>(
769        &'a self,
770        buf: &'a mut Vec<u8>,
771        #[cfg_attr(target_os = "windows", allow(unused_variables))] mmap_slot: &'a mut MmapSlot,
772        arena: ArenaPtr,
773        base_path: &Path,
774        budget: &ContentCacheBudget,
775    ) -> Option<&'a [u8]> {
776        #[cfg(not(target_os = "windows"))]
777        {
778            // Fast path: persistent cache hit (zero-copy). Safe here because
779            // grep callers hold the picker read lock for the lifetime of the
780            // returned slice — see [`Self::get_cached_content`] safety note.
781            if let Some(cached) = self.get_cached_content(arena, base_path, budget) {
782                return Some(cached);
783            }
784        }
785
786        let max_file_size = budget.max_file_size;
787        if self.is_binary() || self.size == 0 || self.size > max_file_size {
788            return None;
789        }
790
791        let abs = self.absolute_path(arena, base_path);
792
793        #[cfg(not(target_os = "windows"))]
794        if self.size >= FRESH_MMAP_THRESHOLD {
795            let file = std::fs::File::open(&abs).ok()?;
796            let mmap = unsafe { memmap2::Mmap::map(&file) }.ok()?;
797            let stored = mmap_slot.insert(mmap);
798            return Some(&stored[..]);
799        } else {
800            let _ = (mmap_slot, arena);
801        }
802
803        let len = self.size as usize;
804        buf.resize(len, 0);
805
806        let mut file = std::fs::File::open(&abs).ok()?;
807        file.read_exact(buf).ok()?;
808        Some(buf.as_slice())
809    }
810}
811
812/// Per-thread scratch slot owning a transient mmap returned from
813/// [`FileItem::get_content_for_search`]. `Option<Mmap>` on Unix,
814/// unit on Windows where mmap is unused.
815#[cfg(not(target_os = "windows"))]
816pub type MmapSlot = Option<memmap2::Mmap>;
817#[cfg(target_os = "windows")]
818pub type MmapSlot = ();
819
820impl Constrainable for FileItem {
821    #[inline]
822    fn write_file_name(&self, arena: ArenaPtr, out: &mut String) {
823        self.path.write_filename_to(arena, out);
824    }
825
826    #[inline]
827    fn write_relative_path(&self, arena: ArenaPtr, out: &mut String) {
828        self.path.write_to_string(arena, out);
829    }
830
831    #[inline]
832    fn git_status(&self) -> Option<git2::Status> {
833        self.git_status
834    }
835
836    #[inline]
837    fn is_overflow(&self) -> bool {
838        FileItem::is_overflow(self)
839    }
840}
841
842#[derive(Debug, Clone, Default)]
843pub struct Score {
844    pub total: i32,
845    pub base_score: i32,
846    pub filename_bonus: i32,
847    pub special_filename_bonus: i32,
848    pub frecency_boost: i32,
849    pub git_status_boost: i32,
850    pub git_recency_boost: i32,
851    pub distance_penalty: i32,
852    pub current_file_penalty: i32,
853    pub combo_match_boost: i32,
854    pub path_alignment_bonus: i32,
855    pub exact_match: bool,
856    pub match_type: &'static str,
857}
858
859#[derive(Debug, Clone, Copy)]
860pub struct PaginationArgs {
861    pub offset: usize,
862    pub limit: usize,
863}
864
865impl Default for PaginationArgs {
866    fn default() -> Self {
867        Self {
868            offset: 0,
869            limit: 100,
870        }
871    }
872}
873
874#[derive(Debug, Clone)]
875pub struct ScoringContext<'a> {
876    pub query: &'a FFFQuery<'a>,
877    pub project_path: Option<&'a Path>,
878    pub current_file: Option<&'a str>,
879    pub max_typos: u16,
880    pub max_threads: usize,
881    pub last_same_query_match: Option<QueryMatchEntry>,
882    pub combo_boost_score_multiplier: i32,
883    pub min_combo_count: u32,
884    pub pagination: PaginationArgs,
885}
886
887impl ScoringContext<'_> {
888    pub fn effective_query(&self) -> &str {
889        match &self.query.fuzzy_query {
890            FuzzyQuery::Text(t) => t,
891            FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
892            _ => self.query.raw_query.trim(),
893        }
894    }
895}
896
897#[derive(Debug, Clone, Default)]
898pub struct SearchResult<'a> {
899    pub items: Vec<&'a FileItem>,
900    pub scores: Vec<Score>,
901    pub match_byte_offsets: Vec<smallvec::SmallVec<[(u32, u32); 4]>>,
902    pub total_matched: usize,
903    pub total_files: usize,
904    pub location: Option<Location>,
905}
906
907/// Search result for directory-only fuzzy search.
908#[derive(Debug, Clone, Default)]
909pub struct DirSearchResult<'a> {
910    pub items: Vec<&'a DirItem>,
911    pub scores: Vec<Score>,
912    pub total_matched: usize,
913    pub total_dirs: usize,
914}
915
916/// A single item in a mixed (files + directories) search result.
917#[derive(Debug, Clone)]
918pub enum MixedItemRef<'a> {
919    File(&'a FileItem),
920    Dir(&'a DirItem),
921}
922
923/// Search result for mixed (files + directories) fuzzy search.
924/// Items are interleaved by total score in descending order.
925#[derive(Debug, Clone, Default)]
926pub struct MixedSearchResult<'a> {
927    pub items: Vec<MixedItemRef<'a>>,
928    pub scores: Vec<Score>,
929    pub total_matched: usize,
930    pub total_files: usize,
931    pub total_dirs: usize,
932    pub location: Option<Location>,
933}
934
935impl Default for MixedItemRef<'_> {
936    fn default() -> Self {
937        // Should never be used, exists only for Default derive on MixedSearchResult
938        unreachable!("MixedItemRef::default should not be called")
939    }
940}
941
942#[derive(Debug)]
943pub struct ContentCacheBudget {
944    pub max_files: usize,
945    pub max_bytes: u64,
946    pub max_file_size: u64,
947    pub cached_count: AtomicUsize,
948    pub cached_bytes: AtomicU64,
949}
950
951impl ContentCacheBudget {
952    pub fn unlimited() -> Self {
953        Self {
954            max_files: usize::MAX,
955            max_bytes: u64::MAX,
956            max_file_size: MAX_FFFILE_SIZE,
957            cached_count: AtomicUsize::new(0),
958            cached_bytes: AtomicU64::new(0),
959        }
960    }
961
962    pub fn zero() -> Self {
963        Self {
964            max_files: 0,
965            max_bytes: 0,
966            max_file_size: 0,
967            cached_count: AtomicUsize::new(0),
968            cached_bytes: AtomicU64::new(0),
969        }
970    }
971
972    // Byte budget
973    pub fn is_exhausted(&self) -> bool {
974        self.cached_count.load(Ordering::Relaxed) >= self.max_files
975            || self.cached_bytes.load(Ordering::Relaxed) >= self.max_bytes
976    }
977
978    pub fn new_for_repo(file_count: usize) -> Self {
979        let max_files = if file_count > 50_000 {
980            5_000
981        } else if file_count > 10_000 {
982            10_000
983        } else {
984            30_000 // effectively unlimited for small repos
985        };
986
987        let max_bytes = if file_count > 50_000 {
988            128 * 1024 * 1024 // 128 MB
989        } else if file_count > 10_000 {
990            256 * 1024 * 1024 // 256 MB
991        } else {
992            MAX_CACHED_CONTENT_BYTES // 512 MB
993        };
994
995        Self {
996            max_files,
997            max_bytes,
998            max_file_size: MAX_FFFILE_SIZE,
999            cached_count: AtomicUsize::new(0),
1000            cached_bytes: AtomicU64::new(0),
1001        }
1002    }
1003
1004    /// Apply an explicit file cap verbatim, keeping the default byte caps.
1005    /// `0` means no persistent caching at all — files stay searchable through
1006    /// the temporary mmaps that grep releases after each call.
1007    pub fn with_max_files(max_files: usize) -> Self {
1008        Self {
1009            max_files,
1010            ..Self::default()
1011        }
1012    }
1013
1014    /// Build a budget from caller-supplied overrides.
1015    ///
1016    /// Each argument is a cap; `0` means "use the library default for that
1017    /// cap" (inherits from [`Self::default`], which is `new_for_repo(30_000)`).
1018    /// Returns `None` when every cap is `0`, signalling to the picker that it
1019    /// should auto-size the budget from the final scanned file count rather
1020    /// than applying an explicit override.
1021    pub fn from_overrides(max_files: usize, max_bytes: u64, max_file_size: u64) -> Option<Self> {
1022        if max_files == 0 && max_bytes == 0 && max_file_size == 0 {
1023            return None;
1024        }
1025
1026        let mut budget = Self::default();
1027        if max_files > 0 {
1028            budget.max_files = max_files;
1029        }
1030        if max_bytes > 0 {
1031            budget.max_bytes = max_bytes;
1032        }
1033        if max_file_size > 0 {
1034            budget.max_file_size = max_file_size;
1035        }
1036        Some(budget)
1037    }
1038
1039    pub fn reset(&self) {
1040        self.cached_count.store(0, Ordering::Relaxed);
1041        self.cached_bytes.store(0, Ordering::Relaxed);
1042    }
1043}
1044
1045impl Default for ContentCacheBudget {
1046    fn default() -> Self {
1047        Self::new_for_repo(30_000)
1048    }
1049}
1050
1051#[cfg(test)]
1052mod content_cache_budget_tests {
1053    use super::*;
1054
1055    #[test]
1056    fn with_max_files_applies_the_cap_verbatim() {
1057        // regression: the cap used to be routed through new_for_repo, which
1058        // read it as a repo file count and bucketed 2000 up to 30_000
1059        assert_eq!(ContentCacheBudget::with_max_files(2000).max_files, 2000);
1060        assert_eq!(ContentCacheBudget::with_max_files(7).max_files, 7);
1061        assert_eq!(
1062            ContentCacheBudget::with_max_files(1_000_000).max_files,
1063            1_000_000
1064        );
1065    }
1066
1067    #[test]
1068    fn with_max_files_zero_disables_persistent_caching_but_keeps_grep() {
1069        let budget = ContentCacheBudget::with_max_files(0);
1070        assert_eq!(budget.max_files, 0);
1071        assert!(budget.is_exhausted());
1072        // temporary-mmap grep is gated on max_file_size, which must survive
1073        assert_eq!(budget.max_file_size, MAX_FFFILE_SIZE);
1074        assert!(budget.max_bytes > 0);
1075    }
1076
1077    #[test]
1078    fn with_max_files_keeps_default_byte_caps() {
1079        let budget = ContentCacheBudget::with_max_files(2000);
1080        let default = ContentCacheBudget::default();
1081        assert_eq!(budget.max_bytes, default.max_bytes);
1082        assert_eq!(budget.max_file_size, default.max_file_size);
1083    }
1084}