1use crate::FFFStringStorage;
32use crate::constants::{MAX_OVERFLOW_FILES, PATH_BUF_SIZE};
33use crate::error::Error;
34use crate::frecency::FrecencyTracker;
35use crate::git::GitStatusCache;
36use crate::git_recency::{self, GitRecencyConfig};
37use crate::grep::{GrepResult, GrepSearchOptions, grep_search, multi_grep_search};
38use crate::index::{BigramFilter, BigramOverlay};
39use crate::query_tracker::QueryTracker;
40use crate::scan::{ScanConfig, ScanJob, ScanSignals};
41use crate::score::{fuzzy_match_and_score_files, fuzzy_match_byte_offsets_for_page};
42use crate::shared::{SharedFilePicker, SharedFrecency};
43use crate::simd_path::ArenaPtr;
44use crate::stable_vec::StableVec;
45use crate::types::{
46 ContentCacheBudget, DirItem, DirSearchResult, FileItem, MixedItemRef, MixedSearchResult,
47 PaginationArgs, Score, ScoringContext, SearchResult,
48};
49use crate::walk::WalkOutput;
50use crate::watch::BackgroundWatcher;
51use ahash::AHashMap;
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#[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 files: StableVec<FileItem>,
104 indexable_count: usize,
105 base_count: usize,
106 pub(crate) live_count: usize,
108 dirs: StableVec<DirItem>,
116 base_dirs_count: usize,
117 live_dirs_count: usize,
119 overflow_builder: Option<crate::simd_path::ChunkedPathStoreBuilder>,
122 bigram_index: Option<Arc<BigramFilter>>,
123 bigram_overlay: Option<Arc<parking_lot::RwLock<BigramOverlay>>>,
124 chunked_paths: Option<Arc<crate::simd_path::ChunkedPathStore>>,
127 pub(crate) ignore_rules: Option<Arc<crate::walk::WalkIgnoreRules>>,
130}
131
132impl FileSync {
133 fn new() -> Self {
134 Self {
135 files: StableVec::from_vec_with_reserve(Vec::new(), MAX_OVERFLOW_FILES),
136 indexable_count: 0,
137 base_count: 0,
138 live_count: 0,
139 dirs: StableVec::from_vec_with_reserve(Vec::new(), MAX_OVERFLOW_FILES),
140 base_dirs_count: 0,
141 live_dirs_count: 0,
142 overflow_builder: None,
143 git_workdir: None,
144 bigram_index: None,
145 bigram_overlay: None,
146 chunked_paths: None,
147 ignore_rules: None,
148 }
149 }
150
151 #[inline]
152 fn arena_base_ptr(&self) -> ArenaPtr {
153 self.chunked_paths
154 .as_ref()
155 .map(|s| s.as_arena_ptr())
156 .unwrap_or(ArenaPtr::null())
157 }
158
159 #[inline]
160 fn arena_overflow_ptr(&self) -> ArenaPtr {
161 self.overflow_builder
162 .as_ref()
163 .map(|b| b.as_arena_ptr())
164 .unwrap_or(ArenaPtr::null())
165 }
166
167 #[inline]
168 fn arena_for_file(&self, file: &FileItem) -> ArenaPtr {
169 if file.is_overflow() {
170 self.arena_overflow_ptr()
171 } else {
172 self.arena_base_ptr()
173 }
174 }
175
176 #[inline]
177 fn files(&self) -> &[FileItem] {
178 &self.files
179 }
180
181 #[inline]
182 fn overflow_files(&self) -> &[FileItem] {
183 &self.files[self.base_count..]
184 }
185
186 #[inline]
187 fn get_file_mut(&mut self, index: usize) -> Option<(ArenaPtr, &mut FileItem)> {
188 Some((
189 if index < self.base_count {
190 self.arena_base_ptr()
191 } else {
192 self.arena_overflow_ptr()
193 },
194 self.files.get_mut(index)?,
195 ))
196 }
197
198 #[inline]
199 fn find_file_index(&self, path: &Path, base_path: &Path) -> Option<usize> {
200 let rel_path_owned: String = match path.strip_prefix(base_path) {
205 Ok(r) => r.to_string_lossy().into_owned(),
206 Err(_) => {
207 #[cfg(windows)]
208 {
209 canonical_relative_path(path, base_path)?
210 }
211 #[cfg(not(windows))]
212 {
213 return None;
214 }
215 }
216 };
217 self.find_by_relative_path(&crate::path_utils::to_canonical_slashes(&rel_path_owned))
220 }
221
222 fn find_by_relative_path(&self, rel_path: &str) -> Option<usize> {
225 let arena = self.arena_base_ptr();
226
227 let parent_end = rel_path
229 .rfind(std::path::is_separator)
230 .map(|i| i + 1)
231 .unwrap_or(0);
232 let dir_rel = &rel_path[..parent_end];
233 let filename = &rel_path[parent_end..];
234
235 let mut dir_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
239 let dir_idx = self.dirs[..self.base_dirs_count]
240 .binary_search_by(|d| d.read_relative_path(arena, &mut dir_buf).cmp(dir_rel))
241 .ok();
242
243 if let Some(dir_idx) = dir_idx {
244 let dir_idx = dir_idx as u32;
245 let cmp_key = |f: &FileItem| {
246 f.parent_dir_index.cmp(&dir_idx).then_with(|| {
247 let fname = f.file_name(arena);
248 fname.as_str().cmp(filename)
249 })
250 };
251
252 if self.indexable_count > 0
253 && let Ok(pos) = self.files[..self.indexable_count].binary_search_by(cmp_key)
254 {
255 return Some(pos);
256 }
257
258 if self.indexable_count < self.base_count
259 && let Ok(rel_pos) =
260 self.files[self.indexable_count..self.base_count].binary_search_by(cmp_key)
261 {
262 return Some(self.indexable_count + rel_pos);
263 }
264 }
265
266 if self.base_count < self.files.len() {
268 let overflow_arena = self.arena_overflow_ptr();
269 if let Some(pos) = self.files[self.base_count..]
270 .iter()
271 .position(|f| f.relative_path_eq(overflow_arena, rel_path))
272 {
273 return Some(self.base_count + pos);
274 }
275 }
276
277 None
278 }
279
280 fn tombstone_files_with_arena<F, T>(&mut self, mut predicate: F, mut on_tombstone: T) -> usize
284 where
285 F: FnMut(&FileItem, ArenaPtr) -> bool,
286 T: FnMut(&mut FileItem, ArenaPtr),
287 {
288 let base_arena = self.arena_base_ptr();
289 let overflow_arena = self.arena_overflow_ptr();
290 let base_count = self.base_count;
291
292 let mut tombstoned = 0usize;
293 for (idx, file) in self.files.iter_mut().enumerate() {
294 if file.is_deleted() {
295 continue;
296 }
297 let arena = if idx < base_count {
298 base_arena
299 } else {
300 overflow_arena
301 };
302 if predicate(file, arena) {
303 on_tombstone(file, arena);
304 file.set_deleted(true);
305 tombstoned += 1;
306 }
307 }
308 self.live_count -= tombstoned;
309 tombstoned
310 }
311
312 fn tombstone_dirs_with_arena<F>(&mut self, mut predicate: F)
315 where
316 F: FnMut(&DirItem, ArenaPtr) -> bool,
317 {
318 let base_arena = self.arena_base_ptr();
319 let overflow_arena = self.arena_overflow_ptr();
320 let base_dirs_count = self.base_dirs_count;
321
322 let mut removed = 0usize;
323 for (idx, dir) in self.dirs.iter_mut().enumerate() {
324 if dir.is_deleted() {
325 continue;
326 }
327 let arena = if idx < base_dirs_count {
328 base_arena
329 } else {
330 overflow_arena
331 };
332 if predicate(dir, arena) && dir.set_deleted(true) {
333 removed += 1;
334 }
335 }
336 self.live_dirs_count -= removed;
337 }
338
339 fn revive_dir(&mut self, dir_idx: u32) {
341 if let Some(dir) = self.dirs.get_mut(dir_idx as usize)
342 && dir.set_deleted(false)
343 {
344 self.live_dirs_count += 1;
345 }
346 }
347
348 fn find_dir_index(&self, dir_rel: &str) -> Option<usize> {
351 let arena = self.arena_base_ptr();
352 let mut dir_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
353 if let Ok(idx) = self.dirs[..self.base_dirs_count]
354 .binary_search_by(|d| d.read_relative_path(arena, &mut dir_buf).cmp(dir_rel))
355 {
356 return Some(idx);
357 }
358
359 let overflow_arena = self.arena_overflow_ptr();
361 self.dirs[self.base_dirs_count..]
362 .iter()
363 .position(|d| d.read_relative_path(overflow_arena, &mut dir_buf) == dir_rel)
364 .map(|pos| self.base_dirs_count + pos)
365 }
366
367 fn find_or_add_dir(&mut self, dir_rel: &str) -> Option<u32> {
370 if let Some(idx) = self.find_dir_index(dir_rel) {
371 return Some(idx as u32);
372 }
373
374 let builder = self.overflow_builder.get_or_insert_with(|| {
375 crate::simd_path::ChunkedPathStoreBuilder::new(MAX_OVERFLOW_FILES)
376 });
377 let chunked = builder.add_dir_immediate(dir_rel);
378
379 let last_seg = if dir_rel.is_empty() {
380 0
381 } else {
382 let trimmed = dir_rel.trim_end_matches(std::path::is_separator);
383 trimmed
384 .rfind(std::path::is_separator)
385 .map(|i| i + 1)
386 .unwrap_or(0) as u16
387 };
388
389 let idx = self.dirs.len();
390 if !self.dirs.push(DirItem::new_overflow(chunked, last_seg)) {
391 return None;
392 }
393 self.live_dirs_count += 1;
394 Some(idx as u32)
395 }
396}
397
398impl FileItem {
399 pub fn new(path: PathBuf, base_path: &Path, git_status: Option<Status>) -> (Self, String) {
400 let metadata = std::fs::metadata(&path).ok();
401 Self::new_with_metadata(path, base_path, git_status, metadata.as_ref())
402 }
403
404 fn new_with_metadata(
408 path: PathBuf,
409 base_path: &Path,
410 git_status: Option<Status>,
411 metadata: Option<&std::fs::Metadata>,
412 ) -> (Self, String) {
413 let path_buf = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
414 let relative_path =
416 crate::path_utils::to_canonical_slashes(&path_buf.to_string_lossy()).into_owned();
417
418 let (size, modified) = match metadata {
419 Some(metadata) => {
420 let size = metadata.len();
421 let modified = metadata
422 .modified()
423 .ok()
424 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
425 .map_or(0, |d| d.as_secs());
426
427 (size, modified)
428 }
429 None => (0, 0),
430 };
431
432 let is_binary = is_known_binary_extension(&path);
433
434 let filename_start = relative_path
435 .rfind(std::path::is_separator)
436 .map(|i| i + 1)
437 .unwrap_or(0) as u16;
438
439 let item = Self::new_raw(filename_start, size, modified, git_status, is_binary);
440 (item, relative_path)
441 }
442
443 pub fn new_from_walk(
449 path: &Path,
450 base_path: &Path,
451 git_status: Option<Status>,
452 metadata: Option<&std::fs::Metadata>,
453 ) -> (Self, String) {
454 let (size, modified) = match metadata {
455 Some(metadata) => {
456 let size = metadata.len();
457 let modified = metadata
458 .modified()
459 .ok()
460 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
461 .map_or(0, |d| d.as_secs());
462 (size, modified)
463 }
464 None => (0, 0),
465 };
466
467 Self::new_from_walk_parts(path, base_path, git_status, size, modified)
468 }
469
470 pub fn new_from_walk_parts(
474 path: &Path,
475 base_path: &Path,
476 git_status: Option<Status>,
477 size: u64,
478 modified: u64,
479 ) -> (Self, String) {
480 let is_binary = is_known_binary_extension(path);
481
482 let rel = pathdiff::diff_paths(path, base_path).unwrap_or_else(|| path.to_path_buf());
483 let rel_str = crate::path_utils::to_canonical_slashes(&rel.to_string_lossy()).into_owned();
485 let fname_offset = rel_str
486 .rfind(std::path::is_separator)
487 .map(|i| i + 1)
488 .unwrap_or(0) as u16;
489
490 let item = Self::new_raw(fname_offset, size, modified, git_status, is_binary);
491 (item, rel_str)
492 }
493
494 pub fn new_from_walk_bytes(
504 path: &Path,
505 relative_path: &[u8],
506 basename_offset: u16,
507 git_status: Option<Status>,
508 size: u64,
509 modified: u64,
510 ) -> (Self, String) {
511 let is_binary = is_known_binary_extension(path);
512 let decoded = String::from_utf8_lossy(relative_path).into_owned();
515 let dir_bytes = relative_path
518 .get(..basename_offset as usize)
519 .unwrap_or(relative_path);
520 let basename_offset = if std::str::from_utf8(dir_bytes).is_ok() {
521 basename_offset
522 } else {
523 String::from_utf8_lossy(dir_bytes).len() as u16
524 };
525 let item = Self::new_raw(basename_offset, size, modified, git_status, is_binary);
526 (item, decoded)
527 }
528
529 pub(crate) fn update_frecency_scores(
530 &mut self,
531 tracker: &FrecencyTracker,
532 arena: ArenaPtr,
533 base_path: &Path,
534 mode: FFFMode,
535 ) -> Result<(), Error> {
536 let mut abs_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
537 let abs = self.write_absolute_path(arena, base_path, &mut abs_buf);
538 self.access_frecency_score = tracker.get_access_score(abs, mode) as i16;
539 self.modification_frecency_score =
540 tracker.get_modification_score(self.modified, self.git_status, mode) as i16;
541
542 Ok(())
543 }
544}
545
546pub struct FilePickerOptions {
548 pub base_path: String,
549 pub enable_mmap_cache: bool,
551 pub enable_content_indexing: bool,
553 pub mode: FFFMode,
555 pub cache_budget: Option<ContentCacheBudget>,
558 pub watch: bool,
560 pub follow_symlinks: bool,
562 pub enable_fs_root_scanning: bool,
564 pub enable_home_dir_scanning: bool,
567 pub git_recency: GitRecencyConfig,
570}
571
572impl Default for FilePickerOptions {
573 fn default() -> Self {
574 Self {
575 base_path: ".".into(),
576 enable_mmap_cache: false,
577 enable_content_indexing: false,
578 mode: FFFMode::default(),
579 cache_budget: None,
580 watch: true,
581 follow_symlinks: false,
582 enable_fs_root_scanning: false,
583 enable_home_dir_scanning: false,
584 git_recency: GitRecencyConfig::default(),
585 }
586 }
587}
588
589pub struct FilePicker {
590 pub mode: FFFMode,
591 pub base_path: PathBuf,
592 sync_data: FileSync,
593 pub(crate) signals: ScanSignals,
594 pub(crate) background_watcher: Option<BackgroundWatcher>,
595 pub(crate) git_status_worker: Arc<crate::git_status_worker::GitStatusWorker>,
599 cache_budget: Arc<ContentCacheBudget>,
600 has_explicit_cache_budget: bool,
601 scanned_files_count: Arc<AtomicUsize>,
602 enable_mmap_cache: bool,
603 enable_content_indexing: bool,
604 watch: bool,
605 follow_symlinks: bool,
606 enable_fs_root_scanning: bool,
607 enable_home_dir_scanning: bool,
608 git_recency_config: GitRecencyConfig,
609 trace_span: tracing::Span,
610 trace_id: String,
611}
612
613impl std::fmt::Debug for FilePicker {
614 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
615 f.debug_struct("FilePicker")
616 .field("base_path", &self.base_path)
617 .field("sync_data", &self.sync_data)
618 .field(
619 "is_scanning",
620 &self.signals.scanning.load(Ordering::Relaxed),
621 )
622 .field(
623 "scanned_files_count",
624 &self.scanned_files_count.load(Ordering::Relaxed),
625 )
626 .finish_non_exhaustive()
627 }
628}
629
630impl FFFStringStorage for &FilePicker {
631 #[inline]
632 fn arena_for(&self, file: &FileItem) -> crate::simd_path::ArenaPtr {
633 self.sync_data.arena_for_file(file)
634 }
635
636 #[inline]
637 fn base_arena(&self) -> crate::simd_path::ArenaPtr {
638 self.sync_data.arena_base_ptr()
639 }
640
641 #[inline]
642 fn overflow_arena(&self) -> crate::simd_path::ArenaPtr {
643 self.sync_data.arena_overflow_ptr()
644 }
645}
646
647impl FilePicker {
648 pub fn base_path(&self) -> &Path {
649 &self.base_path
650 }
651
652 pub fn has_git_repo(&self) -> bool {
653 self.sync_data.git_workdir.is_some()
654 }
655
656 pub(crate) fn ignore_rules(&self) -> Option<Arc<crate::walk::WalkIgnoreRules>> {
661 self.sync_data.ignore_rules.clone()
662 }
663
664 pub fn has_mmap_cache(&self) -> bool {
665 self.enable_mmap_cache
666 }
667
668 pub fn has_content_indexing(&self) -> bool {
669 self.enable_content_indexing
670 }
671
672 pub fn has_watcher(&self) -> bool {
673 self.watch
674 }
675
676 pub fn is_watcher_ready(&self) -> bool {
677 self.background_watcher.is_some() && self.signals.watcher_ready.load(Ordering::Acquire)
678 }
679
680 pub fn follows_symlinks(&self) -> bool {
681 self.follow_symlinks
682 }
683
684 pub fn fs_root_scanning_enabled(&self) -> bool {
685 self.enable_fs_root_scanning
686 }
687
688 pub fn home_dir_scanning_enabled(&self) -> bool {
689 self.enable_home_dir_scanning
690 }
691
692 pub fn git_recency_config(&self) -> GitRecencyConfig {
693 self.git_recency_config
694 }
695
696 pub fn trace_id(&self) -> &str {
697 &self.trace_id
698 }
699
700 pub fn trace_span(&self) -> tracing::Span {
701 self.trace_span.clone()
702 }
703
704 pub fn mode(&self) -> FFFMode {
705 self.mode
706 }
707
708 pub fn cache_budget(&self) -> &ContentCacheBudget {
709 &self.cache_budget
710 }
711
712 pub fn bigram_index(&self) -> Option<&BigramFilter> {
713 self.sync_data.bigram_index.as_deref()
714 }
715
716 pub fn bigram_overlay(&self) -> Option<&parking_lot::RwLock<BigramOverlay>> {
717 self.sync_data.bigram_overlay.as_deref()
718 }
719
720 pub fn get_file_mut(&mut self, index: usize) -> Option<(ArenaPtr, &mut FileItem)> {
721 self.sync_data.get_file_mut(index)
722 }
723
724 pub fn git_root(&self) -> Option<&Path> {
727 self.sync_data.git_workdir.as_deref()
728 }
729
730 pub fn has_explicit_cache_budget(&self) -> bool {
731 self.has_explicit_cache_budget
732 }
733
734 pub fn set_cache_budget(&mut self, budget: ContentCacheBudget) {
735 self.cache_budget = Arc::new(budget);
736 }
737
738 pub fn get_files(&self) -> &[FileItem] {
742 self.sync_data.files()
743 }
744
745 #[inline]
747 pub fn live_file_count(&self) -> usize {
748 self.sync_data.live_count
749 }
750
751 pub fn get_overflow_files(&self) -> &[FileItem] {
752 self.sync_data.overflow_files()
753 }
754
755 pub fn get_dirs(&self) -> &[DirItem] {
757 &self.sync_data.dirs
758 }
759
760 pub fn arena_bytes(&self) -> (usize, usize, usize) {
763 let chunked = self
764 .sync_data
765 .chunked_paths
766 .as_ref()
767 .map_or(0, |s| s.heap_bytes());
768
769 (chunked, 0, 0)
770 }
771
772 #[tracing::instrument(level = "debug", skip_all)]
773 pub(crate) fn for_each_dir(&self, mut f: impl FnMut(&Path) -> ControlFlow<()>) {
774 let dir_table = &self.sync_data.dirs;
775 let base = self.base_path.as_path();
776
777 if !dir_table.is_empty() {
778 let arena = self.arena_base_ptr();
779 let overflow_arena = self.sync_data.arena_overflow_ptr();
780 let mut path_buf = PathBuf::with_capacity(crate::simd_path::PATH_BUF_SIZE);
781 let mut prev_relative_path = String::new();
782
783 let mut scratch_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
784 for dir_item in dir_table.iter() {
785 if dir_item.is_deleted() {
786 continue;
787 }
788 let item_arena = if dir_item.is_overflow() {
789 overflow_arena
790 } else {
791 arena
792 };
793 let full_relative_path = dir_item.read_relative_path(item_arena, &mut scratch_buf);
794 let relative_path = full_relative_path.trim_end_matches(std::path::is_separator);
795
796 if relative_path.is_empty() {
797 prev_relative_path.clear();
799 continue;
800 }
801
802 let mut i = common_dir_prefix_len(&prev_relative_path, relative_path);
803 if i < relative_path.len()
807 && std::path::is_separator(relative_path.as_bytes()[i] as char)
808 {
809 i += 1;
810 }
811
812 while i < relative_path.len() {
815 let next_sep = relative_path[i..]
816 .find(std::path::is_separator)
817 .map(|off| i + off)
818 .unwrap_or(relative_path.len());
819 let ancestor_rel = &relative_path[..next_sep];
820
821 path_buf.clear();
822 path_buf.push(base);
823 path_buf.push(ancestor_rel);
824
825 if matches!(f(path_buf.as_path()), ControlFlow::Break(())) {
827 return;
828 }
829
830 i = next_sep + 1;
831 }
832
833 prev_relative_path.clear();
834 prev_relative_path.push_str(relative_path);
835 }
836 return;
837 }
838
839 let files = self.sync_data.files();
842 let arena = self.arena_base_ptr();
843 let mut current = self.base_path.clone();
844 let mut path_buf = [0u8; PATH_BUF_SIZE];
845
846 for file in files {
847 let abs = file.write_absolute_path(arena, base, &mut path_buf);
848 let Some(parent) = abs.parent() else {
849 continue;
850 };
851 if parent == current.as_path() {
852 continue;
853 }
854
855 while current.as_path() != base && !parent.starts_with(¤t) {
856 current.pop();
857 }
858
859 let Ok(remainder) = parent.strip_prefix(¤t) else {
860 continue;
861 };
862 for component in remainder.components() {
863 current.push(component);
864 if matches!(f(current.as_path()), ControlFlow::Break(())) {
865 return;
866 }
867 }
868 }
869 }
870
871 pub fn new(options: FilePickerOptions) -> Result<Self, Error> {
875 crate::git::tune_libgit2_for_local_reads();
876
877 let path = PathBuf::from(&options.base_path);
878 if !path.exists() {
879 error!("Base path does not exist: {}", options.base_path);
880 return Err(Error::InvalidPath(path));
881 }
882 let path = if path.is_relative() {
887 std::env::current_dir()
888 .map(|cwd| cwd.join(&path).components().collect())
889 .unwrap_or(path)
890 } else {
891 path
892 };
893 if path.parent().is_none() && !options.enable_fs_root_scanning {
894 error!("Refusing to index filesystem root: {}", path.display());
895 return Err(Error::FilesystemRoot(path));
896 }
897 if !options.enable_home_dir_scanning
898 && Some(path.as_os_str()) == dirs::home_dir().as_ref().map(|p| p.as_os_str())
899 {
900 error!("Refusing to index home directory: {}", path.display());
901 return Err(Error::FilesystemRoot(path));
902 }
903
904 #[cfg(windows)]
908 let path = crate::path_utils::canonicalize(&path).unwrap_or(path);
909
910 let has_explicit_budget = options.cache_budget.is_some();
911 let initial_budget = options.cache_budget.unwrap_or_default();
912
913 let trace_id = crate::log::generate_trace_id();
914 let trace_span = crate::log::trace_span(&trace_id, "picker");
915
916 Ok(FilePicker {
917 background_watcher: None,
918 git_status_worker: crate::git_status_worker::GitStatusWorker::new(),
919 base_path: path,
920 cache_budget: Arc::new(initial_budget),
921 has_explicit_cache_budget: has_explicit_budget,
922 signals: crate::scan::ScanSignals::default(),
923 mode: options.mode,
924 scanned_files_count: Arc::new(AtomicUsize::new(0)),
925 sync_data: FileSync::new(),
926 enable_mmap_cache: options.enable_mmap_cache,
927 enable_content_indexing: options.enable_content_indexing,
928 watch: options.watch,
929 follow_symlinks: options.follow_symlinks,
930 enable_fs_root_scanning: options.enable_fs_root_scanning,
931 enable_home_dir_scanning: options.enable_home_dir_scanning,
932 git_recency_config: options.git_recency,
933 trace_span,
934 trace_id,
935 })
936 }
937
938 pub fn new_with_shared_state(
941 shared_picker: SharedFilePicker,
942 shared_frecency: SharedFrecency,
943 options: FilePickerOptions,
944 ) -> Result<(), Error> {
945 let picker = Self::new(options)?;
946
947 info!(
948 "Spawning background threads: base_path={}, warmup={}, content_indexing={}, mode={:?}",
949 picker.base_path.display(),
950 picker.enable_mmap_cache,
951 picker.enable_content_indexing,
952 picker.mode,
953 );
954
955 let warmup = picker.enable_mmap_cache;
956 let content_indexing = picker.enable_content_indexing;
957 let watch = picker.watch;
958 let mode = picker.mode;
959 let follow_symlinks = picker.follow_symlinks;
960 let enable_fs_root_scanning = picker.enable_fs_root_scanning;
961 let enable_home_dir_scanning = picker.enable_home_dir_scanning;
962
963 let signals = picker.scan_signals();
964 let scanned_files_counter = picker.scanned_files_counter();
965 let path = picker.base_path.clone();
966 let trace_span = picker.trace_span.clone();
967
968 signals
975 .scanning
976 .store(true, std::sync::atomic::Ordering::Release);
977
978 shared_picker.rebase_watches(&path);
980
981 {
982 let mut guard = shared_picker.write()?;
983 *guard = Some(picker);
984 }
986
987 ScanJob::new_initial(
988 shared_picker,
989 shared_frecency,
990 path,
991 mode,
992 signals,
993 scanned_files_counter,
994 trace_span,
995 ScanConfig {
996 warmup,
997 content_indexing,
998 watch,
999 auto_cache_budget: true,
1000 install_watcher: true,
1001 follow_symlinks,
1002 enable_fs_root_scanning,
1003 enable_home_dir_scanning,
1004 },
1005 )
1006 .spawn();
1007
1008 Ok(())
1009 }
1010
1011 pub fn collect_files(&mut self) -> Result<(), Error> {
1020 self.signals.scanning.store(true, Ordering::Relaxed);
1021 self.scanned_files_count.store(0, Ordering::Relaxed);
1022
1023 let git_workdir = FileSync::discover_git_workdir(&self.base_path);
1024 let git_handle = git_workdir.clone().map(FileSync::spawn_git_status);
1025
1026 let empty_frecency = SharedFrecency::default();
1027 let sync = FileSync::walk_filesystem(
1028 &self.base_path,
1029 git_workdir,
1030 &self.scanned_files_count,
1031 &empty_frecency,
1032 self.mode,
1033 self.follow_symlinks,
1034 )?;
1035
1036 self.sync_data = sync;
1037
1038 if !self.has_explicit_cache_budget {
1039 let file_count = self.sync_data.files().len();
1040 self.cache_budget = Arc::new(ContentCacheBudget::new_for_repo(file_count));
1041 } else {
1042 self.cache_budget.reset();
1043 }
1044
1045 if let Some(handle) = git_handle
1046 && let Ok(Some(git_cache)) = handle.join()
1047 {
1048 let mut path_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
1049
1050 let arena = self.arena_base_ptr();
1051 for file in self.sync_data.files.iter_mut() {
1052 file.git_status = git_cache.lookup_status(file.write_absolute_path(
1053 arena,
1054 &self.base_path,
1055 &mut path_buf,
1056 ));
1057 }
1058 }
1059
1060 if let Some(workdir) = self.sync_data.git_workdir.clone()
1061 && let Ok(repo) = Repository::open(&workdir)
1062 .inspect_err(|e| debug!(?e, ?workdir, "git recency: failed to open repo"))
1063 {
1064 let recency =
1065 git_recency::compute_git_recency(&repo, &self.git_recency_config, &self.base_path);
1066 self.apply_git_recency(recency.as_ref());
1067 }
1068
1069 self.signals.scanning.store(false, Ordering::Relaxed);
1070 Ok(())
1071 }
1072
1073 #[tracing::instrument(skip_all, name = "Fuzzy file search", fields(query = query.raw_query))]
1079 pub fn fuzzy_search<'q>(
1080 &self,
1081 query: &'q FFFQuery<'q>,
1082 query_tracker: Option<&QueryTracker>,
1083 options: FuzzySearchOptions<'q>,
1084 ) -> SearchResult<'_> {
1085 let files = self.get_files();
1086 let max_threads = if options.max_threads == 0 {
1087 std::thread::available_parallelism()
1088 .map(|n| n.get())
1089 .unwrap_or(4)
1090 } else {
1091 options.max_threads
1092 };
1093
1094 debug!(
1095 raw_query = ?query.raw_query,
1096 pagination = ?options.pagination,
1097 ?max_threads,
1098 current_file = ?options.current_file,
1099 "Fuzzy search",
1100 );
1101
1102 let total_files = self.live_file_count();
1103 let location = query.location;
1104
1105 let effective_query = match &query.fuzzy_query {
1107 fff_query_parser::FuzzyQuery::Text(t) => *t,
1108 fff_query_parser::FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
1109 _ => query.raw_query.trim(),
1110 };
1111
1112 let max_typos = (effective_query.len() as u16 / 4).clamp(2, 6);
1114 let last_same_query_entry =
1116 query_tracker
1117 .zip(options.project_path)
1118 .and_then(|(tracker, project_path)| {
1119 tracker
1120 .get_last_query_entry(
1121 query.raw_query,
1122 project_path,
1123 options.min_combo_count,
1124 )
1125 .ok()
1126 .flatten()
1127 });
1128
1129 let context = ScoringContext {
1130 query,
1131 max_typos,
1132 max_threads,
1133 project_path: options.project_path,
1134 current_file: options.current_file,
1135 last_same_query_match: last_same_query_entry,
1136 combo_boost_score_multiplier: options.combo_boost_score_multiplier,
1137 min_combo_count: options.min_combo_count,
1138 pagination: options.pagination,
1139 };
1140
1141 let time = std::time::Instant::now();
1142
1143 let base_arena = self.sync_data.arena_base_ptr();
1144 let overflow_arena = self.sync_data.arena_overflow_ptr();
1145
1146 let (items, scores, total_matched) = fuzzy_match_and_score_files(
1147 files,
1148 &context,
1149 self.sync_data.base_count,
1150 base_arena,
1151 overflow_arena,
1152 );
1153 let match_byte_offsets =
1154 fuzzy_match_byte_offsets_for_page(query, &items, max_typos, base_arena, overflow_arena);
1155
1156 info!(
1157 ?query,
1158 completed_in = ?time.elapsed(),
1159 total_matched,
1160 returned_count = items.len(),
1161 pagination = ?options.pagination,
1162 "Fuzzy search completed",
1163 );
1164
1165 SearchResult {
1166 items,
1167 scores,
1168 match_byte_offsets,
1169 total_matched,
1170 total_files,
1171 location,
1172 }
1173 }
1174
1175 pub fn fuzzy_search_directories<'q>(
1179 &self,
1180 query: &'q FFFQuery<'q>,
1181 options: FuzzySearchOptions<'q>,
1182 ) -> DirSearchResult<'_> {
1183 let dirs = self.get_dirs();
1184 let max_threads = if options.max_threads == 0 {
1185 std::thread::available_parallelism()
1186 .map(|n| n.get())
1187 .unwrap_or(4)
1188 } else {
1189 options.max_threads
1190 };
1191
1192 let total_dirs = self.sync_data.live_dirs_count;
1193
1194 let effective_query = match &query.fuzzy_query {
1195 fff_query_parser::FuzzyQuery::Text(t) => *t,
1196 fff_query_parser::FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
1197 _ => query.raw_query.trim(),
1198 };
1199
1200 let max_typos = (effective_query.len() as u16 / 4).clamp(2, 6);
1201
1202 let context = ScoringContext {
1203 query,
1204 max_typos,
1205 max_threads,
1206 project_path: options.project_path,
1207 current_file: options.current_file,
1208 last_same_query_match: None,
1209 combo_boost_score_multiplier: 0,
1210 min_combo_count: 0,
1211 pagination: options.pagination,
1212 };
1213
1214 let arena = self.sync_data.arena_base_ptr();
1215 let overflow_arena = self.sync_data.arena_overflow_ptr();
1216 let time = std::time::Instant::now();
1217
1218 let (items, scores, total_matched) =
1219 crate::score::fuzzy_match_and_score_dirs(dirs, &context, arena, overflow_arena);
1220
1221 info!(
1222 ?query,
1223 completed_in = ?time.elapsed(),
1224 total_matched,
1225 returned_count = items.len(),
1226 "Directory search completed",
1227 );
1228
1229 DirSearchResult {
1230 items,
1231 scores,
1232 total_matched,
1233 total_dirs,
1234 }
1235 }
1236
1237 pub fn fuzzy_search_mixed<'q>(
1247 &self,
1248 query: &'q FFFQuery<'q>,
1249 query_tracker: Option<&QueryTracker>,
1250 options: FuzzySearchOptions<'q>,
1251 ) -> MixedSearchResult<'_> {
1252 let location = query.location;
1253 let page_offset = options.pagination.offset;
1254 let page_limit = if options.pagination.limit > 0 {
1255 options.pagination.limit
1256 } else {
1257 100
1258 };
1259
1260 let dirs_only =
1261 query.raw_query.ends_with(std::path::MAIN_SEPARATOR) || query.raw_query.ends_with('/');
1262
1263 let internal_limit = page_offset.saturating_add(page_limit).saturating_mul(2);
1265
1266 let dir_options = FuzzySearchOptions {
1267 pagination: PaginationArgs {
1268 offset: 0,
1269 limit: internal_limit,
1270 },
1271 ..options
1272 };
1273 let dir_results = self.fuzzy_search_directories(query, dir_options);
1274
1275 if dirs_only {
1276 let total_matched = dir_results.total_matched;
1277 let total_dirs = dir_results.total_dirs;
1278
1279 let mut merged: Vec<(MixedItemRef<'_>, Score)> =
1280 Vec::with_capacity(dir_results.items.len());
1281 for (dir, score) in dir_results.items.into_iter().zip(dir_results.scores) {
1282 merged.push((MixedItemRef::Dir(dir), score));
1283 }
1284
1285 if page_offset >= merged.len() {
1286 return MixedSearchResult {
1287 items: vec![],
1288 scores: vec![],
1289 total_matched,
1290 total_files: self.live_file_count(),
1291 total_dirs,
1292 location,
1293 };
1294 }
1295
1296 let end = (page_offset + page_limit).min(merged.len());
1297 let page = merged.drain(page_offset..end);
1298 let (items, scores): (Vec<_>, Vec<_>) = page.unzip();
1299
1300 return MixedSearchResult {
1301 items,
1302 scores,
1303 total_matched,
1304 total_files: self.live_file_count(),
1305 total_dirs,
1306 location,
1307 };
1308 }
1309
1310 let file_options = FuzzySearchOptions {
1311 pagination: PaginationArgs {
1312 offset: 0,
1313 limit: internal_limit,
1314 },
1315 ..options
1316 };
1317 let file_results = self.fuzzy_search(query, query_tracker, file_options);
1318
1319 let total_matched = file_results.total_matched + dir_results.total_matched;
1321 let total_files = file_results.total_files;
1322 let total_dirs = dir_results.total_dirs;
1323
1324 let mut merged: Vec<(MixedItemRef<'_>, Score)> =
1325 Vec::with_capacity(file_results.items.len() + dir_results.items.len());
1326
1327 for (file, score) in file_results.items.into_iter().zip(file_results.scores) {
1328 merged.push((MixedItemRef::File(file), score));
1329 }
1330 for (dir, score) in dir_results.items.into_iter().zip(dir_results.scores) {
1331 merged.push((MixedItemRef::Dir(dir), score));
1332 }
1333
1334 merged.sort_unstable_by_key(|b| std::cmp::Reverse(b.1.total));
1336
1337 if page_offset >= merged.len() {
1339 return MixedSearchResult {
1340 items: vec![],
1341 scores: vec![],
1342 total_matched,
1343 total_files,
1344 total_dirs,
1345 location,
1346 };
1347 }
1348
1349 let end = (page_offset + page_limit).min(merged.len());
1350 let page = merged.drain(page_offset..end);
1351 let (items, scores): (Vec<_>, Vec<_>) = page.unzip();
1352
1353 MixedSearchResult {
1354 items,
1355 scores,
1356 total_matched,
1357 total_files,
1358 total_dirs,
1359 location,
1360 }
1361 }
1362
1363 pub fn glob<'p>(
1371 &'p self,
1372 pattern: &'p str,
1373 options: FuzzySearchOptions<'p>,
1374 ) -> SearchResult<'p> {
1375 let query = FFFQuery {
1376 raw_query: pattern,
1377 constraints: vec![fff_query_parser::Constraint::Glob(pattern)],
1378 fuzzy_query: fff_query_parser::FuzzyQuery::Empty,
1379 location: None,
1380 };
1381
1382 self.fuzzy_search(&query, None, options)
1387 }
1388
1389 pub fn grep(&self, query: &FFFQuery<'_>, options: &GrepSearchOptions) -> GrepResult<'_> {
1394 let overlay_guard = self.sync_data.bigram_overlay.as_ref().map(|o| o.read());
1395 let arena = self.arena_base_ptr();
1396 let overflow_arena = self.sync_data.arena_overflow_ptr();
1397 let cancel = options
1398 .abort_signal
1399 .as_deref()
1400 .unwrap_or(&self.signals.cancelled);
1401
1402 SEARCH_THREAD_POOL.install(|| {
1403 grep_search(
1404 self.get_files(),
1405 query,
1406 options,
1407 self.cache_budget(),
1408 self.sync_data.bigram_index.as_deref(),
1409 overlay_guard.as_deref(),
1410 cancel,
1411 &self.base_path,
1412 arena,
1413 overflow_arena,
1414 )
1415 })
1416 }
1417
1418 pub fn multi_grep(
1420 &self,
1421 patterns: &[&str],
1422 constraints: &[fff_query_parser::Constraint<'_>],
1423 options: &GrepSearchOptions,
1424 ) -> GrepResult<'_> {
1425 let overlay_guard = self.sync_data.bigram_overlay.as_ref().map(|o| o.read());
1426 let arena = self.arena_base_ptr();
1427 let overflow_arena = self.sync_data.arena_overflow_ptr();
1428 let cancel = options
1429 .abort_signal
1430 .as_deref()
1431 .unwrap_or(&self.signals.cancelled);
1432
1433 SEARCH_THREAD_POOL.install(|| {
1434 multi_grep_search(
1435 self.get_files(),
1436 patterns,
1437 constraints,
1438 options,
1439 self.cache_budget(),
1440 self.sync_data.bigram_index.as_deref(),
1441 overlay_guard.as_deref(),
1442 cancel,
1443 &self.base_path,
1444 arena,
1445 overflow_arena,
1446 )
1447 })
1448 }
1449
1450 pub fn get_scan_progress(&self) -> ScanProgress {
1452 let scanned_count = self.scanned_files_count.load(Ordering::Relaxed);
1453 let is_scanning = self.signals.scanning.load(Ordering::Relaxed);
1454
1455 ScanProgress {
1456 scanned_files_count: scanned_count,
1457 is_scanning,
1458 is_watcher_ready: self.signals.watcher_ready.load(Ordering::Relaxed),
1459 is_warmup_complete: !self.enable_content_indexing
1460 || self.sync_data.bigram_index.is_some(),
1461 }
1462 }
1463
1464 pub(crate) fn set_bigram_index(&mut self, index: BigramFilter) {
1465 self.sync_data.bigram_index = Some(Arc::new(index));
1466 self.sync_data.bigram_overlay = Some(Arc::new(parking_lot::RwLock::new(
1468 BigramOverlay::new(self.sync_data.indexable_count),
1469 )));
1470 }
1471
1472 pub(crate) fn scan_signals(&self) -> crate::scan::ScanSignals {
1473 self.signals.clone()
1474 }
1475
1476 pub(crate) fn scanned_files_counter(&self) -> Arc<AtomicUsize> {
1477 Arc::clone(&self.scanned_files_count)
1478 }
1479
1480 pub(crate) unsafe fn post_scan_snapshot(&self) -> Option<PostScanUnsafeSnapshot> {
1498 if self
1499 .signals
1500 .post_scan_indexing_active
1501 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1502 .is_err()
1503 {
1504 tracing::error!(
1505 "Can not acquire post scan unsafe snapshot, someone already acquired it"
1506 );
1507 return None;
1508 }
1509
1510 Some(PostScanUnsafeSnapshot {
1511 files: self.sync_data.files.clone(),
1512 arena: self.sync_data.chunked_paths.as_ref().map(Arc::clone),
1513 base_count: self.sync_data.base_count,
1514 indexable_count: self.sync_data.indexable_count,
1515 base_path: self.base_path.clone(),
1516 post_scan_flag: Arc::clone(&self.signals.post_scan_indexing_active),
1517 _budget: Arc::clone(&self.cache_budget),
1518 })
1519 }
1520
1521 pub(crate) fn commit_new_sync(&mut self, sync: FileSync) {
1522 self.sync_data = sync;
1523 self.cache_budget.reset();
1524 }
1525
1526 #[inline]
1527 pub(crate) fn arena_base_ptr(&self) -> ArenaPtr {
1528 self.sync_data.arena_base_ptr()
1529 }
1530
1531 pub(crate) fn update_git_statuses(
1533 &mut self,
1534 status_cache: GitStatusCache,
1535 shared_frecency: &SharedFrecency,
1536 ) -> Result<(), Error> {
1537 debug!(
1538 statuses_count = status_cache.statuses_len(),
1539 "Updating git status",
1540 );
1541
1542 let mode = self.mode;
1543 let bp = self.base_path.clone();
1544 let frecency = shared_frecency.read()?;
1545
1546 status_cache
1547 .into_iter()
1548 .try_for_each(|(path, status)| -> Result<(), Error> {
1549 if let Some((arena, file)) = self.get_mut_file_by_path(&path) {
1550 file.git_status = Some(status);
1551 if let Some(ref f) = *frecency {
1552 file.update_frecency_scores(f, arena, &bp, mode)?;
1553 }
1554 let score = file.access_frecency_score as i32;
1558 let dir_idx = file.parent_dir_index as usize;
1559 if let Some(dir) = self.sync_data.dirs.get(dir_idx) {
1560 dir.update_frecency_if_larger(score);
1561 }
1562 } else {
1563 debug!(?path, "Git status for path not in index, skipping");
1567 }
1568 Ok(())
1569 })?;
1570
1571 Ok(())
1572 }
1573
1574 pub(crate) fn apply_git_recency(&mut self, scores: Option<&AHashMap<String, i16>>) {
1577 if !self.git_recency_config.enabled {
1578 return;
1579 }
1580
1581 for file in self.sync_data.files.iter_mut() {
1582 file.git_recency_score = 0;
1583 }
1584
1585 let Some(scores) = scores else { return };
1586
1587 let mut applied = 0usize;
1588 for (relative_path, score) in scores {
1589 if let Some(index) = self.sync_data.find_by_relative_path(relative_path)
1590 && let Some((_, file)) = self.sync_data.get_file_mut(index)
1591 {
1592 file.git_recency_score = *score;
1593 applied += 1;
1594 }
1595 }
1596
1597 debug!(files_scored = applied, "Git recency scores applied");
1598 }
1599
1600 pub fn update_single_file_frecency(
1601 &mut self,
1602 file_path: impl AsRef<Path>,
1603 frecency_tracker: &FrecencyTracker,
1604 ) -> Result<(), Error> {
1605 let path = file_path.as_ref();
1606
1607 let Some(index) = self.sync_data.find_file_index(path, &self.base_path) else {
1608 return Ok(());
1609 };
1610
1611 if let Some((arena, file)) = self.sync_data.get_file_mut(index) {
1612 file.update_frecency_scores(frecency_tracker, arena, &self.base_path, self.mode)?;
1613
1614 let score = file.access_frecency_score as i32;
1616 let dir_idx = file.parent_dir_index as usize;
1617 if let Some(dir) = self.sync_data.dirs.get(dir_idx) {
1618 dir.update_frecency_if_larger(score);
1619 }
1620 }
1621
1622 Ok(())
1623 }
1624
1625 pub fn get_file_by_path(&self, path: impl AsRef<Path>) -> Option<&FileItem> {
1626 self.sync_data
1627 .find_file_index(path.as_ref(), &self.base_path)
1628 .and_then(|index| self.sync_data.files().get(index))
1629 }
1630
1631 pub fn get_mut_file_by_path(
1632 &mut self,
1633 path: impl AsRef<Path>,
1634 ) -> Option<(ArenaPtr, &mut FileItem)> {
1635 let path = path.as_ref();
1636 let index = self.sync_data.find_file_index(path, &self.base_path);
1637 index.and_then(|i| self.sync_data.get_file_mut(i))
1638 }
1639
1640 #[tracing::instrument(skip(self),level = Level::DEBUG)]
1644 pub fn handle_create_or_modify(&mut self, path: impl AsRef<Path> + Debug) -> Option<&FileItem> {
1645 let path = path.as_ref();
1646
1647 if let Some(idx) = self.sync_data.find_file_index(path, &self.base_path) {
1648 let slot = if idx < self.sync_data.base_count {
1649 FileSlot::Base(idx)
1650 } else {
1651 FileSlot::Overflow(idx)
1652 };
1653
1654 return self.handle_file_modify(path, slot);
1655 }
1656
1657 self.add_new_file(path)
1658 }
1659
1660 #[tracing::instrument(skip_all, fields(path = ?path), level = Level::DEBUG)]
1661 fn handle_file_modify(&mut self, path: &Path, slot: FileSlot) -> Option<&FileItem> {
1662 let overlay = self.sync_data.bigram_overlay.as_ref().map(Arc::clone);
1663 let pos = slot.index();
1664
1665 let metadata = match std::fs::metadata(path) {
1668 Ok(m) => {
1669 self.untombstone_file(pos);
1670
1671 m
1672 }
1673 Err(_) => {
1674 self.tombstone_file(pos);
1675 return None;
1676 }
1677 };
1678
1679 let (_arena, file) = self.sync_data.get_file_mut(pos)?;
1680
1681 let size = metadata.len();
1682 let modified_time = metadata
1683 .modified()
1684 .ok()
1685 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1686 .map(|d| d.as_secs());
1687
1688 file.update_metadata(&self.cache_budget, modified_time, Some(size));
1689
1690 if !file.is_binary() {
1693 let mut chunk = [0u8; crate::types::BINARY_CLASSIFICATION_CHUNK_SIZE];
1694 file.detect_binary_per_byte(path, &mut chunk);
1695 }
1696
1697 if matches!(slot, FileSlot::Base(_))
1699 && let Some(ref overlay) = overlay
1700 {
1701 let in_indexable = {
1702 let guard = overlay.read();
1703 pos < guard.base_file_count()
1704 };
1705
1706 if in_indexable && let Ok(content) = std::fs::read(path) {
1707 overlay.write().modify_file(pos, &content);
1708 }
1709 }
1710
1711 self.sync_data.files().get(pos)
1712 }
1713
1714 #[tracing::instrument(skip(self))]
1717 pub fn add_new_file(&mut self, path: &Path) -> Option<&FileItem> {
1718 #[cfg(windows)]
1722 let canonical_buf: Option<PathBuf> = if path.starts_with(&self.base_path) {
1723 None
1724 } else if let Ok(c) = crate::path_utils::canonicalize(path) {
1725 Some(c)
1726 } else {
1727 tracing::error!(path = ?path.display(), "Failed to canonicalize file path to add");
1728 return None;
1729 };
1730
1731 #[cfg(windows)]
1732 let path_for_index: &Path = canonical_buf.as_deref().unwrap_or(path);
1733 #[cfg(not(windows))]
1734 let path_for_index: &Path = path;
1735
1736 let (mut file_item, rel_path) =
1737 FileItem::new(path_for_index.to_path_buf(), &self.base_path, None);
1738
1739 file_item.detect_binary_per_byte(
1742 path_for_index,
1743 &mut [0u8; crate::types::BINARY_CLASSIFICATION_CHUNK_SIZE],
1745 );
1746
1747 let builder = self.sync_data.overflow_builder.get_or_insert_with(|| {
1748 crate::simd_path::ChunkedPathStoreBuilder::new(MAX_OVERFLOW_FILES)
1750 });
1751
1752 file_item.set_path(builder.add_file_immediate(&rel_path, file_item.path.filename_offset));
1753 file_item.set_overflow(true);
1754
1755 let dir_rel = crate::path_utils::to_canonical_slashes(
1758 &rel_path[..file_item.path.filename_offset as usize],
1759 );
1760
1761 if let Some(dir_idx) = self.sync_data.find_or_add_dir(&dir_rel) {
1762 file_item.parent_dir_index = dir_idx;
1763 }
1764 let parent_dir = file_item.parent_dir_index;
1765
1766 if !self.sync_data.files.push(file_item) {
1767 return None;
1768 }
1769
1770 self.sync_data.live_count += 1;
1771 self.sync_data.revive_dir(parent_dir);
1774 self.sync_data.files.last()
1775 }
1776
1777 fn tombstone_file(&mut self, index: usize) {
1778 let file = &mut self.sync_data.files[index];
1779 if file.is_deleted() {
1780 return;
1781 }
1782
1783 file.set_deleted(true);
1784 file.invalidate_mmap(&self.cache_budget);
1785 file.git_status = None;
1786
1787 if index < self.sync_data.base_count
1789 && let Some(ref overlay) = self.sync_data.bigram_overlay
1790 {
1791 overlay.write().delete_file(index);
1792 }
1793
1794 self.sync_data.live_count -= 1;
1795 }
1796
1797 fn untombstone_file(&mut self, index: usize) {
1798 let file = &mut self.sync_data.files[index];
1799 if !file.is_deleted() {
1800 return;
1801 }
1802 file.set_deleted(false);
1803 let parent_dir = file.parent_dir_index;
1804
1805 self.sync_data.live_count += 1;
1806 self.sync_data.revive_dir(parent_dir);
1808 }
1809
1810 pub fn remove_file_by_path(&mut self, path: impl AsRef<Path>) -> bool {
1814 let path = path.as_ref();
1815 if let Some(index) = self.sync_data.find_file_index(path, &self.base_path) {
1816 self.tombstone_file(index);
1817 true
1818 } else {
1819 false
1820 }
1821 }
1822
1823 pub fn remove_all_files_in_dir(&mut self, dir: impl AsRef<Path>) -> usize {
1825 self.remove_all_files_in_dirs_inner(std::iter::once(dir.as_ref()), None)
1826 }
1827
1828 pub(crate) fn remove_all_files_in_dirs_with_callback<'a>(
1830 &mut self,
1831 dirs: impl IntoIterator<Item = &'a Path>,
1832 mut callback: impl FnMut(&Path),
1833 ) -> usize {
1834 self.remove_all_files_in_dirs_inner(dirs, Some(&mut callback))
1835 }
1836
1837 pub(crate) fn remove_all_files_in_dirs<'a>(
1838 &mut self,
1839 dirs: impl IntoIterator<Item = &'a Path>,
1840 ) -> usize {
1841 self.remove_all_files_in_dirs_inner(dirs, None)
1842 }
1843
1844 fn remove_all_files_in_dirs_inner<'a>(
1845 &mut self,
1846 dirs: impl IntoIterator<Item = &'a Path>,
1847 mut callback: Option<&mut dyn FnMut(&Path)>,
1848 ) -> usize {
1849 let mut dir_prefixes = Vec::new();
1850 for dir_path in dirs {
1851 let Some(relative_dir) = self
1852 .to_relative_path(dir_path)
1853 .map(|path| path.into_owned())
1854 else {
1855 continue;
1856 };
1857
1858 if relative_dir.is_empty() {
1859 dir_prefixes.push(String::new());
1860 } else {
1861 dir_prefixes.push(format!("{relative_dir}/"));
1863 }
1864 }
1865
1866 if dir_prefixes.is_empty() {
1867 return 0;
1868 }
1869
1870 let base_path = self.base_path.clone();
1871 let cache_budget = &self.cache_budget;
1872 let mut path_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
1873 let tombstoned = self.sync_data.tombstone_files_with_arena(
1874 |file, arena| {
1875 dir_prefixes
1876 .iter()
1877 .any(|prefix| file.relative_path_starts_with(arena, prefix))
1878 },
1879 |file, arena| {
1880 file.invalidate_mmap(cache_budget);
1881 if let Some(callback) = callback.as_mut() {
1882 callback(file.write_absolute_path(arena, &base_path, &mut path_buf));
1883 }
1884 },
1885 );
1886
1887 let mut dir_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
1890 self.sync_data.tombstone_dirs_with_arena(|dir, arena| {
1891 let rel = dir.read_relative_path(arena, &mut dir_buf);
1892 dir_prefixes.iter().any(|prefix| rel.starts_with(prefix))
1893 });
1894
1895 tombstoned
1896 }
1897
1898 pub fn cancel(&self) {
1900 self.signals.cancelled.store(true, Ordering::Release);
1901 }
1902
1903 pub fn stop_background_monitor(&mut self) {
1905 if let Some(mut watcher) = self.background_watcher.take() {
1906 watcher.stop();
1907 }
1908 self.signals.watcher_ready.store(false, Ordering::Release);
1909 }
1910
1911 pub fn is_scan_active(&self) -> bool {
1913 self.signals.scanning.load(Ordering::Relaxed)
1914 }
1915
1916 pub fn is_post_scan_active(&self) -> bool {
1917 self.signals
1918 .post_scan_indexing_active
1919 .load(Ordering::Acquire)
1920 }
1921
1922 pub fn watcher_signal(&self) -> Arc<AtomicBool> {
1925 Arc::clone(&self.signals.watcher_ready)
1926 }
1927
1928 fn to_relative_path<'a>(&self, path: &'a Path) -> Option<std::borrow::Cow<'a, str>> {
1936 if let Ok(stripped) = path.strip_prefix(&self.base_path)
1937 && let Some(s) = stripped.to_str()
1938 {
1939 return Some(crate::path_utils::to_canonical_slashes(s));
1941 }
1942
1943 #[cfg(windows)]
1944 {
1945 let rel = canonical_relative_path(path, &self.base_path)?;
1946 return Some(std::borrow::Cow::Owned(rel));
1947 }
1948
1949 #[cfg(not(windows))]
1950 None
1951 }
1952}
1953
1954#[cfg(windows)]
1958fn canonical_relative_path(path: &Path, base: &Path) -> Option<String> {
1959 if let Ok(canonical) = crate::path_utils::canonicalize(path)
1960 && let Ok(stripped) = canonical.strip_prefix(base)
1961 && let Some(s) = stripped.to_str()
1962 {
1963 return Some(crate::path_utils::to_canonical_slashes(s).into_owned());
1964 }
1965
1966 let parent = path.parent()?;
1969 let file_name = path.file_name()?;
1970 let canonical_parent = crate::path_utils::canonicalize(parent).ok()?;
1971 let stripped_parent = canonical_parent.strip_prefix(base).ok()?;
1972 let mut rel = stripped_parent.to_path_buf();
1973 rel.push(file_name);
1974 rel.to_str()
1975 .map(|s| crate::path_utils::to_canonical_slashes(s).into_owned())
1976}
1977
1978impl Drop for FilePicker {
1979 fn drop(&mut self) {
1980 self.signals.cancelled.store(true, Ordering::Release);
1983 self.git_status_worker.signal_shutdown();
1986 }
1987}
1988
1989#[derive(Debug, Clone, Copy)]
1990enum FileSlot {
1991 Base(usize),
1992 Overflow(usize),
1993}
1994
1995impl FileSlot {
1996 fn index(self) -> usize {
1997 match self {
1998 FileSlot::Base(i) | FileSlot::Overflow(i) => i,
1999 }
2000 }
2001}
2002
2003pub(crate) struct PostScanUnsafeSnapshot {
2015 pub files: StableVec<FileItem>,
2016 pub arena: Option<Arc<crate::simd_path::ChunkedPathStore>>,
2017 pub _budget: Arc<crate::types::ContentCacheBudget>,
2019 pub base_count: usize,
2020 pub indexable_count: usize,
2021 pub base_path: PathBuf,
2022 post_scan_flag: Arc<AtomicBool>,
2023}
2024
2025impl Drop for PostScanUnsafeSnapshot {
2026 fn drop(&mut self) {
2027 self.post_scan_flag.store(false, Ordering::Release);
2028 }
2029}
2030
2031unsafe impl Send for PostScanUnsafeSnapshot {}
2035unsafe impl Sync for PostScanUnsafeSnapshot {}
2036
2037#[derive(Debug, Clone)]
2042pub struct ScanProgress {
2043 pub scanned_files_count: usize,
2044 pub is_scanning: bool,
2045 pub is_watcher_ready: bool,
2046 pub is_warmup_complete: bool,
2047}
2048
2049impl FileSync {
2050 pub(crate) fn discover_git_workdir(base_path: &Path) -> Option<PathBuf> {
2051 let git_workdir = Repository::discover(base_path)
2052 .ok()
2053 .and_then(|repo| repo.workdir().map(Path::to_path_buf))
2054 .map(crate::path_utils::normalize);
2055
2056 match &git_workdir {
2057 Some(workdir) => debug!("Git repository found at: {}", workdir.display()),
2058 None => warn!("No git repository found for path: {}", base_path.display()),
2059 }
2060
2061 git_workdir
2062 }
2063
2064 pub(crate) fn spawn_git_status(git_workdir: PathBuf) -> JoinHandle<Option<GitStatusCache>> {
2065 std::thread::spawn(move || {
2066 GitStatusCache::read_git_status(
2067 Some(git_workdir.as_path()),
2068 &mut crate::git::initial_scan_status_options(),
2069 )
2070 })
2071 }
2072
2073 #[tracing::instrument(skip_all, name = "walk_filesystem", level = Level::INFO)]
2077 pub(crate) fn walk_filesystem(
2078 base_path: &Path,
2079 git_workdir: Option<PathBuf>,
2080 synced_files_count: &Arc<AtomicUsize>,
2081 shared_frecency: &SharedFrecency,
2082 mode: FFFMode,
2083 follow_symlinks: bool,
2084 ) -> Result<FileSync, Error> {
2085 let scan_start = std::time::Instant::now();
2086 info!("SCAN: Starting filesystem walk and git status (async)");
2087
2088 let is_git_repo = git_workdir.is_some();
2090 let bg_threads = BACKGROUND_THREAD_POOL.current_num_threads();
2091
2092 let WalkOutput {
2093 dirs: mut walked_dirs,
2094 mut pairs,
2095 ignore_rules,
2096 } = crate::walk::walk_collect_files(
2097 base_path,
2098 is_git_repo,
2099 follow_symlinks,
2100 bg_threads,
2101 synced_files_count,
2102 )?;
2103 let ignore_rules = ignore_rules.map(Arc::new);
2104
2105 BACKGROUND_THREAD_POOL.install(|| {
2107 rayon::join(
2108 || {
2109 pairs.par_sort_unstable_by(|(a, path_a), (b, path_b)| {
2110 let (a_dir, a_file) = path_a.split_at(a.path.filename_offset as usize);
2112 let (b_dir, b_file) = path_b.split_at(b.path.filename_offset as usize);
2113 a_dir.cmp(b_dir).then_with(|| a_file.cmp(b_file))
2114 });
2115 },
2116 || walked_dirs.par_sort_unstable(),
2117 );
2118 });
2119 walked_dirs.dedup();
2120
2121 let mut builder = crate::simd_path::ChunkedPathStoreBuilder::new(pairs.len());
2122 let dirs = populates_dirs_files_chunked_storage(&mut pairs, &walked_dirs, &mut builder);
2123 drop(walked_dirs);
2124
2125 let mut files: Vec<FileItem> = pairs.into_iter().map(|(file, _)| file).collect();
2126 let chunked_paths = builder.finish();
2127 let arena = chunked_paths.as_arena_ptr();
2128
2129 let frecency = shared_frecency
2132 .read()
2133 .map_err(|_| Error::AcquireFrecencyLock)?;
2134
2135 if let Some(frecency) = frecency.as_ref() {
2136 let dirs_ref = &dirs;
2137 BACKGROUND_THREAD_POOL.install(|| {
2138 files.par_iter_mut().for_each(|file| {
2139 let _ = file.update_frecency_scores(frecency, arena, base_path, mode);
2140 let score = file.access_frecency_score as i32;
2141 if score > 0 {
2142 let dir_idx = file.parent_dir_index as usize;
2143 if let Some(dir) = dirs_ref.get(dir_idx) {
2144 dir.update_frecency_if_larger(score);
2145 }
2146 }
2147 });
2148 });
2149 }
2150 drop(frecency);
2151
2152 let is_indexable = |f: &FileItem| {
2154 !f.is_binary()
2155 && f.size > 0
2156 && f.size <= crate::constants::MAX_INDEXABLE_FILE_SIZE as u64
2157 };
2158
2159 BACKGROUND_THREAD_POOL.install(|| {
2160 files.par_sort_unstable_by(|a, b| {
2161 (!is_indexable(a))
2162 .cmp(&!is_indexable(b))
2163 .then_with(|| a.parent_dir_index.cmp(&b.parent_dir_index))
2165 .then_with(|| a.file_name(arena).cmp(&b.file_name(arena)))
2166 });
2167 });
2168 let indexable_count = files.partition_point(is_indexable);
2169
2170 hint_allocator_collect();
2172
2173 let file_item_size = std::mem::size_of::<FileItem>();
2174 let files_vec_bytes = files.len() * file_item_size;
2175 let dir_table_bytes = dirs.len() * std::mem::size_of::<DirItem>()
2176 + dirs
2177 .iter()
2178 .map(|d| d.relative_path(arena).len())
2179 .sum::<usize>();
2180
2181 let total_time = scan_start.elapsed();
2182 info!(
2183 "SCAN: Walk completed in {:?} ({} files, {} dirs, \
2184 chunked_store={:.2}MB, files_vec={:.2}MB, dirs={:.2}MB, FileItem={}B)",
2185 total_time,
2186 files.len(),
2187 dirs.len(),
2188 chunked_paths.heap_bytes() as f64 / 1_048_576.0,
2189 files_vec_bytes as f64 / 1_048_576.0,
2190 dir_table_bytes as f64 / 1_048_576.0,
2191 file_item_size,
2192 );
2193
2194 let base_count = files.len();
2195 let base_dirs_count = dirs.len();
2196
2197 Ok(FileSync {
2198 files: StableVec::from_vec_with_reserve(files, MAX_OVERFLOW_FILES),
2199 indexable_count,
2200 base_count,
2201 live_count: base_count,
2202 dirs: StableVec::from_vec_with_reserve(dirs, MAX_OVERFLOW_FILES),
2203 base_dirs_count,
2204 live_dirs_count: base_dirs_count,
2205 overflow_builder: None,
2206 git_workdir,
2207 bigram_index: None,
2208 bigram_overlay: None,
2209 chunked_paths: Some(Arc::new(chunked_paths)),
2210 ignore_rules,
2211 })
2212 }
2213}
2214
2215#[allow(dead_code)]
2218#[tracing::instrument(skip(files), name = "warmup_mmaps", level = Level::DEBUG)]
2219pub(crate) fn warmup_mmaps(
2220 files: &[FileItem],
2221 budget: &ContentCacheBudget,
2222 base_path: &Path,
2223 arena: ArenaPtr,
2224) {
2225 for file in files.iter() {
2227 if file.is_likely_hot()
2228 || file.is_binary()
2229 || file.size == 0
2230 || file.size > budget.max_file_size
2231 {
2232 continue;
2233 }
2234
2235 let _ = file.get_cached_content(arena, base_path, budget);
2236
2237 if budget.is_exhausted() {
2238 break;
2239 }
2240 }
2241}
2242
2243fn populates_dirs_files_chunked_storage<'a>(
2249 pairs: &'a mut [(FileItem, String)],
2250 walked_dirs: &[String],
2251 chunk_storage: &mut crate::simd_path::ChunkedPathStoreBuilder,
2252) -> Vec<DirItem> {
2253 let mut dirs: Vec<DirItem> = Vec::with_capacity(walked_dirs.len() + 1);
2254 let mut dir_iter = walked_dirs.iter().peekable();
2255
2256 if pairs
2258 .first()
2259 .is_some_and(|(f, _)| f.path.filename_offset == 0)
2260 {
2261 push_dir_item(&mut dirs, chunk_storage, "");
2262 }
2263
2264 let mut prev_dir: &'a str = "";
2267 let mut current_dir_idx: u32 = 0;
2268
2269 for (file, rel) in pairs.iter_mut() {
2270 let rel: &'a str = rel;
2271 let dir_part: &'a str = &rel[..file.path.filename_offset as usize];
2272
2273 if prev_dir != dir_part {
2274 while let Some(dir) = dir_iter.peek()
2277 && dir.as_str() < dir_part
2278 {
2279 push_dir_item(&mut dirs, chunk_storage, dir);
2280 dir_iter.next();
2281 }
2282
2283 match dir_iter.peek() {
2284 Some(dir) if dir.as_str() == dir_part => {
2285 push_dir_item(&mut dirs, chunk_storage, dir);
2286 dir_iter.next();
2287 }
2288 _ => push_dir_item(&mut dirs, chunk_storage, dir_part),
2291 }
2292
2293 current_dir_idx = (dirs.len() - 1) as u32;
2294 prev_dir = dir_part;
2295 }
2296
2297 file.path = chunk_storage.add_file_immediate(rel, file.path.filename_offset);
2298 file.parent_dir_index = current_dir_idx;
2299 }
2300
2301 for dir in dir_iter {
2302 push_dir_item(&mut dirs, chunk_storage, dir);
2303 }
2304
2305 dirs
2306}
2307
2308fn push_dir_item(
2309 dirs: &mut Vec<DirItem>,
2310 chunk_storage: &mut crate::simd_path::ChunkedPathStoreBuilder,
2311 dir_part: &str,
2312) {
2313 let dir_string = chunk_storage.add_dir_immediate(dir_part);
2314
2315 let last_seg = if dir_part.is_empty() {
2317 0
2318 } else {
2319 let trimmed = dir_part.trim_end_matches(std::path::is_separator);
2320 trimmed
2321 .rfind(std::path::is_separator)
2322 .map(|i| i + 1)
2323 .unwrap_or(0) as u16
2324 };
2325
2326 dirs.push(DirItem::new(dir_string, last_seg));
2327}
2328
2329#[inline]
2332#[doc(hidden)]
2333pub fn is_known_binary_extension(path: &Path) -> bool {
2334 let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
2335 return false;
2336 };
2337 is_binary_extension_str(ext)
2338}
2339
2340#[cfg(feature = "zlob")]
2345#[inline]
2346pub(crate) fn is_known_binary_extension_basename(name: &str) -> bool {
2347 match name.rfind('.') {
2348 Some(pos) if pos > 0 && pos < name.len() - 1 => is_binary_extension_str(&name[pos + 1..]),
2349 _ => false,
2350 }
2351}
2352
2353#[inline]
2354fn is_binary_extension_str(ext: &str) -> bool {
2355 matches!(
2356 ext,
2357 "png" | "jpg" | "jpeg" | "gif" | "bmp" | "ico" | "webp" | "tiff" | "tif" | "avif" |
2359 "heic" | "heif" | "jxl" | "jp2" | "j2k" | "psd" | "icns" | "cur" | "cr2" |
2360 "nef" | "dng" | "tga" |
2361 "rgbe" | "hdr" | "exr" | "dds" | "ktx" | "ktx2" | "pvr" | "astc" |
2363 "ai" | "webarchive" | "mhtml" |
2365 "mp4" | "avi" | "mov" | "wmv" | "mkv" | "mp3" | "wav" | "flac" | "ogg" | "m4a" |
2367 "aac" | "webm" | "flv" | "mpg" | "mpeg" | "wma" | "opus" | "pcm" | "reapeaks" |
2368 "zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" | "zst" | "lz4" | "lzma" |
2370 "cab" | "cpio" | "jsonlz4" |
2371 "deb" | "rpm" | "apk" | "dmg" | "msi" | "iso" | "nupkg" | "whl" | "egg" |
2373 "appimage" | "flatpak" | "crx" | "pak" |
2374 "exe" | "dll" | "so" | "dylib" | "o" | "a" | "lib" | "bin" | "elf" |
2376 "pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" |
2378 "db" | "sqlite" | "sqlite3" | "mdb" |
2380 "sqlite-wal" | "sqlite-shm" | "sqlite3-wal" | "sqlite3-shm" |
2382 "db-wal" | "db-shm" | "ldb" |
2383 "ttf" | "otf" | "woff" | "woff2" | "eot" |
2385 "class" | "pyc" | "pyo" | "wasm" | "dex" | "jar" | "war" |
2387 "cmi" | "cmt" | "cmti" | "cmx" | "nib" |
2389 "swiftdeps" | "swiftdeps~" | "swiftdoc" | "swiftmodule" | "swiftsourceinfo" |
2390 "npy" | "npz" | "h5" | "hdf5" | "pt" | "onnx" |
2392 "safetensors" | "tfrecord" | "tflite" | "gguf" | "ggml" | "joblib" |
2393 "glb" | "blend" | "blp" |
2395 "dia" | "bcmap" |
2397 "pb" |
2399 "parquet" | "arrow" |
2401 "suo"
2403 )
2404}
2405
2406fn common_dir_prefix_len(a: &str, b: &str) -> usize {
2420 let max = a.len().min(b.len());
2421 let a_bytes = a.as_bytes();
2422 let b_bytes = b.as_bytes();
2423 let mut last_sep = 0;
2424 let mut i = 0;
2425 while i < max && a_bytes[i] == b_bytes[i] {
2426 if std::path::is_separator(a_bytes[i] as char) {
2427 last_sep = i + 1;
2428 }
2429 i += 1;
2430 }
2431 if i == max && i > 0 {
2434 let longer = if a.len() > b.len() { a_bytes } else { b_bytes };
2435 if i < longer.len() && std::path::is_separator(longer[i] as char) {
2436 return i;
2437 }
2438 }
2439 last_sep
2440}
2441
2442#[cfg(feature = "mimalloc-collect")]
2446pub extern "C" fn tune_mimalloc() {
2447 unsafe {
2449 let user_set = !libc::getenv(c"MIMALLOC_ALLOW_LARGE_OS_PAGES".as_ptr()).is_null()
2450 || !libc::getenv(c"MIMALLOC_LARGE_OS_PAGES".as_ptr()).is_null();
2451 if !user_set {
2452 libmimalloc_sys::mi_option_set(libmimalloc_sys::mi_option_large_os_pages, 0);
2453 }
2454 }
2455}
2456
2457pub(crate) fn hint_allocator_collect() {
2461 #[cfg(feature = "mimalloc-collect")]
2462 {
2463 BACKGROUND_THREAD_POOL.broadcast(|_| unsafe { libmimalloc_sys::mi_collect(true) });
2467
2468 unsafe { libmimalloc_sys::mi_collect(true) };
2470 }
2471}
2472
2473#[cfg(test)]
2474mod tests {
2475 use super::*;
2476
2477 #[test]
2482 fn extract_watch_dirs_includes_pure_ancestor_dirs() {
2483 let dir = tempfile::tempdir().unwrap();
2484 let base_buf = crate::path_utils::canonicalize(dir.path()).unwrap();
2488 let base = base_buf.as_path();
2489
2490 for rel in [
2495 "src/components/button.txt",
2496 "src/routes/home.txt",
2497 "lib/deep/nested/util.txt",
2498 ] {
2499 let path = base.join(rel);
2500 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2501 std::fs::write(&path, b"x").unwrap();
2502 }
2503
2504 let mut picker = FilePicker::new(FilePickerOptions {
2505 base_path: base.to_str().unwrap().into(),
2506 watch: false,
2507 ..Default::default()
2508 })
2509 .unwrap();
2510 picker.collect_files().unwrap();
2511
2512 let mut watch_dirs: Vec<PathBuf> = Vec::new();
2513 picker.for_each_dir(|p| {
2514 watch_dirs.push(p.to_path_buf());
2515 std::ops::ControlFlow::Continue(())
2516 });
2517 let watch_set: std::collections::HashSet<PathBuf> = watch_dirs.iter().cloned().collect();
2518
2519 for rel in ["src/components", "src/routes", "lib/deep/nested"] {
2521 assert!(
2522 watch_set.contains(&base.join(rel)),
2523 "expected immediate parent {rel} in watch dirs, got {watch_set:?}",
2524 );
2525 }
2526
2527 for rel in ["src", "lib", "lib/deep"] {
2529 assert!(
2530 watch_set.contains(&base.join(rel)),
2531 "expected pure-ancestor {rel} in watch dirs, got {watch_set:?}",
2532 );
2533 }
2534
2535 assert_eq!(
2537 watch_dirs.len(),
2538 watch_set.len(),
2539 "duplicate watch dir emitted: {watch_dirs:?}",
2540 );
2541
2542 assert!(
2546 !watch_set.contains(base),
2547 "base path must not be in watch dirs (covered by the top-level watch call)",
2548 );
2549 }
2550
2551 #[test]
2555 fn for_each_dir_includes_empty_directories() {
2556 let dir = tempfile::tempdir().unwrap();
2557 let base_buf = crate::path_utils::canonicalize(dir.path()).unwrap();
2558 let base = base_buf.as_path();
2559
2560 std::fs::create_dir_all(base.join("commands")).unwrap();
2566 std::fs::create_dir_all(base.join("src/plugins/extra")).unwrap();
2567 std::fs::write(base.join("init.lua"), b"x").unwrap();
2568 std::fs::write(base.join("src/main.rs"), b"x").unwrap();
2569
2570 let mut picker = FilePicker::new(FilePickerOptions {
2571 base_path: base.to_str().unwrap().into(),
2572 watch: false,
2573 ..Default::default()
2574 })
2575 .unwrap();
2576 picker.collect_files().unwrap();
2577
2578 let mut watch_dirs: Vec<PathBuf> = Vec::new();
2579 picker.for_each_dir(|p| {
2580 watch_dirs.push(p.to_path_buf());
2581 std::ops::ControlFlow::Continue(())
2582 });
2583 let watch_set: std::collections::HashSet<PathBuf> = watch_dirs.iter().cloned().collect();
2584
2585 for rel in ["commands", "src/plugins", "src/plugins/extra", "src"] {
2586 assert!(
2587 watch_set.contains(&base.join(rel)),
2588 "expected {rel} in watch dirs, got {watch_set:?}",
2589 );
2590 }
2591
2592 assert_eq!(
2594 watch_dirs.len(),
2595 watch_set.len(),
2596 "duplicate watch dir emitted: {watch_dirs:?}",
2597 );
2598 }
2599
2600 #[test]
2601 fn dir_table_merges_walked_dirs_with_file_parents() {
2602 let mut pairs: Vec<(FileItem, String)> = ["src/main.rs", "src/deep/lib.rs", "root.txt"]
2603 .iter()
2604 .map(|p| {
2605 let (item, rel) = FileItem::new(PathBuf::from(p), Path::new(""), None);
2606 (item, rel)
2607 })
2608 .collect();
2609 pairs.sort_by(|(a, pa), (b, pb)| {
2610 pa[..a.path.filename_offset as usize]
2611 .cmp(&pb[..b.path.filename_offset as usize])
2612 .then_with(|| pa.cmp(pb))
2613 });
2614
2615 let walked: Vec<String> = ["empty/", "src/", "src/deep/", "src/deeper/"]
2618 .iter()
2619 .map(|s| s.to_string())
2620 .collect();
2621
2622 let mut builder = crate::simd_path::ChunkedPathStoreBuilder::new(pairs.len());
2623 let dirs = populates_dirs_files_chunked_storage(&mut pairs, &walked, &mut builder);
2624 let store = builder.finish();
2625 let arena = store.as_arena_ptr();
2626
2627 let table: Vec<String> = dirs.iter().map(|d| d.relative_path(arena)).collect();
2628 assert_eq!(table, ["", "empty/", "src/", "src/deep/", "src/deeper/"]);
2630
2631 for (file, _) in &pairs {
2633 let dir = &dirs[file.parent_dir_index as usize];
2634 let rel = file.relative_path(arena);
2635 assert!(
2636 rel.starts_with(&dir.relative_path(arena)),
2637 "file {rel} must live under its parent dir",
2638 );
2639 }
2640 }
2641
2642 #[test]
2643 fn common_dir_prefix_len_cases() {
2644 assert_eq!(common_dir_prefix_len("", ""), 0);
2645 assert_eq!(common_dir_prefix_len("", "src"), 0);
2646 assert_eq!(common_dir_prefix_len("lib", "src"), 0);
2647 assert_eq!(common_dir_prefix_len("src/components", "src/routes"), 4);
2648 assert_eq!(common_dir_prefix_len("lib/deep/nested", "lib/deep"), 8);
2649 assert_eq!(common_dir_prefix_len("lib/deep", "lib/deep/nested"), 8);
2650 assert_eq!(common_dir_prefix_len("lib/deep", "lib/deeper"), 4);
2651 assert_eq!(common_dir_prefix_len("src", "src"), 0);
2652 assert_eq!(common_dir_prefix_len("src", "src/x"), 3);
2654 }
2655
2656 #[test]
2657 fn directory_removal_collects_each_tombstoned_path() {
2658 let dir = tempfile::tempdir().unwrap();
2659 let base = crate::path_utils::canonicalize(dir.path()).unwrap();
2660 let removed_dir = base.join("removed");
2661 let kept = base.join("kept.txt");
2662 let first = removed_dir.join("a.txt");
2663 let second = removed_dir.join("nested/b.txt");
2664 std::fs::create_dir_all(second.parent().unwrap()).unwrap();
2665 std::fs::write(&first, b"a").unwrap();
2666 std::fs::write(&second, b"b").unwrap();
2667 std::fs::write(&kept, b"kept").unwrap();
2668
2669 let mut picker = FilePicker::new(FilePickerOptions {
2670 base_path: base.to_string_lossy().into_owned(),
2671 watch: false,
2672 ..Default::default()
2673 })
2674 .unwrap();
2675 picker.collect_files().unwrap();
2676
2677 let mut removed = Vec::new();
2678 assert_eq!(
2679 picker.remove_all_files_in_dirs_with_callback(
2680 std::iter::once(removed_dir.as_path()),
2681 |path| {
2682 removed.push(path.to_path_buf());
2683 }
2684 ),
2685 2
2686 );
2687 removed.sort_unstable();
2688 assert_eq!(removed, vec![first, second]);
2689 assert!(picker.get_file_by_path(&kept).is_some());
2690
2691 let outside = base.parent().unwrap().join("outside");
2692 assert_eq!(picker.remove_all_files_in_dir(&outside), 0);
2693 assert!(picker.get_file_by_path(&kept).is_some());
2694 }
2695}