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