Skip to main content

fff_search/
file_picker.rs

1//! Core file picker: filesystem indexing, background watching, and fuzzy search.
2//!
3//! [`FilePicker`] is the central component of fff-search. It:
4//!
5//! 1. **Indexes** a directory tree in a background thread, collecting every
6//!    non-ignored file into a path-sorted `Vec<FileItem>`.
7//! 2. **Watches** the filesystem via the `notify` crate, applying
8//!    create/modify/delete events to the index in real time.
9//! 3. **Owns files**: Provides a values for search and provides a good entry point for
10//!    fuzzy search and live grep
11//!
12//! # Lifecycle
13//!
14//! ```text
15//!   new_with_shared_state()
16//!     │
17//!     ├─> background scan thread ──> populates SharedPicker
18//!     └─> file-system watcher    ──> live updates SharedPicker
19//!
20//!   search()         <── borrows &self, delegates to fuzzy_search
21//!   grep()           <── static, borrows &[FileItem] (live content search)
22//!   trigger_rescan() <── synchronous re-index
23//!   cancel()         <── shuts down background work
24//! ```
25//!
26//! # Thread Safety
27//!
28//! `FilePicker` itself is **not** `Sync`!
29//! all concurrent access goes through [`SharedPicker`](crate::SharedPicker) .
30//! The background scanner and watcher acquire write locks only when mutating
31//! the file index, so read-heavy search workloads rarely contend.
32
33use crate::FFFStringStorage;
34use crate::background_watcher::{BackgroundWatcher, is_git_file};
35use crate::bigram_filter::{BigramFilter, BigramOverlay};
36use crate::constants::{MAX_OVERFLOW_FILES, PATH_BUF_SIZE};
37use crate::error::Error;
38use crate::frecency::FrecencyTracker;
39use crate::git::GitStatusCache;
40use crate::grep::{GrepResult, GrepSearchOptions, grep_search, multi_grep_search};
41use crate::ignore::non_git_repo_overrides;
42use crate::query_tracker::QueryTracker;
43use crate::scan::{ScanConfig, ScanJob, ScanSignals};
44use crate::score::fuzzy_match_and_score_files;
45use crate::shared::{SharedFilePicker, SharedFrecency};
46use crate::simd_path::ArenaPtr;
47use crate::stable_vec::StableVec;
48use crate::types::{
49    ContentCacheBudget, DirItem, DirSearchResult, FileItem, MixedItemRef, MixedSearchResult,
50    PaginationArgs, Score, ScoringContext, SearchResult,
51};
52use fff_query_parser::FFFQuery;
53use git2::{Repository, Status};
54use rayon::prelude::*;
55use std::fmt::Debug;
56use std::ops::ControlFlow;
57use std::path::{Path, PathBuf};
58use std::sync::{
59    Arc,
60    atomic::{AtomicBool, AtomicUsize, Ordering},
61};
62use std::thread::JoinHandle;
63use std::time::SystemTime;
64use tracing::{Level, debug, error, info, warn};
65
66use crate::parallelism::{BACKGROUND_THREAD_POOL, SEARCH_THREAD_POOL};
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub enum FFFMode {
70    #[default]
71    Neovim,
72    Ai,
73}
74
75impl FFFMode {
76    pub fn is_ai(self) -> bool {
77        self == FFFMode::Ai
78    }
79}
80
81/// Configuration for a single fuzzy search invocation.
82///
83/// Passed to [`FilePicker::search`] to control threading, pagination,
84/// and scoring behavior.
85#[derive(Debug, Clone, Copy, Default)]
86pub struct FuzzySearchOptions<'a> {
87    pub max_threads: usize,
88    pub current_file: Option<&'a str>,
89    pub project_path: Option<&'a Path>,
90    pub combo_boost_score_multiplier: i32,
91    pub min_combo_count: u32,
92    pub pagination: PaginationArgs,
93}
94
95#[derive(Debug, Clone)]
96pub(crate) struct FileSync {
97    pub(crate) git_workdir: Option<PathBuf>,
98    /// Base files laid out in two partitions, each internally sorted by
99    /// (parent_dir, filename):
100    ///   `files[..indexable_count]` - indexable
101    ///   `files[indexable_count..base_count]` - original-unindexable
102    ///   `files[base_count..]`— overflow (created on demand)
103    files: StableVec<FileItem>,
104    indexable_count: usize,
105    base_count: usize,
106    /// Number of active present files that exists in the file system
107    pub(crate) live_count: usize,
108    /// Sorted directory table. `StableVec` so post-scan snapshots can keep
109    /// the allocation alive across a picker drop without copying, and so
110    /// concurrent readers observe a consistent view via the same shared
111    /// allocation. Dir frecency is updated through the per-entry atomic
112    /// (`DirItem::max_access_frecency`) without `&mut` aliasing.
113    dirs: StableVec<DirItem>,
114    /// Shared builder for overflow file paths. Each overflow file's ChunkedString
115    /// uses `arena_override` pointing into this builder's arena.
116    overflow_builder: Option<crate::simd_path::ChunkedPathStoreBuilder>,
117    bigram_index: Option<Arc<BigramFilter>>,
118    bigram_overlay: Option<Arc<parking_lot::RwLock<BigramOverlay>>>,
119    /// Chunk-level deduped path store. Arc so post-scan snapshots can hold
120    /// the arena alive while iterating file paths.
121    chunked_paths: Option<Arc<crate::simd_path::ChunkedPathStore>>,
122}
123
124impl FileSync {
125    fn new() -> Self {
126        Self {
127            files: StableVec::from_vec_with_reserve(Vec::new(), MAX_OVERFLOW_FILES),
128            indexable_count: 0,
129            base_count: 0,
130            live_count: 0,
131            dirs: StableVec::from_vec_with_reserve(Vec::new(), 0),
132            overflow_builder: None,
133            git_workdir: None,
134            bigram_index: None,
135            bigram_overlay: None,
136            chunked_paths: None,
137        }
138    }
139
140    #[inline]
141    fn arena_base_ptr(&self) -> ArenaPtr {
142        self.chunked_paths
143            .as_ref()
144            .map(|s| s.as_arena_ptr())
145            .unwrap_or(ArenaPtr::null())
146    }
147
148    #[inline]
149    fn arena_overflow_ptr(&self) -> ArenaPtr {
150        self.overflow_builder
151            .as_ref()
152            .map(|b| b.as_arena_ptr())
153            .unwrap_or(ArenaPtr::null())
154    }
155
156    #[inline]
157    fn arena_for_file(&self, file: &FileItem) -> ArenaPtr {
158        if file.is_overflow() {
159            self.arena_overflow_ptr()
160        } else {
161            self.arena_base_ptr()
162        }
163    }
164
165    #[inline]
166    fn files(&self) -> &[FileItem] {
167        &self.files
168    }
169
170    #[inline]
171    fn overflow_files(&self) -> &[FileItem] {
172        &self.files[self.base_count..]
173    }
174
175    #[inline]
176    fn get_file_mut(&mut self, index: usize) -> Option<(ArenaPtr, &mut FileItem)> {
177        Some((
178            if index < self.base_count {
179                self.arena_base_ptr()
180            } else {
181                self.arena_overflow_ptr()
182            },
183            self.files.get_mut(index)?,
184        ))
185    }
186
187    #[inline]
188    fn find_file_index(&self, path: &Path, base_path: &Path) -> Option<usize> {
189        let arena = self.arena_base_ptr();
190
191        // Strip base_path prefix to get the relative path. On Windows this
192        // can fail for 8.3 short names or a different casing; fall back to
193        // canonicalize-then-strip so watcher events still land on the right
194        // `FileItem`.
195        let rel_path_owned: String = match path.strip_prefix(base_path) {
196            Ok(r) => r.to_string_lossy().into_owned(),
197            Err(_) => {
198                #[cfg(windows)]
199                {
200                    canonical_relative_path(path, base_path)?
201                }
202                #[cfg(not(windows))]
203                {
204                    return None;
205                }
206            }
207        };
208        let rel_path: &str = &rel_path_owned;
209
210        // Split into directory (with trailing '/') and filename.
211        let parent_end = rel_path
212            .rfind(std::path::is_separator)
213            .map(|i| i + 1)
214            .unwrap_or(0);
215        let dir_rel = &rel_path[..parent_end];
216        let filename = &rel_path[parent_end..];
217
218        // Binary search dirs to find the parent directory index.
219        // Dir items store the relative path including trailing '/' (e.g. "src/components/").
220        let mut dir_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
221        let dir_idx = self
222            .dirs
223            .binary_search_by(|d| d.read_relative_path(arena, &mut dir_buf).cmp(dir_rel))
224            .ok();
225
226        // Binary search base files by (parent_dir, filename). Base files live in
227        // two internally-sorted partitions — indexable first, then unindexable —
228        // so we try each half in turn. Two O(log n) searches with short-circuit.
229        if let Some(dir_idx) = dir_idx {
230            let dir_idx = dir_idx as u32;
231            let cmp_key = |f: &FileItem| {
232                f.parent_dir_index.cmp(&dir_idx).then_with(|| {
233                    let fname = f.file_name(arena);
234                    fname.as_str().cmp(filename)
235                })
236            };
237
238            if self.indexable_count > 0
239                && let Ok(pos) = self.files[..self.indexable_count].binary_search_by(cmp_key)
240            {
241                return Some(pos);
242            }
243
244            if self.indexable_count < self.base_count
245                && let Ok(rel_pos) =
246                    self.files[self.indexable_count..self.base_count].binary_search_by(cmp_key)
247            {
248                return Some(self.indexable_count + rel_pos);
249            }
250        }
251
252        // Overflow region: linear scan by full relative path.
253        if self.base_count < self.files.len() {
254            let overflow_arena = self.arena_overflow_ptr();
255            if let Some(pos) = self.files[self.base_count..]
256                .iter()
257                .position(|f| f.relative_path_eq(overflow_arena, rel_path))
258            {
259                return Some(self.base_count + pos);
260            }
261        }
262
263        None
264    }
265
266    // TODO remove this function and make a better way to remove all files
267    // from the directory without looping over the whole sync data list
268    /// Tombstones every file in the arena that matches certain predicate
269    fn tombstone_files_with_arena<F>(&mut self, mut predicate: F) -> usize
270    where
271        F: FnMut(&FileItem, ArenaPtr) -> bool,
272    {
273        let base_arena = self.arena_base_ptr();
274        let overflow_arena = self.arena_overflow_ptr();
275        let base_count = self.base_count;
276
277        let mut tombstoned = 0usize;
278        for (idx, file) in self.files.iter_mut().enumerate() {
279            if file.is_deleted() {
280                continue;
281            }
282            let arena = if idx < base_count {
283                base_arena
284            } else {
285                overflow_arena
286            };
287            if predicate(file, arena) {
288                file.set_deleted(true);
289                tombstoned += 1;
290            }
291        }
292        self.live_count -= tombstoned;
293        tombstoned
294    }
295}
296
297impl FileItem {
298    pub fn new(path: PathBuf, base_path: &Path, git_status: Option<Status>) -> (Self, String) {
299        let metadata = std::fs::metadata(&path).ok();
300        Self::new_with_metadata(path, base_path, git_status, metadata.as_ref())
301    }
302
303    /// Create a FileItem using pre-fetched metadata to avoid a redundant stat syscall.
304    /// Returns `(FileItem, relative_path)`. The FileItem's `path` field is
305    /// empty; callers must populate it via `set_path` or `build_chunked_path_store_and_assign`.
306    fn new_with_metadata(
307        path: PathBuf,
308        base_path: &Path,
309        git_status: Option<Status>,
310        metadata: Option<&std::fs::Metadata>,
311    ) -> (Self, String) {
312        let path_buf = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
313        let relative_path = path_buf.to_string_lossy().into_owned();
314
315        let (size, modified) = match metadata {
316            Some(metadata) => {
317                let size = metadata.len();
318                let modified = metadata
319                    .modified()
320                    .ok()
321                    .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
322                    .map_or(0, |d| d.as_secs());
323
324                (size, modified)
325            }
326            None => (0, 0),
327        };
328
329        let is_binary = is_known_binary_extension(&path);
330
331        let filename_start = relative_path
332            .rfind(std::path::is_separator)
333            .map(|i| i + 1)
334            .unwrap_or(0) as u16;
335
336        let item = Self::new_raw(filename_start, size, modified, git_status, is_binary);
337        (item, relative_path)
338    }
339
340    /// Create a FileItem with an empty ChunkedString from a path on disk.
341    ///
342    /// Returns `(file_item, relative_path_string)`. The relative path must be
343    /// kept alongside the FileItem until `build_chunked_path_store_and_assign`
344    /// populates each item's `path` field from the shared arena.
345    pub fn new_from_walk(
346        path: &Path,
347        base_path: &Path,
348        git_status: Option<Status>,
349        metadata: Option<&std::fs::Metadata>,
350    ) -> (Self, String) {
351        let (size, modified) = match metadata {
352            Some(metadata) => {
353                let size = metadata.len();
354                let modified = metadata
355                    .modified()
356                    .ok()
357                    .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
358                    .map_or(0, |d| d.as_secs());
359                (size, modified)
360            }
361            None => (0, 0),
362        };
363
364        let is_binary = is_known_binary_extension(path);
365
366        let rel = pathdiff::diff_paths(path, base_path).unwrap_or_else(|| path.to_path_buf());
367        let rel_str = rel.to_string_lossy().into_owned();
368        let fname_offset = rel_str
369            .rfind(std::path::is_separator)
370            .map(|i| i + 1)
371            .unwrap_or(0) as u16;
372
373        let item = Self::new_raw(fname_offset, size, modified, git_status, is_binary);
374        (item, rel_str)
375    }
376
377    pub(crate) fn update_frecency_scores(
378        &mut self,
379        tracker: &FrecencyTracker,
380        arena: ArenaPtr,
381        base_path: &Path,
382        mode: FFFMode,
383    ) -> Result<(), Error> {
384        let mut abs_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
385        let abs = self.write_absolute_path(arena, base_path, &mut abs_buf);
386        self.access_frecency_score = tracker.get_access_score(abs, mode) as i16;
387        self.modification_frecency_score =
388            tracker.get_modification_score(self.modified, self.git_status, mode) as i16;
389
390        Ok(())
391    }
392}
393
394/// Options for creating a [`FilePicker`].
395pub struct FilePickerOptions {
396    pub base_path: String,
397    /// Pre-populate mmap caches for top-frecency files after the initial scan.
398    pub enable_mmap_cache: bool,
399    /// Build content index after the initial scan for faster content-aware filtering.
400    pub enable_content_indexing: bool,
401    /// Mode of the picker impact the way file watcher events are handled and the scoring logic
402    pub mode: FFFMode,
403    /// Explicit cache budget. When `None`, the budget is auto-computed from
404    /// the repo size after the initial scan completes.
405    pub cache_budget: Option<ContentCacheBudget>,
406    /// When `false`, `new_with_shared_state` skips the background file watcher.
407    pub watch: bool,
408    /// Follow symbolic links during file indexing.
409    pub follow_symlinks: bool,
410    /// Allow indexing the filesystem root (`/`). Off by default — these dirs
411    /// generate enormous fs-event traffic and are rarely the intended target.
412    pub enable_fs_root_scanning: bool,
413    /// Allow indexing the user's home directory. Off by default for the same
414    /// reason as `enable_fs_root_scanning`.
415    pub enable_home_dir_scanning: bool,
416}
417
418impl Default for FilePickerOptions {
419    fn default() -> Self {
420        Self {
421            base_path: ".".into(),
422            enable_mmap_cache: false,
423            enable_content_indexing: false,
424            mode: FFFMode::default(),
425            cache_budget: None,
426            watch: true,
427            follow_symlinks: false,
428            enable_fs_root_scanning: false,
429            enable_home_dir_scanning: false,
430        }
431    }
432}
433
434pub struct FilePicker {
435    pub mode: FFFMode,
436    pub base_path: PathBuf,
437    sync_data: FileSync,
438    pub(crate) signals: ScanSignals,
439    pub(crate) background_watcher: Option<BackgroundWatcher>,
440    cache_budget: Arc<ContentCacheBudget>,
441    has_explicit_cache_budget: bool,
442    scanned_files_count: Arc<AtomicUsize>,
443    enable_mmap_cache: bool,
444    enable_content_indexing: bool,
445    watch: bool,
446    follow_symlinks: bool,
447    enable_fs_root_scanning: bool,
448    enable_home_dir_scanning: bool,
449    trace_span: tracing::Span,
450    trace_id: String,
451}
452
453impl std::fmt::Debug for FilePicker {
454    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455        f.debug_struct("FilePicker")
456            .field("base_path", &self.base_path)
457            .field("sync_data", &self.sync_data)
458            .field(
459                "is_scanning",
460                &self.signals.scanning.load(Ordering::Relaxed),
461            )
462            .field(
463                "scanned_files_count",
464                &self.scanned_files_count.load(Ordering::Relaxed),
465            )
466            .finish_non_exhaustive()
467    }
468}
469
470impl FFFStringStorage for &FilePicker {
471    #[inline]
472    fn arena_for(&self, file: &FileItem) -> crate::simd_path::ArenaPtr {
473        self.sync_data.arena_for_file(file)
474    }
475
476    #[inline]
477    fn base_arena(&self) -> crate::simd_path::ArenaPtr {
478        self.sync_data.arena_base_ptr()
479    }
480
481    #[inline]
482    fn overflow_arena(&self) -> crate::simd_path::ArenaPtr {
483        self.sync_data.arena_overflow_ptr()
484    }
485}
486
487impl FilePicker {
488    pub fn base_path(&self) -> &Path {
489        &self.base_path
490    }
491
492    pub fn has_mmap_cache(&self) -> bool {
493        self.enable_mmap_cache
494    }
495
496    pub fn has_content_indexing(&self) -> bool {
497        self.enable_content_indexing
498    }
499
500    pub fn has_watcher(&self) -> bool {
501        self.watch
502    }
503
504    pub fn follows_symlinks(&self) -> bool {
505        self.follow_symlinks
506    }
507
508    pub fn fs_root_scanning_enabled(&self) -> bool {
509        self.enable_fs_root_scanning
510    }
511
512    pub fn home_dir_scanning_enabled(&self) -> bool {
513        self.enable_home_dir_scanning
514    }
515
516    pub fn trace_id(&self) -> &str {
517        &self.trace_id
518    }
519
520    pub fn trace_span(&self) -> tracing::Span {
521        self.trace_span.clone()
522    }
523
524    pub fn mode(&self) -> FFFMode {
525        self.mode
526    }
527
528    pub fn cache_budget(&self) -> &ContentCacheBudget {
529        &self.cache_budget
530    }
531
532    pub fn bigram_index(&self) -> Option<&BigramFilter> {
533        self.sync_data.bigram_index.as_deref()
534    }
535
536    pub fn bigram_overlay(&self) -> Option<&parking_lot::RwLock<BigramOverlay>> {
537        self.sync_data.bigram_overlay.as_deref()
538    }
539
540    pub fn get_file_mut(&mut self, index: usize) -> Option<(ArenaPtr, &mut FileItem)> {
541        self.sync_data.get_file_mut(index)
542    }
543
544    /// Absolute path to the repository root if the indexed tree lives
545    /// inside a git working directory. `None` for non-git bases.
546    pub fn git_root(&self) -> Option<&Path> {
547        self.sync_data.git_workdir.as_deref()
548    }
549
550    pub fn has_explicit_cache_budget(&self) -> bool {
551        self.has_explicit_cache_budget
552    }
553
554    pub fn set_cache_budget(&mut self, budget: ContentCacheBudget) {
555        self.cache_budget = Arc::new(budget);
556    }
557
558    /// Get all indexed files sorted by path.
559    /// Note: Files are stored sorted by PATH for efficient insert/remove.
560    /// For frecency-sorted results, use search() which sorts matched results.
561    pub fn get_files(&self) -> &[FileItem] {
562        self.sync_data.files()
563    }
564
565    /// Count of live (non-tombstoned) files. O(1).
566    #[inline]
567    pub fn live_file_count(&self) -> usize {
568        self.sync_data.live_count
569    }
570
571    pub fn get_overflow_files(&self) -> &[FileItem] {
572        self.sync_data.overflow_files()
573    }
574
575    /// Get the directory table (sorted by path).
576    pub fn get_dirs(&self) -> &[DirItem] {
577        &self.sync_data.dirs
578    }
579
580    /// Actual heap bytes used: (chunked_path_store, 0, 0).
581    /// The second element is 0 because leaked overflow stores aren't tracked.
582    pub fn arena_bytes(&self) -> (usize, usize, usize) {
583        let chunked = self
584            .sync_data
585            .chunked_paths
586            .as_ref()
587            .map_or(0, |s| s.heap_bytes());
588
589        (chunked, 0, 0)
590    }
591
592    #[tracing::instrument(level = "debug", skip_all)]
593    pub(crate) fn for_each_dir(&self, mut f: impl FnMut(&Path) -> ControlFlow<()>) {
594        let dir_table = &self.sync_data.dirs;
595        let base = self.base_path.as_path();
596
597        if !dir_table.is_empty() {
598            let arena = self.arena_base_ptr();
599            let mut path_buf = PathBuf::with_capacity(crate::simd_path::PATH_BUF_SIZE);
600            let mut prev_relative_path = String::new();
601
602            let mut scratch_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
603            for dir_item in dir_table.iter() {
604                let full_relative_path = dir_item.read_relative_path(arena, &mut scratch_buf);
605                let relative_path = full_relative_path.trim_end_matches(std::path::is_separator);
606
607                if relative_path.is_empty() {
608                    // Files directly under base_path
609                    prev_relative_path.clear();
610                    continue;
611                }
612
613                let mut i = common_dir_prefix_len(&prev_relative_path, relative_path);
614                // If we stopped on a separator, skip it — we want to start
615                // emitting at the first unseen segment, not re-emit the
616                // already-emitted prefix path.
617                if i < relative_path.len()
618                    && std::path::is_separator(relative_path.as_bytes()[i] as char)
619                {
620                    i += 1;
621                }
622
623                // Walk the suffix of `relative_path` one segment at a time, emitting
624                // each previously unseen ancestor up to and including `relative_path`.
625                while i < relative_path.len() {
626                    let next_sep = relative_path[i..]
627                        .find(std::path::is_separator)
628                        .map(|off| i + off)
629                        .unwrap_or(relative_path.len());
630                    let ancestor_rel = &relative_path[..next_sep];
631
632                    path_buf.clear();
633                    path_buf.push(base);
634                    path_buf.push(ancestor_rel);
635
636                    // we can't really emit iterator here unfortunately
637                    if matches!(f(path_buf.as_path()), ControlFlow::Break(())) {
638                        return;
639                    }
640
641                    i = next_sep + 1;
642                }
643
644                prev_relative_path.clear();
645                prev_relative_path.push_str(relative_path);
646            }
647            return;
648        }
649
650        // fallback that should never be happening, but it is possible to get the file
651        // path from the absolute path using components api as well:
652        let files = self.sync_data.files();
653        let arena = self.arena_base_ptr();
654        let mut current = self.base_path.clone();
655        let mut path_buf = [0u8; PATH_BUF_SIZE];
656
657        for file in files {
658            let abs = file.write_absolute_path(arena, base, &mut path_buf);
659            let Some(parent) = abs.parent() else {
660                continue;
661            };
662            if parent == current.as_path() {
663                continue;
664            }
665
666            while current.as_path() != base && !parent.starts_with(&current) {
667                current.pop();
668            }
669
670            let Ok(remainder) = parent.strip_prefix(&current) else {
671                continue;
672            };
673            for component in remainder.components() {
674                current.push(component);
675                if matches!(f(current.as_path()), ControlFlow::Break(())) {
676                    return;
677                }
678            }
679        }
680    }
681
682    /// Create a new FilePicker from options.
683    /// Always prefer new_with_shared_state for the consumer application, use this only if you know
684    /// what you are doing. This won't spawn the backgraound watcher and won't walk the file tree.
685    pub fn new(options: FilePickerOptions) -> Result<Self, Error> {
686        let path = PathBuf::from(&options.base_path);
687        if !path.exists() {
688            error!("Base path does not exist: {}", options.base_path);
689            return Err(Error::InvalidPath(path));
690        }
691        if path.parent().is_none() && !options.enable_fs_root_scanning {
692            error!("Refusing to index filesystem root: {}", path.display());
693            return Err(Error::FilesystemRoot(path));
694        }
695        if !options.enable_home_dir_scanning
696            && Some(path.as_os_str()) == dirs::home_dir().as_ref().map(|p| p.as_os_str())
697        {
698            error!("Refusing to index home directory: {}", path.display());
699            return Err(Error::FilesystemRoot(path));
700        }
701
702        // Windows-only: canonicalize with so the base path does NOT
703        // have the `\\?\` UNC prefix that `std::fs::canonicalize` adds.
704        // libgit2's `repo.workdir()`
705        #[cfg(windows)]
706        let path = crate::path_utils::canonicalize(&path).unwrap_or(path);
707
708        let has_explicit_budget = options.cache_budget.is_some();
709        let initial_budget = options.cache_budget.unwrap_or_default();
710
711        let trace_id = crate::log::generate_trace_id();
712        let trace_span = crate::log::trace_span(&trace_id, "picker");
713
714        Ok(FilePicker {
715            background_watcher: None,
716            base_path: path,
717            cache_budget: Arc::new(initial_budget),
718            has_explicit_cache_budget: has_explicit_budget,
719            signals: crate::scan::ScanSignals::default(),
720            mode: options.mode,
721            scanned_files_count: Arc::new(AtomicUsize::new(0)),
722            sync_data: FileSync::new(),
723            enable_mmap_cache: options.enable_mmap_cache,
724            enable_content_indexing: options.enable_content_indexing,
725            watch: options.watch,
726            follow_symlinks: options.follow_symlinks,
727            enable_fs_root_scanning: options.enable_fs_root_scanning,
728            enable_home_dir_scanning: options.enable_home_dir_scanning,
729            trace_span,
730            trace_id,
731        })
732    }
733
734    /// Create a picker, place it into the shared handle, and spawn background
735    /// indexing + file-system watcgenerate_trace_id the default entry point.
736    pub fn new_with_shared_state(
737        shared_picker: SharedFilePicker,
738        shared_frecency: SharedFrecency,
739        options: FilePickerOptions,
740    ) -> Result<(), Error> {
741        let picker = Self::new(options)?;
742
743        info!(
744            "Spawning background threads: base_path={}, warmup={}, content_indexing={}, mode={:?}",
745            picker.base_path.display(),
746            picker.enable_mmap_cache,
747            picker.enable_content_indexing,
748            picker.mode,
749        );
750
751        let warmup = picker.enable_mmap_cache;
752        let content_indexing = picker.enable_content_indexing;
753        let watch = picker.watch;
754        let mode = picker.mode;
755        let follow_symlinks = picker.follow_symlinks;
756        let enable_fs_root_scanning = picker.enable_fs_root_scanning;
757        let enable_home_dir_scanning = picker.enable_home_dir_scanning;
758
759        let signals = picker.scan_signals();
760        let scanned_files_counter = picker.scanned_files_counter();
761        let path = picker.base_path.clone();
762        let trace_span = picker.trace_span.clone();
763
764        // Pre-arm `scanning` BEFORE publishing the new picker. `ScanJob::spawn`
765        // also sets it, but that runs after this function returns; consumers
766        // (e.g. lua `wait_for_initial_scan` after `restart_index_in_path`)
767        // that grab the signal Arc between publish and spawn would otherwise
768        // observe scanning=false and skip the wait, racing the walker. The
769        // race is wide on Windows CI where notify is slow.
770        signals
771            .scanning
772            .store(true, std::sync::atomic::Ordering::Release);
773
774        {
775            let mut guard = shared_picker.write()?;
776            *guard = Some(picker);
777            // dropping old picker flips its `cancelled` flag → bg threads exit cleanly
778        }
779
780        ScanJob::new_initial(
781            shared_picker,
782            shared_frecency,
783            path,
784            mode,
785            signals,
786            scanned_files_counter,
787            trace_span,
788            ScanConfig {
789                warmup,
790                content_indexing,
791                watch,
792                auto_cache_budget: true,
793                install_watcher: true,
794                follow_symlinks,
795                enable_fs_root_scanning,
796                enable_home_dir_scanning,
797            },
798        )
799        .spawn();
800
801        Ok(())
802    }
803
804    /// Synchronous filesystem scan — populates `self` with indexed files.
805    ///
806    /// Use this when you need direct access to the picker without shared state:
807    /// ```ignore
808    /// let mut picker = FilePicker::new(options)?;
809    /// picker.collect_files()?;
810    /// // picker.get_files() is now populated
811    /// ```
812    pub fn collect_files(&mut self) -> Result<(), Error> {
813        self.signals.scanning.store(true, Ordering::Relaxed);
814        self.scanned_files_count.store(0, Ordering::Relaxed);
815
816        let git_workdir = FileSync::discover_git_workdir(&self.base_path);
817        let git_handle = git_workdir.clone().map(FileSync::spawn_git_status);
818
819        let empty_frecency = SharedFrecency::default();
820        let sync = FileSync::walk_filesystem(
821            &self.base_path,
822            git_workdir,
823            &self.scanned_files_count,
824            &empty_frecency,
825            self.mode,
826            self.follow_symlinks,
827        )?;
828
829        self.sync_data = sync;
830
831        if !self.has_explicit_cache_budget {
832            let file_count = self.sync_data.files().len();
833            self.cache_budget = Arc::new(ContentCacheBudget::new_for_repo(file_count));
834        } else {
835            self.cache_budget.reset();
836        }
837
838        if let Some(handle) = git_handle
839            && let Ok(Some(git_cache)) = handle.join()
840        {
841            let mut path_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
842
843            let arena = self.arena_base_ptr();
844            for file in self.sync_data.files.iter_mut() {
845                file.git_status = git_cache.lookup_status(file.write_absolute_path(
846                    arena,
847                    &self.base_path,
848                    &mut path_buf,
849                ));
850            }
851        }
852
853        self.signals.scanning.store(false, Ordering::Relaxed);
854        Ok(())
855    }
856
857    /// Start the background file-system watcher.
858    ///
859    /// The picker must already be placed into `shared_picker` (the watcher
860    /// needs the shared handle to apply live updates). Call after
861    /// [`collect_files`](Self::collect_files) or after an initial scan.
862    pub fn spawn_background_watcher(
863        &mut self,
864        shared_picker: &SharedFilePicker,
865        shared_frecency: &SharedFrecency,
866    ) -> Result<(), Error> {
867        let git_workdir = self.sync_data.git_workdir.clone();
868        let watcher = BackgroundWatcher::new(
869            self.base_path.clone(),
870            git_workdir,
871            shared_picker.clone(),
872            shared_frecency.clone(),
873            self.mode,
874            self.enable_fs_root_scanning,
875            self.enable_home_dir_scanning,
876            self.trace_span.clone(),
877        )?;
878        self.background_watcher = Some(watcher);
879        self.signals.watcher_ready.store(true, Ordering::Release);
880        Ok(())
881    }
882
883    /// Perform fuzzy search on files with a pre-parsed query.
884    ///
885    /// The query should be parsed using [`FFFQuery`]::parse() before calling
886    /// this function. If a [`QueryTracker`] is provided, the search will
887    /// automatically look up the last selected file for this query and boost it
888    #[tracing::instrument(skip_all, name = "Fuzzy file search", fields(query = query.raw_query))]
889    pub fn fuzzy_search<'q>(
890        &self,
891        query: &'q FFFQuery<'q>,
892        query_tracker: Option<&QueryTracker>,
893        options: FuzzySearchOptions<'q>,
894    ) -> SearchResult<'_> {
895        let files = self.get_files();
896        let max_threads = if options.max_threads == 0 {
897            std::thread::available_parallelism()
898                .map(|n| n.get())
899                .unwrap_or(4)
900        } else {
901            options.max_threads
902        };
903
904        debug!(
905            raw_query = ?query.raw_query,
906            pagination = ?options.pagination,
907            ?max_threads,
908            current_file = ?options.current_file,
909            "Fuzzy search",
910        );
911
912        let total_files = self.live_file_count();
913        let location = query.location;
914
915        // Get effective query for max_typos calculation (without location suffix)
916        let effective_query = match &query.fuzzy_query {
917            fff_query_parser::FuzzyQuery::Text(t) => *t,
918            fff_query_parser::FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
919            _ => query.raw_query.trim(),
920        };
921
922        // small queries with a large number of results can match absolutely everything
923        let max_typos = (effective_query.len() as u16 / 4).clamp(2, 6);
924        // Look up the last file selected for this query (combo-boost scoring)
925        let last_same_query_entry =
926            query_tracker
927                .zip(options.project_path)
928                .and_then(|(tracker, project_path)| {
929                    tracker
930                        .get_last_query_entry(
931                            query.raw_query,
932                            project_path,
933                            options.min_combo_count,
934                        )
935                        .ok()
936                        .flatten()
937                });
938
939        let context = ScoringContext {
940            query,
941            max_typos,
942            max_threads,
943            project_path: options.project_path,
944            current_file: options.current_file,
945            last_same_query_match: last_same_query_entry,
946            combo_boost_score_multiplier: options.combo_boost_score_multiplier,
947            min_combo_count: options.min_combo_count,
948            pagination: options.pagination,
949        };
950
951        let time = std::time::Instant::now();
952
953        let base_arena = self.sync_data.arena_base_ptr();
954        let overflow_arena = self.sync_data.arena_overflow_ptr();
955
956        let (items, scores, total_matched) = fuzzy_match_and_score_files(
957            files,
958            &context,
959            self.sync_data.base_count,
960            base_arena,
961            overflow_arena,
962        );
963
964        info!(
965            ?query,
966            completed_in = ?time.elapsed(),
967            total_matched,
968            returned_count = items.len(),
969            pagination = ?options.pagination,
970            "Fuzzy search completed",
971        );
972
973        SearchResult {
974            items,
975            scores,
976            total_matched,
977            total_files,
978            location,
979        }
980    }
981
982    /// Perform fuzzy search on indexed directories.
983    ///
984    /// Returns directories ranked by fuzzy match quality + frecency.
985    pub fn fuzzy_search_directories<'q>(
986        &self,
987        query: &'q FFFQuery<'q>,
988        options: FuzzySearchOptions<'q>,
989    ) -> DirSearchResult<'_> {
990        let dirs = self.get_dirs();
991        let max_threads = if options.max_threads == 0 {
992            std::thread::available_parallelism()
993                .map(|n| n.get())
994                .unwrap_or(4)
995        } else {
996            options.max_threads
997        };
998
999        let total_dirs = dirs.len();
1000
1001        let effective_query = match &query.fuzzy_query {
1002            fff_query_parser::FuzzyQuery::Text(t) => *t,
1003            fff_query_parser::FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
1004            _ => query.raw_query.trim(),
1005        };
1006
1007        let max_typos = (effective_query.len() as u16 / 4).clamp(2, 6);
1008
1009        let context = ScoringContext {
1010            query,
1011            max_typos,
1012            max_threads,
1013            project_path: options.project_path,
1014            current_file: options.current_file,
1015            last_same_query_match: None,
1016            combo_boost_score_multiplier: 0,
1017            min_combo_count: 0,
1018            pagination: options.pagination,
1019        };
1020
1021        let arena = self.sync_data.arena_base_ptr();
1022        let time = std::time::Instant::now();
1023
1024        let (items, scores, total_matched) =
1025            crate::score::fuzzy_match_and_score_dirs(dirs, &context, arena);
1026
1027        info!(
1028            ?query,
1029            completed_in = ?time.elapsed(),
1030            total_matched,
1031            returned_count = items.len(),
1032            "Directory search completed",
1033        );
1034
1035        DirSearchResult {
1036            items,
1037            scores,
1038            total_matched,
1039            total_dirs,
1040        }
1041    }
1042
1043    /// Perform a mixed fuzzy search across both files and directories.
1044    ///
1045    /// Returns a single flat list where files and directories are interleaved
1046    /// by total score in descending order.
1047    ///
1048    /// If the raw query ends with a path separator (`/`), only directories
1049    /// are searched — files are skipped entirely. The caller should parse the
1050    /// query with `DirSearchConfig` so that trailing `/` is kept as fuzzy
1051    /// text instead of becoming a `PathSegment` constraint.
1052    pub fn fuzzy_search_mixed<'q>(
1053        &self,
1054        query: &'q FFFQuery<'q>,
1055        query_tracker: Option<&QueryTracker>,
1056        options: FuzzySearchOptions<'q>,
1057    ) -> MixedSearchResult<'_> {
1058        let location = query.location;
1059        let page_offset = options.pagination.offset;
1060        let page_limit = if options.pagination.limit > 0 {
1061            options.pagination.limit
1062        } else {
1063            100
1064        };
1065
1066        let dirs_only =
1067            query.raw_query.ends_with(std::path::MAIN_SEPARATOR) || query.raw_query.ends_with('/');
1068
1069        // Run file search and dir search with no pagination (we merge then paginate).
1070        let internal_limit = page_offset.saturating_add(page_limit).saturating_mul(2);
1071
1072        let dir_options = FuzzySearchOptions {
1073            pagination: PaginationArgs {
1074                offset: 0,
1075                limit: internal_limit,
1076            },
1077            ..options
1078        };
1079        let dir_results = self.fuzzy_search_directories(query, dir_options);
1080
1081        if dirs_only {
1082            let total_matched = dir_results.total_matched;
1083            let total_dirs = dir_results.total_dirs;
1084
1085            let mut merged: Vec<(MixedItemRef<'_>, Score)> =
1086                Vec::with_capacity(dir_results.items.len());
1087            for (dir, score) in dir_results.items.into_iter().zip(dir_results.scores) {
1088                merged.push((MixedItemRef::Dir(dir), score));
1089            }
1090
1091            if page_offset >= merged.len() {
1092                return MixedSearchResult {
1093                    items: vec![],
1094                    scores: vec![],
1095                    total_matched,
1096                    total_files: self.live_file_count(),
1097                    total_dirs,
1098                    location,
1099                };
1100            }
1101
1102            let end = (page_offset + page_limit).min(merged.len());
1103            let page = merged.drain(page_offset..end);
1104            let (items, scores): (Vec<_>, Vec<_>) = page.unzip();
1105
1106            return MixedSearchResult {
1107                items,
1108                scores,
1109                total_matched,
1110                total_files: self.live_file_count(),
1111                total_dirs,
1112                location,
1113            };
1114        }
1115
1116        let file_options = FuzzySearchOptions {
1117            pagination: PaginationArgs {
1118                offset: 0,
1119                limit: internal_limit,
1120            },
1121            ..options
1122        };
1123        let file_results = self.fuzzy_search(query, query_tracker, file_options);
1124
1125        // Merge by score descending.
1126        let total_matched = file_results.total_matched + dir_results.total_matched;
1127        let total_files = file_results.total_files;
1128        let total_dirs = dir_results.total_dirs;
1129
1130        let mut merged: Vec<(MixedItemRef<'_>, Score)> =
1131            Vec::with_capacity(file_results.items.len() + dir_results.items.len());
1132
1133        for (file, score) in file_results.items.into_iter().zip(file_results.scores) {
1134            merged.push((MixedItemRef::File(file), score));
1135        }
1136        for (dir, score) in dir_results.items.into_iter().zip(dir_results.scores) {
1137            merged.push((MixedItemRef::Dir(dir), score));
1138        }
1139
1140        // Sort merged results by total score descending.
1141        merged.sort_unstable_by_key(|b| std::cmp::Reverse(b.1.total));
1142
1143        // Paginate.
1144        if page_offset >= merged.len() {
1145            return MixedSearchResult {
1146                items: vec![],
1147                scores: vec![],
1148                total_matched,
1149                total_files,
1150                total_dirs,
1151                location,
1152            };
1153        }
1154
1155        let end = (page_offset + page_limit).min(merged.len());
1156        let page = merged.drain(page_offset..end);
1157        let (items, scores): (Vec<_>, Vec<_>) = page.unzip();
1158
1159        MixedSearchResult {
1160            items,
1161            scores,
1162            total_matched,
1163            total_files,
1164            total_dirs,
1165            location,
1166        }
1167    }
1168
1169    /// Glob search: filter indexed files by a single glob pattern, rank by
1170    /// frecency, and paginate. Bypasses the regular query parser entirely —
1171    /// useful when callers already have a literal glob (`*.rs`, `**/*.test.ts`)
1172    /// and want neither fuzzy matching nor multi-token constraint parsing.
1173    ///
1174    /// Pipeline: `apply_constraints(Glob) → score_filtered_by_frecency → sort_and_paginate`.
1175    /// Same ranking semantics as `fuzzy_search` when the fuzzy query is empty.
1176    pub fn glob<'p>(
1177        &'p self,
1178        pattern: &'p str,
1179        options: FuzzySearchOptions<'p>,
1180    ) -> SearchResult<'p> {
1181        let query = FFFQuery {
1182            raw_query: pattern,
1183            constraints: vec![fff_query_parser::Constraint::Glob(pattern)],
1184            fuzzy_query: fff_query_parser::FuzzyQuery::Empty,
1185            location: None,
1186        };
1187
1188        // `fuzzy_search` short-circuits to `score_filtered_by_frecency` when
1189        // `fuzzy_query` is `Empty`, then runs the same `sort_and_paginate`
1190        // path. Reusing it keeps the ranking guarantees identical without
1191        // exposing the private scoring helpers.
1192        self.fuzzy_search(&query, None, options)
1193    }
1194
1195    /// Perform a live grep search across indexed files.
1196    ///
1197    /// If `options.abort_signal` is set it overrides the picker's internal
1198    /// cancellation flag, giving the caller full control over when to stop.
1199    pub fn grep(&self, query: &FFFQuery<'_>, options: &GrepSearchOptions) -> GrepResult<'_> {
1200        let overlay_guard = self.sync_data.bigram_overlay.as_ref().map(|o| o.read());
1201        let arena = self.arena_base_ptr();
1202        let overflow_arena = self.sync_data.arena_overflow_ptr();
1203        let cancel = options
1204            .abort_signal
1205            .as_deref()
1206            .unwrap_or(&self.signals.cancelled);
1207
1208        SEARCH_THREAD_POOL.install(|| {
1209            grep_search(
1210                self.get_files(),
1211                query,
1212                options,
1213                self.cache_budget(),
1214                self.sync_data.bigram_index.as_deref(),
1215                overlay_guard.as_deref(),
1216                cancel,
1217                &self.base_path,
1218                arena,
1219                overflow_arena,
1220            )
1221        })
1222    }
1223
1224    /// Multi-pattern grep search across indexed files.
1225    pub fn multi_grep(
1226        &self,
1227        patterns: &[&str],
1228        constraints: &[fff_query_parser::Constraint<'_>],
1229        options: &GrepSearchOptions,
1230    ) -> GrepResult<'_> {
1231        let overlay_guard = self.sync_data.bigram_overlay.as_ref().map(|o| o.read());
1232        let arena = self.arena_base_ptr();
1233        let overflow_arena = self.sync_data.arena_overflow_ptr();
1234        let cancel = options
1235            .abort_signal
1236            .as_deref()
1237            .unwrap_or(&self.signals.cancelled);
1238
1239        SEARCH_THREAD_POOL.install(|| {
1240            multi_grep_search(
1241                self.get_files(),
1242                patterns,
1243                constraints,
1244                options,
1245                self.cache_budget(),
1246                self.sync_data.bigram_index.as_deref(),
1247                overlay_guard.as_deref(),
1248                cancel,
1249                &self.base_path,
1250                arena,
1251                overflow_arena,
1252            )
1253        })
1254    }
1255
1256    // Returns an ongoing or finisshed scan progress
1257    pub fn get_scan_progress(&self) -> ScanProgress {
1258        let scanned_count = self.scanned_files_count.load(Ordering::Relaxed);
1259        let is_scanning = self.signals.scanning.load(Ordering::Relaxed);
1260
1261        ScanProgress {
1262            scanned_files_count: scanned_count,
1263            is_scanning,
1264            is_watcher_ready: self.signals.watcher_ready.load(Ordering::Relaxed),
1265            is_warmup_complete: !self.enable_content_indexing
1266                || self.sync_data.bigram_index.is_some(),
1267        }
1268    }
1269
1270    pub(crate) fn set_bigram_index(&mut self, index: BigramFilter) {
1271        self.sync_data.bigram_index = Some(Arc::new(index));
1272        // once the index is reset automatically reset the overaly
1273        self.sync_data.bigram_overlay = Some(Arc::new(parking_lot::RwLock::new(
1274            BigramOverlay::new(self.sync_data.indexable_count),
1275        )));
1276    }
1277
1278    pub(crate) fn scan_signals(&self) -> crate::scan::ScanSignals {
1279        self.signals.clone()
1280    }
1281
1282    pub(crate) fn scanned_files_counter(&self) -> Arc<AtomicUsize> {
1283        Arc::clone(&self.scanned_files_count)
1284    }
1285
1286    /// Capture raw pointers to the picker's internal arrays for off-lock use.
1287    ///
1288    /// Sets `post_scan_indexing_active` and returns a snapshot that clears it
1289    /// on drop. This is the ONLY approved way to escape the lock for
1290    /// long-running parallel work (git status, warmup, bigram).
1291    ///
1292    /// Returns `None` if `post_scan_indexing_active` is already set — this
1293    /// means another post-scan is in flight and we must not create a second
1294    /// set of dangling pointers.
1295    ///
1296    /// # Safety
1297    /// 1. `walk_filesystem` reserved `MAX_OVERFLOW_FILES` capacity on the
1298    ///    files Vec at creation — watcher pushes cannot reallocate it.
1299    /// 2. `post_scan_indexing_active` is set — prevents `commit_new_sync`
1300    ///    from replacing the Vec (ScanJob::new checks this flag).
1301    /// 3. Only `[..base_count]` is accessed — base files use the immutable
1302    ///    base arena. Overflow files use a different arena.
1303    pub(crate) unsafe fn post_scan_snapshot(&self) -> Option<PostScanUnsafeSnapshot> {
1304        if self
1305            .signals
1306            .post_scan_indexing_active
1307            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1308            .is_err()
1309        {
1310            tracing::error!(
1311                "Can not acquire post scan unsafe snapshot, someone already acquired it"
1312            );
1313            return None;
1314        }
1315
1316        Some(PostScanUnsafeSnapshot {
1317            files: self.sync_data.files.clone(),
1318            dirs: self.sync_data.dirs.clone(),
1319            arena: self.sync_data.chunked_paths.as_ref().map(Arc::clone),
1320            base_count: self.sync_data.base_count,
1321            indexable_count: self.sync_data.indexable_count,
1322            base_path: self.base_path.clone(),
1323            cancelled: Arc::clone(&self.signals.cancelled),
1324            post_scan_flag: Arc::clone(&self.signals.post_scan_indexing_active),
1325            _budget: Arc::clone(&self.cache_budget),
1326        })
1327    }
1328
1329    pub(crate) fn commit_new_sync(&mut self, sync: FileSync) {
1330        self.sync_data = sync;
1331        self.cache_budget.reset();
1332    }
1333
1334    #[inline]
1335    pub(crate) fn arena_base_ptr(&self) -> ArenaPtr {
1336        self.sync_data.arena_base_ptr()
1337    }
1338
1339    /// Update git statuses for files, using the provided shared frecency tracker.
1340    pub(crate) fn update_git_statuses(
1341        &mut self,
1342        status_cache: GitStatusCache,
1343        shared_frecency: &SharedFrecency,
1344    ) -> Result<(), Error> {
1345        debug!(
1346            statuses_count = status_cache.statuses_len(),
1347            "Updating git status",
1348        );
1349
1350        let mode = self.mode;
1351        let bp = self.base_path.clone();
1352        let frecency = shared_frecency.read()?;
1353
1354        status_cache
1355            .into_iter()
1356            .try_for_each(|(path, status)| -> Result<(), Error> {
1357                if let Some((arena, file)) = self.get_mut_file_by_path(&path) {
1358                    file.git_status = Some(status);
1359                    if let Some(ref f) = *frecency {
1360                        file.update_frecency_scores(f, arena, &bp, mode)?;
1361                    }
1362                    // Update parent dir frecency inline. `DirItem` has an
1363                    // interior-mutable atomic score, so `&self` access is
1364                    // enough — no write aliasing against Arc clones.
1365                    let score = file.access_frecency_score as i32;
1366                    let dir_idx = file.parent_dir_index as usize;
1367                    if let Some(dir) = self.sync_data.dirs.get(dir_idx) {
1368                        dir.update_frecency_if_larger(score);
1369                    }
1370                } else {
1371                    // Expected on sparse checkouts: git reports a status for
1372                    // a path that isn't materialized on disk and therefore
1373                    // isn't in the file index. Don't spam the log (#404).
1374                    debug!(?path, "Git status for path not in index, skipping");
1375                }
1376                Ok(())
1377            })?;
1378
1379        Ok(())
1380    }
1381
1382    pub fn update_single_file_frecency(
1383        &mut self,
1384        file_path: impl AsRef<Path>,
1385        frecency_tracker: &FrecencyTracker,
1386    ) -> Result<(), Error> {
1387        let path = file_path.as_ref();
1388
1389        let Some(index) = self.sync_data.find_file_index(path, &self.base_path) else {
1390            return Ok(());
1391        };
1392
1393        if let Some((arena, file)) = self.sync_data.get_file_mut(index) {
1394            file.update_frecency_scores(frecency_tracker, arena, &self.base_path, self.mode)?;
1395
1396            // Update parent dir frecency inline (atomic, &self access).
1397            let score = file.access_frecency_score as i32;
1398            let dir_idx = file.parent_dir_index as usize;
1399            if let Some(dir) = self.sync_data.dirs.get(dir_idx) {
1400                dir.update_frecency_if_larger(score);
1401            }
1402        }
1403
1404        Ok(())
1405    }
1406
1407    pub fn get_file_by_path(&self, path: impl AsRef<Path>) -> Option<&FileItem> {
1408        self.sync_data
1409            .find_file_index(path.as_ref(), &self.base_path)
1410            .and_then(|index| self.sync_data.files().get(index))
1411    }
1412
1413    pub fn get_mut_file_by_path(
1414        &mut self,
1415        path: impl AsRef<Path>,
1416    ) -> Option<(ArenaPtr, &mut FileItem)> {
1417        let path = path.as_ref();
1418        let index = self.sync_data.find_file_index(path, &self.base_path);
1419        index.and_then(|i| self.sync_data.get_file_mut(i))
1420    }
1421
1422    /// Handle the event of certain file being modified or adds a neww file if it is not added
1423    /// If this function returns `None` it means that picker is in the invalid state, or the capacity
1424    /// of index is exhausted and a new rescan needs to be triggered.
1425    #[tracing::instrument(skip(self),level = Level::DEBUG)]
1426    pub fn handle_create_or_modify(&mut self, path: impl AsRef<Path> + Debug) -> Option<&FileItem> {
1427        let path = path.as_ref();
1428
1429        if let Some(idx) = self.sync_data.find_file_index(path, &self.base_path) {
1430            let slot = if idx < self.sync_data.base_count {
1431                FileSlot::Base(idx)
1432            } else {
1433                FileSlot::Overflow(idx)
1434            };
1435
1436            return self.handle_file_modify(path, slot);
1437        }
1438
1439        self.add_new_file(path)
1440    }
1441
1442    #[tracing::instrument(skip_all, fields(path = ?path), level = Level::DEBUG)]
1443    fn handle_file_modify(&mut self, path: &Path, slot: FileSlot) -> Option<&FileItem> {
1444        let overlay = self.sync_data.bigram_overlay.as_ref().map(Arc::clone);
1445        let pos = slot.index();
1446
1447        // this is the only way to actually know if the file is on disk, we CAN NOT
1448        // rely on the watcher to proive the latest state of the file, do the actual check
1449        let metadata = match std::fs::metadata(path) {
1450            Ok(m) => {
1451                self.untombstone_file(pos);
1452
1453                m
1454            }
1455            Err(_) => {
1456                self.tombstone_file(pos);
1457                return None;
1458            }
1459        };
1460
1461        let (_arena, file) = self.sync_data.get_file_mut(pos)?;
1462
1463        let size = metadata.len();
1464        let modified_time = metadata
1465            .modified()
1466            .ok()
1467            .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1468            .map(|d| d.as_secs());
1469
1470        file.update_metadata(&self.cache_budget, modified_time, Some(size));
1471
1472        // Re-classify binary status from current content (chunked, fixed
1473        // buffer). Already-binary files are left alone.
1474        if !file.is_binary() {
1475            let mut chunk = [0u8; crate::types::BINARY_CLASSIFICATION_CHUNK_SIZE];
1476            file.detect_binary_per_byte(path, &mut chunk);
1477        }
1478
1479        // Indexable base-region files feed fresh content to the bigram overlay.
1480        if matches!(slot, FileSlot::Base(_))
1481            && let Some(ref overlay) = overlay
1482        {
1483            let in_indexable = {
1484                let guard = overlay.read();
1485                pos < guard.base_file_count()
1486            };
1487
1488            if in_indexable && let Ok(content) = std::fs::read(path) {
1489                overlay.write().modify_file(pos, &content);
1490            }
1491        }
1492
1493        self.sync_data.files().get(pos)
1494    }
1495
1496    /// Adds a new file to picker, if the file can not be added returns `None`
1497    /// which indicates that it's time to trigger a new sync
1498    #[tracing::instrument(skip(self))]
1499    pub fn add_new_file(&mut self, path: &Path) -> Option<&FileItem> {
1500        // On Windows `pathdiff::diff_paths` is byte-wise, so a short-name
1501        // input never shares a prefix with the canonicalized base_path and
1502        // the resulting relative path becomes absolute. Canonicalize first.
1503        #[cfg(windows)]
1504        let canonical_buf: Option<PathBuf> = if path.starts_with(&self.base_path) {
1505            None
1506        } else if let Ok(c) = crate::path_utils::canonicalize(path) {
1507            Some(c)
1508        } else {
1509            tracing::error!(path = ?path.display(), "Failed to canonicalize file path to add");
1510            return None;
1511        };
1512
1513        #[cfg(windows)]
1514        let path_for_index: &Path = canonical_buf.as_deref().unwrap_or(path);
1515        #[cfg(not(windows))]
1516        let path_for_index: &Path = path;
1517
1518        let (mut file_item, rel_path) =
1519            FileItem::new(path_for_index.to_path_buf(), &self.base_path, None);
1520
1521        // we have to perform manual classification for every new file this will be
1522        // batched during the scan, this is the path when the file is ad-hoc added to the sync
1523        file_item.detect_binary_per_byte(
1524            path_for_index,
1525            // inline chunk buf
1526            &mut [0u8; crate::types::BINARY_CLASSIFICATION_CHUNK_SIZE],
1527        );
1528
1529        let builder = self.sync_data.overflow_builder.get_or_insert_with(|| {
1530            // we know that overflow would never create more files during the file
1531            crate::simd_path::ChunkedPathStoreBuilder::new(MAX_OVERFLOW_FILES)
1532        });
1533
1534        file_item.set_path(builder.add_file_immediate(&rel_path, file_item.path.filename_offset));
1535        file_item.set_overflow(true);
1536
1537        if !self.sync_data.files.push(file_item) {
1538            return None;
1539        }
1540
1541        self.sync_data.live_count += 1;
1542        self.sync_data.files.last()
1543    }
1544
1545    fn tombstone_file(&mut self, index: usize) {
1546        let file = &mut self.sync_data.files[index];
1547        if file.is_deleted() {
1548            return;
1549        }
1550
1551        file.set_deleted(true);
1552        file.invalidate_mmap(&self.cache_budget);
1553        file.git_status = None;
1554
1555        // Only base-region files participate in the bigram overlay
1556        if index < self.sync_data.base_count
1557            && let Some(ref overlay) = self.sync_data.bigram_overlay
1558        {
1559            overlay.write().delete_file(index);
1560        }
1561
1562        self.sync_data.live_count -= 1;
1563    }
1564
1565    fn untombstone_file(&mut self, index: usize) {
1566        let file = &mut self.sync_data.files[index];
1567        if !file.is_deleted() {
1568            return;
1569        }
1570        file.set_deleted(false);
1571
1572        self.sync_data.live_count += 1;
1573    }
1574
1575    /// Marks file as deleted, make sure that if you call this yourself these changes can be reverted
1576    /// by the internal mechanics if the file actually exists on the disk, use only if you know that
1577    /// the file going to be disapperaed or if you do not have the watcher installed
1578    pub fn remove_file_by_path(&mut self, path: impl AsRef<Path>) -> bool {
1579        let path = path.as_ref();
1580        if let Some(index) = self.sync_data.find_file_index(path, &self.base_path) {
1581            self.tombstone_file(index);
1582            true
1583        } else {
1584            false
1585        }
1586    }
1587
1588    // TODO make this O(n)
1589    pub fn remove_all_files_in_dir(&mut self, dir: impl AsRef<Path>) -> usize {
1590        let dir_path = dir.as_ref();
1591        let relative_dir = self
1592            .to_relative_path(dir_path)
1593            .map(|c| c.into_owned())
1594            .unwrap_or_default();
1595
1596        let dir_prefix = if relative_dir.is_empty() {
1597            String::new()
1598        } else {
1599            format!("{}{}", relative_dir, std::path::MAIN_SEPARATOR)
1600        };
1601
1602        self.sync_data.tombstone_files_with_arena(|file, arena| {
1603            file.relative_path_starts_with(arena, &dir_prefix)
1604        })
1605    }
1606
1607    /// Use this to prevent any substantial background threads from acquiring the locks
1608    pub fn cancel(&self) {
1609        self.signals.cancelled.store(true, Ordering::Release);
1610    }
1611
1612    /// Stop the background filesystem watcher. Non-blocking.
1613    pub fn stop_background_monitor(&mut self) {
1614        if let Some(mut watcher) = self.background_watcher.take() {
1615            watcher.stop();
1616        }
1617    }
1618
1619    /// Quick way to check if scan is going without acquiring a lock for [Self::get_scan_progress]
1620    pub fn is_scan_active(&self) -> bool {
1621        self.signals.scanning.load(Ordering::Relaxed)
1622    }
1623
1624    pub fn is_post_scan_active(&self) -> bool {
1625        self.signals
1626            .post_scan_indexing_active
1627            .load(Ordering::Acquire)
1628    }
1629
1630    /// Return a clone of the watcher-ready flag so callers can poll it without
1631    /// holding a lock on the picker.
1632    pub fn watcher_signal(&self) -> Arc<AtomicBool> {
1633        Arc::clone(&self.signals.watcher_ready)
1634    }
1635
1636    /// Convert an absolute path to a relative path string (relative to base_path).
1637    /// Returns None if the path doesn't start with base_path.
1638    ///
1639    /// On Windows the picker canonicalizes its base via `dunce`, so caller
1640    /// paths that still carry 8.3 short names or a different casing would
1641    /// fail a naive prefix check. Fall back to canonicalizing (or, when the
1642    /// file was just deleted, canonicalizing its parent) before stripping.
1643    fn to_relative_path<'a>(&self, path: &'a Path) -> Option<std::borrow::Cow<'a, str>> {
1644        if let Ok(stripped) = path.strip_prefix(&self.base_path)
1645            && let Some(s) = stripped.to_str()
1646        {
1647            return Some(std::borrow::Cow::Borrowed(s));
1648        }
1649
1650        #[cfg(windows)]
1651        {
1652            let rel = canonical_relative_path(path, &self.base_path)?;
1653            return Some(std::borrow::Cow::Owned(rel));
1654        }
1655
1656        #[cfg(not(windows))]
1657        None
1658    }
1659}
1660
1661/// Resolve a possibly-short-name Windows path to the picker's canonical base.
1662/// Used by the Windows-only fallbacks in `to_relative_path` and
1663/// `find_file_index` so events still match tombstoned entries.
1664#[cfg(windows)]
1665fn canonical_relative_path(path: &Path, base: &Path) -> Option<String> {
1666    if let Ok(canonical) = crate::path_utils::canonicalize(path)
1667        && let Ok(stripped) = canonical.strip_prefix(base)
1668        && let Some(s) = stripped.to_str()
1669    {
1670        return Some(s.to_owned());
1671    }
1672
1673    // Deleted files can't be canonicalized — canonicalize the parent and
1674    // re-attach the filename.
1675    let parent = path.parent()?;
1676    let file_name = path.file_name()?;
1677    let canonical_parent = crate::path_utils::canonicalize(parent).ok()?;
1678    let stripped_parent = canonical_parent.strip_prefix(base).ok()?;
1679    let mut rel = stripped_parent.to_path_buf();
1680    rel.push(file_name);
1681    rel.to_str().map(str::to_owned)
1682}
1683
1684impl Drop for FilePicker {
1685    fn drop(&mut self) {
1686        // Cancel any in-flight ScanJob bound to this picker's signals so
1687        // it cannot mutate the replacement picker after a swap.
1688        self.signals.cancelled.store(true, Ordering::Release);
1689    }
1690}
1691
1692#[derive(Debug, Clone, Copy)]
1693enum FileSlot {
1694    Base(usize),
1695    Overflow(usize),
1696}
1697
1698impl FileSlot {
1699    fn index(self) -> usize {
1700        match self {
1701            FileSlot::Base(i) | FileSlot::Overflow(i) => i,
1702        }
1703    }
1704}
1705
1706/// Snapshot of FilePicker state for off-lock post-scan work.
1707///
1708/// Each data field is an Arc-shared clone of the picker's backing
1709/// allocation, so dropping the `FilePicker` (e.g. via
1710/// `SharedFilePicker::write().take()`) cannot free memory this
1711/// snapshot is still reading — UAF is impossible by construction.
1712///
1713/// Implements `Drop` to clear `post_scan_indexing_active`. Since only
1714/// one snapshot can exist at a time (enforced by the flag check in
1715/// `post_scan_snapshot`) and it is always created/dropped within
1716/// `ScanJob::run`, `scan_job_running == false` implies no live snapshot.
1717pub(crate) struct PostScanUnsafeSnapshot {
1718    pub files: StableVec<FileItem>,
1719    pub dirs: StableVec<crate::types::DirItem>,
1720    pub arena: Option<Arc<crate::simd_path::ChunkedPathStore>>,
1721    // TODO figure this out
1722    pub _budget: Arc<crate::types::ContentCacheBudget>,
1723    pub base_count: usize,
1724    pub indexable_count: usize,
1725    pub base_path: PathBuf,
1726    pub cancelled: Arc<AtomicBool>,
1727    post_scan_flag: Arc<AtomicBool>,
1728}
1729
1730impl Drop for PostScanUnsafeSnapshot {
1731    fn drop(&mut self) {
1732        self.post_scan_flag.store(false, Ordering::Release);
1733    }
1734}
1735
1736// SAFETY: every data field is Arc-shared and outlives the snapshot
1737// via its own refcount. The mutable cast in `apply_git_status_and_frecency`
1738// is consumed on the scan thread under the single-writer discipline.
1739unsafe impl Send for PostScanUnsafeSnapshot {}
1740unsafe impl Sync for PostScanUnsafeSnapshot {}
1741
1742/// A point-in-time snapshot of the file-scanning progress.
1743///
1744/// Returned by [`FilePicker::get_scan_progress`]. Useful for displaying
1745/// a progress indicator while the initial scan is running.
1746#[derive(Debug, Clone)]
1747pub struct ScanProgress {
1748    pub scanned_files_count: usize,
1749    pub is_scanning: bool,
1750    pub is_watcher_ready: bool,
1751    pub is_warmup_complete: bool,
1752}
1753
1754impl FileSync {
1755    pub(crate) fn discover_git_workdir(base_path: &Path) -> Option<PathBuf> {
1756        let git_workdir = Repository::discover(base_path)
1757            .ok()
1758            .and_then(|repo| repo.workdir().map(Path::to_path_buf))
1759            .map(crate::path_utils::normalize);
1760
1761        match &git_workdir {
1762            Some(workdir) => debug!("Git repository found at: {}", workdir.display()),
1763            None => warn!("No git repository found for path: {}", base_path.display()),
1764        }
1765
1766        git_workdir
1767    }
1768
1769    pub(crate) fn spawn_git_status(git_workdir: PathBuf) -> JoinHandle<Option<GitStatusCache>> {
1770        std::thread::spawn(move || {
1771            GitStatusCache::read_git_status(
1772                Some(git_workdir.as_path()),
1773                &mut crate::git::initial_scan_status_options(),
1774            )
1775        })
1776    }
1777
1778    /// Returns files immediately (searchable) and a handle to the in-progress
1779    /// git status computation. This avoids blocking on `git status` which can
1780    /// take 10+ seconds on very large repos (e.g. chromium).
1781    #[tracing::instrument(skip_all, name = "walk_filesystem", level = Level::INFO)]
1782    pub(crate) fn walk_filesystem(
1783        base_path: &Path,
1784        git_workdir: Option<PathBuf>,
1785        synced_files_count: &Arc<AtomicUsize>,
1786        shared_frecency: &SharedFrecency,
1787        mode: FFFMode,
1788        follow_symlinks: bool,
1789    ) -> Result<FileSync, Error> {
1790        use ignore::WalkBuilder;
1791
1792        let scan_start = std::time::Instant::now();
1793        info!("SCAN: Starting filesystem walk and git status (async)");
1794
1795        // Walk files (the fast part, typically 2-3s even on huge repos).
1796        let is_git_repo = git_workdir.is_some();
1797        let bg_threads = BACKGROUND_THREAD_POOL.current_num_threads();
1798
1799        let mut walk_builder = WalkBuilder::new(base_path);
1800        walk_builder
1801            // this is a very important guard for the user opening ~/ or other root non-git dir
1802            .hidden(!is_git_repo)
1803            .git_ignore(true)
1804            .git_exclude(true)
1805            .git_global(true)
1806            .ignore(true)
1807            .follow_links(follow_symlinks)
1808            .threads(bg_threads);
1809
1810        if !is_git_repo && let Some(overrides) = non_git_repo_overrides(base_path) {
1811            walk_builder.overrides(overrides);
1812        }
1813
1814        let walker = walk_builder.build_parallel();
1815        let walker_start = std::time::Instant::now();
1816        debug!("SCAN: Starting file walker");
1817
1818        // Walk: collect (FileItem, rel_path) pairs. Keep the walk fast —
1819        // no chunking, no HashMap, just Vec::push under the Mutex.
1820        let pairs = parking_lot::Mutex::new(Vec::<(FileItem, String)>::new());
1821
1822        let walker_span = tracing::info_span!("walker_run").entered();
1823        walker.run(|| {
1824            let pairs = &pairs;
1825            let counter = Arc::clone(synced_files_count);
1826            let base_path = base_path.to_path_buf();
1827
1828            Box::new(move |result| {
1829                let Ok(entry) = result else {
1830                    return ignore::WalkState::Continue;
1831                };
1832
1833                if entry.file_type().is_some_and(|ft| ft.is_file()) {
1834                    let path = entry.path();
1835
1836                    // Ignore walkers sometimes surface files inside `.git/`
1837                    // when the base is itself a git repo — skip them.
1838                    if is_git_file(path) {
1839                        return ignore::WalkState::Continue;
1840                    }
1841
1842                    if !is_git_repo && is_known_binary_extension(path) {
1843                        return ignore::WalkState::Continue;
1844                    }
1845
1846                    let metadata = entry.metadata().ok();
1847                    let (file_item, rel_path) =
1848                        FileItem::new_from_walk(path, &base_path, None, metadata.as_ref());
1849
1850                    pairs.lock().push((file_item, rel_path));
1851                    counter.fetch_add(1, Ordering::Relaxed);
1852                }
1853                ignore::WalkState::Continue
1854            })
1855        });
1856        drop(walker_span);
1857
1858        let mut pairs = pairs.into_inner();
1859        info!(
1860            "SCAN: File walking completed in {:?} for {} files",
1861            walker_start.elapsed(),
1862            pairs.len(),
1863        );
1864
1865        // Sort by (dir_part, filename). This groups files by their directory
1866        // into contiguous runs so the linear dir-extraction pass below can
1867        // dedupe by comparing only against the previous dir.
1868        BACKGROUND_THREAD_POOL.install(|| {
1869            pairs.par_sort_unstable_by(|(a, path_a), (b, path_b)| {
1870                // SAFETY: `filename_offset` is always at a character boundary
1871                let (a_dir, a_file) = path_a.split_at(a.path.filename_offset as usize);
1872                let (b_dir, b_file) = path_b.split_at(b.path.filename_offset as usize);
1873                a_dir.cmp(b_dir).then_with(|| a_file.cmp(b_file))
1874            });
1875        });
1876
1877        let mut builder = crate::simd_path::ChunkedPathStoreBuilder::new(pairs.len());
1878        let dirs = populates_dirs_files_chunked_storage(&mut pairs, &mut builder);
1879
1880        let mut files: Vec<FileItem> = pairs.into_iter().map(|(file, _)| file).collect();
1881        let chunked_paths = builder.finish();
1882        let arena = chunked_paths.as_arena_ptr();
1883
1884        // Apply frecency scores (access-based only — git status not yet available).
1885        // DirItem.max_access_frecency is AtomicI32, so parallel threads write directly.
1886        let frecency = shared_frecency
1887            .read()
1888            .map_err(|_| Error::AcquireFrecencyLock)?;
1889
1890        if let Some(frecency) = frecency.as_ref() {
1891            let dirs_ref = &dirs;
1892            BACKGROUND_THREAD_POOL.install(|| {
1893                files.par_iter_mut().for_each(|file| {
1894                    let _ = file.update_frecency_scores(frecency, arena, base_path, mode);
1895                    let score = file.access_frecency_score as i32;
1896                    if score > 0 {
1897                        let dir_idx = file.parent_dir_index as usize;
1898                        if let Some(dir) = dirs_ref.get(dir_idx) {
1899                            dir.update_frecency_if_larger(score);
1900                        }
1901                    }
1902                });
1903            });
1904        }
1905        drop(frecency);
1906
1907        // un-indexable files that are binary or not fitting the size cap has to beplaced in the end
1908        let is_indexable = |f: &FileItem| {
1909            !f.is_binary()
1910                && f.size > 0
1911                && f.size <= crate::constants::MAX_INDEXABLE_FILE_SIZE as u64
1912        };
1913
1914        BACKGROUND_THREAD_POOL.install(|| {
1915            files.par_sort_unstable_by(|a, b| {
1916                (!is_indexable(a))
1917                    .cmp(&!is_indexable(b))
1918                    // this just makes it faster in terms of allocation - we store the dir indexes
1919                    .then_with(|| a.parent_dir_index.cmp(&b.parent_dir_index))
1920                    .then_with(|| a.file_name(arena).cmp(&b.file_name(arena)))
1921            });
1922        });
1923        let indexable_count = files.partition_point(is_indexable);
1924
1925        // Ask the allocator to return freed pages to the OS.
1926        hint_allocator_collect();
1927
1928        let file_item_size = std::mem::size_of::<FileItem>();
1929        let files_vec_bytes = files.len() * file_item_size;
1930        let dir_table_bytes = dirs.len() * std::mem::size_of::<DirItem>()
1931            + dirs
1932                .iter()
1933                .map(|d| d.relative_path(arena).len())
1934                .sum::<usize>();
1935
1936        let total_time = scan_start.elapsed();
1937        info!(
1938            "SCAN: Walk completed in {:?} ({} files, {} dirs, \
1939         chunked_store={:.2}MB, files_vec={:.2}MB, dirs={:.2}MB, FileItem={}B)",
1940            total_time,
1941            files.len(),
1942            dirs.len(),
1943            chunked_paths.heap_bytes() as f64 / 1_048_576.0,
1944            files_vec_bytes as f64 / 1_048_576.0,
1945            dir_table_bytes as f64 / 1_048_576.0,
1946            file_item_size,
1947        );
1948
1949        let base_count = files.len();
1950
1951        Ok(FileSync {
1952            files: StableVec::from_vec_with_reserve(files, MAX_OVERFLOW_FILES),
1953            indexable_count,
1954            base_count,
1955            live_count: base_count,
1956            dirs: StableVec::from_vec_with_reserve(dirs, 0),
1957            overflow_builder: None,
1958            git_workdir,
1959            bigram_index: None,
1960            bigram_overlay: None,
1961            chunked_paths: Some(Arc::new(chunked_paths)),
1962        })
1963    }
1964}
1965
1966/// Pre-populate mmap caches for cold tail files so the first grep search
1967/// doesn't pay the mmap creation + page fault cost.
1968#[allow(dead_code)]
1969#[tracing::instrument(skip(files), name = "warmup_mmaps", level = Level::DEBUG)]
1970pub(crate) fn warmup_mmaps(
1971    files: &[FileItem],
1972    budget: &ContentCacheBudget,
1973    base_path: &Path,
1974    arena: ArenaPtr,
1975) {
1976    // for most of the use cases mmaps limit would be significantly smaller than arepo
1977    for file in files.iter() {
1978        if file.is_likely_hot()
1979            || file.is_binary()
1980            || file.size == 0
1981            || file.size > budget.max_file_size
1982        {
1983            continue;
1984        }
1985
1986        let _ = file.get_cached_content(arena, base_path, budget);
1987
1988        if budget.is_exhausted() {
1989            break;
1990        }
1991    }
1992}
1993
1994/// This does both thing (yes sorry all the OOP morons)
1995/// in one go: populates files chunked storage and creates new directories
1996fn populates_dirs_files_chunked_storage<'a>(
1997    pairs: &'a mut [(FileItem, String)],
1998    chunk_storage: &mut crate::simd_path::ChunkedPathStoreBuilder,
1999) -> Vec<DirItem> {
2000    let mut dirs: Vec<DirItem> = Vec::new();
2001
2002    let mut prev_dir: &'a str = "";
2003    let mut prev_dir_valid = false;
2004    let mut current_dir_idx: u32 = 0;
2005
2006    for (file, rel) in pairs.iter_mut() {
2007        let rel: &'a str = rel;
2008        let dir_part: &'a str = &rel[..file.path.filename_offset as usize];
2009
2010        if !prev_dir_valid || prev_dir != dir_part {
2011            let dir_string = chunk_storage.add_dir_immediate(dir_part);
2012
2013            // Compute last-segment offset: for "src/components/" -> 4 (points to "components/")
2014            let last_seg = if dir_part.is_empty() {
2015                0
2016            } else {
2017                let trimmed = dir_part.trim_end_matches(std::path::is_separator);
2018                trimmed
2019                    .rfind(std::path::is_separator)
2020                    .map(|i| i + 1)
2021                    .unwrap_or(0) as u16
2022            };
2023
2024            dirs.push(DirItem::new(dir_string, last_seg));
2025            current_dir_idx = (dirs.len() - 1) as u32;
2026
2027            prev_dir = dir_part;
2028            prev_dir_valid = true;
2029        }
2030
2031        file.path = chunk_storage.add_file_immediate(rel, file.path.filename_offset);
2032        file.parent_dir_index = current_dir_idx;
2033    }
2034
2035    dirs
2036}
2037
2038/// Fast extension-based binary detection. Avoids opening files during scan.
2039/// Covers the vast majority of binary files in typical repositories.
2040#[inline]
2041#[doc(hidden)]
2042pub fn is_known_binary_extension(path: &Path) -> bool {
2043    let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
2044        return false;
2045    };
2046
2047    matches!(
2048        ext,
2049        // Images
2050        "png" | "jpg" | "jpeg" | "gif" | "bmp" | "ico" | "webp" | "tiff" | "tif" | "avif" |
2051        "heic" | "heif" | "jxl" | "jp2" | "j2k" | "psd" | "icns" | "cur" | "cr2" |
2052        "nef" | "dng" | "tga" |
2053        // GPU / VFX texture formats
2054        "rgbe" | "hdr" | "exr" | "dds" | "ktx" | "ktx2" | "pvr" | "astc" |
2055        // Adobe Illustrator (PDF wrapper) / Apple webarchive / MIME HTML archive
2056        "ai" | "webarchive" | "mhtml" |
2057        // Video/Audio
2058        "mp4" | "avi" | "mov" | "wmv" | "mkv" | "mp3" | "wav" | "flac" | "ogg" | "m4a" |
2059        "aac" | "webm" | "flv" | "mpg" | "mpeg" | "wma" | "opus" | "pcm" | "reapeaks" |
2060        // Compressed/Archives
2061        "zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" | "zst" | "lz4" | "lzma" |
2062        "cab" | "cpio" | "jsonlz4" |
2063        // Packages/Installers
2064        "deb" | "rpm" | "apk" | "dmg" | "msi" | "iso" | "nupkg" | "whl" | "egg" |
2065        "appimage" | "flatpak" | "crx" | "pak" |
2066        // Executables/Libraries
2067        "exe" | "dll" | "so" | "dylib" | "o" | "a" | "lib" | "bin" | "elf" |
2068        // Documents (binary office formats)
2069        "pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" |
2070        // Databases
2071        "db" | "sqlite" | "sqlite3" | "mdb" |
2072        // SQLite / LevelDB auxiliary files
2073        "sqlite-wal" | "sqlite-shm" | "sqlite3-wal" | "sqlite3-shm" |
2074        "db-wal" | "db-shm" | "ldb" |
2075        // Fonts
2076        "ttf" | "otf" | "woff" | "woff2" | "eot" |
2077        // Compiled/Runtime
2078        "class" | "pyc" | "pyo" | "wasm" | "dex" | "jar" | "war" |
2079        // OCaml / Swift / Objective-C build artefacts
2080        "cmi" | "cmt" | "cmti" | "cmx" | "nib" |
2081        "swiftdeps" | "swiftdeps~" | "swiftdoc" | "swiftmodule" | "swiftsourceinfo" |
2082        // ML/Data Science
2083        "npy" | "npz" | "h5" | "hdf5" | "pt" | "onnx" |
2084        "safetensors" | "tfrecord" | "tflite" | "gguf" | "ggml" | "joblib" |
2085        // 3D/Game assets
2086        "glb" | "blend" | "blp" |
2087        // Gzipped-XML / binary maps
2088        "dia" | "bcmap" |
2089        // Protobuf wire format
2090        "pb" |
2091        // Data/serialized
2092        "parquet" | "arrow" |
2093        // IDE/OS metadata
2094        "suo"
2095    )
2096}
2097
2098/// Length of the longest shared directory prefix of two relative dir
2099/// paths (without a trailing separator), measured as the number of bytes
2100/// up to and including the last shared separator — plus the full shorter
2101/// path when it is itself a directory prefix of the longer one.
2102///
2103/// Examples:
2104///   `"src/components"` vs `"src/routes"`   → 4  (`"src/"` emitted once)
2105///   `"lib/deep/nested"` vs `"lib/deep"`   → 8  (`"lib/deep"` is a prefix)
2106///   `"lib/deep"` vs `"lib/deeper"`        → 4  (only `"lib/"` is shared)
2107///   `"lib"` vs `"src"`                    → 0
2108///
2109/// Used by [`FilePicker::for_each_watch_dir`] to avoid re-emitting
2110/// ancestors that were already yielded for the previous (sorted) sibling.
2111fn common_dir_prefix_len(a: &str, b: &str) -> usize {
2112    let max = a.len().min(b.len());
2113    let a_bytes = a.as_bytes();
2114    let b_bytes = b.as_bytes();
2115    let mut last_sep = 0;
2116    let mut i = 0;
2117    while i < max && a_bytes[i] == b_bytes[i] {
2118        if std::path::is_separator(a_bytes[i] as char) {
2119            last_sep = i + 1;
2120        }
2121        i += 1;
2122    }
2123    // If one string is a prefix of the other and the next byte in the
2124    // longer one is a separator, the full shorter path is a shared dir.
2125    if i == max && i > 0 {
2126        let longer = if a.len() > b.len() { a_bytes } else { b_bytes };
2127        if i < longer.len() && std::path::is_separator(longer[i] as char) {
2128            return i;
2129        }
2130    }
2131    last_sep
2132}
2133
2134/// Ask the global allocator to return freed pages to the OS.
2135/// Enabled via the `mimalloc-collect` feature (set by fff-nvim).
2136/// No-op when the feature is off (tests, system allocator).
2137pub(crate) fn hint_allocator_collect() {
2138    #[cfg(feature = "mimalloc-collect")]
2139    {
2140        // Collect BACKGROUND_THREAD_POOL workers — that's where the bigram
2141        // builder allocated memory. `rayon::broadcast` would target the global
2142        // pool, which is the wrong set of threads.
2143        BACKGROUND_THREAD_POOL.broadcast(|_| unsafe { libmimalloc_sys::mi_collect(true) });
2144
2145        // Main thread too.
2146        unsafe { libmimalloc_sys::mi_collect(true) };
2147    }
2148}
2149
2150#[cfg(test)]
2151mod tests {
2152    use super::*;
2153
2154    /// The watcher must watch every ancestor directory up to `base_path`,
2155    /// not just the immediate parents of indexed files. Intermediate dirs
2156    /// that contain only subdirectories (no direct files) are NOT in
2157    /// `sync_data.dirs` — yet they must still appear in `extract_watch_dirs`
2158    /// so Create events on new subdirectories below them fire.
2159    ///
2160    /// Correctness regression guard for any refactor that replaces the
2161    /// ancestor walk with a direct `sync_data.dirs` iteration.
2162    #[test]
2163    fn extract_watch_dirs_includes_pure_ancestor_dirs() {
2164        let dir = tempfile::tempdir().unwrap();
2165        // On Windows the picker canonicalizes base_path with dunce; match that
2166        // here so the stored dir paths line up with assertions built from
2167        // `base.join(..)` (which otherwise would carry an 8.3 short name).
2168        let base_buf = crate::path_utils::canonicalize(dir.path()).unwrap();
2169        let base = base_buf.as_path();
2170
2171        // Tree:
2172        //   base/src/components/button.txt    (src/components has a file)
2173        //   base/src/routes/home.txt          (src/routes has a file)
2174        //   base/lib/deep/nested/util.txt     (lib and lib/deep have no files)
2175        //
2176        // `sync_data.dirs` will only contain:
2177        //   src/components/
2178        //   src/routes/
2179        //   lib/deep/nested/
2180        //
2181        // But the watcher also needs:
2182        //   src/       (pure ancestor — no direct files)
2183        //   lib/       (pure ancestor)
2184        //   lib/deep/  (pure ancestor)
2185        // otherwise new siblings like `src/NewDir/x.txt` are missed.
2186        for rel in [
2187            "src/components/button.txt",
2188            "src/routes/home.txt",
2189            "lib/deep/nested/util.txt",
2190        ] {
2191            let path = base.join(rel);
2192            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2193            std::fs::write(&path, b"x").unwrap();
2194        }
2195
2196        let mut picker = FilePicker::new(FilePickerOptions {
2197            base_path: base.to_str().unwrap().into(),
2198            watch: false,
2199            ..Default::default()
2200        })
2201        .unwrap();
2202        picker.collect_files().unwrap();
2203
2204        let mut watch_dirs: Vec<PathBuf> = Vec::new();
2205        picker.for_each_dir(|p| {
2206            watch_dirs.push(p.to_path_buf());
2207            std::ops::ControlFlow::Continue(())
2208        });
2209        let watch_set: std::collections::HashSet<PathBuf> = watch_dirs.iter().cloned().collect();
2210
2211        // Immediate parents (in sync_data.dirs) must be present.
2212        for rel in ["src/components", "src/routes", "lib/deep/nested"] {
2213            assert!(
2214                watch_set.contains(&base.join(rel)),
2215                "expected immediate parent {rel} in watch dirs, got {watch_set:?}",
2216            );
2217        }
2218
2219        // Pure-ancestor dirs (NOT in sync_data.dirs) must also be present.
2220        for rel in ["src", "lib", "lib/deep"] {
2221            assert!(
2222                watch_set.contains(&base.join(rel)),
2223                "expected pure-ancestor {rel} in watch dirs, got {watch_set:?}",
2224            );
2225        }
2226
2227        // No duplicates — streaming dedup must not emit the same dir twice.
2228        assert_eq!(
2229            watch_dirs.len(),
2230            watch_set.len(),
2231            "duplicate watch dir emitted: {watch_dirs:?}",
2232        );
2233
2234        // Base path itself is NOT walked into the result — the walker stops
2235        // at `current == base`. The outer `debouncer.watch(base_path, ...)`
2236        // call in create_debouncer covers it separately.
2237        assert!(
2238            !watch_set.contains(base),
2239            "base path must not be in watch dirs (covered by the top-level watch call)",
2240        );
2241    }
2242
2243    #[test]
2244    fn common_dir_prefix_len_cases() {
2245        assert_eq!(common_dir_prefix_len("", ""), 0);
2246        assert_eq!(common_dir_prefix_len("", "src"), 0);
2247        assert_eq!(common_dir_prefix_len("lib", "src"), 0);
2248        assert_eq!(common_dir_prefix_len("src/components", "src/routes"), 4);
2249        assert_eq!(common_dir_prefix_len("lib/deep/nested", "lib/deep"), 8);
2250        assert_eq!(common_dir_prefix_len("lib/deep", "lib/deep/nested"), 8);
2251        assert_eq!(common_dir_prefix_len("lib/deep", "lib/deeper"), 4);
2252        assert_eq!(common_dir_prefix_len("src", "src"), 0);
2253        // "src" is emitted-as-dir; "src/x" extends it — full "src" is shared.
2254        assert_eq!(common_dir_prefix_len("src", "src/x"), 3);
2255    }
2256}