Skip to main content

codex_file_search/
lib.rs

1use crossbeam_channel::Receiver;
2use crossbeam_channel::Sender;
3use crossbeam_channel::after;
4use crossbeam_channel::never;
5use crossbeam_channel::select;
6use crossbeam_channel::unbounded;
7use ignore::WalkBuilder;
8use ignore::overrides::OverrideBuilder;
9use nucleo::Config;
10use nucleo::Injector;
11use nucleo::Matcher;
12use nucleo::Nucleo;
13use nucleo::Utf32String;
14use nucleo::pattern::CaseMatching;
15use nucleo::pattern::Normalization;
16use serde::Serialize;
17use std::num::NonZero;
18use std::path::Path;
19use std::path::PathBuf;
20use std::sync::Arc;
21use std::sync::Condvar;
22use std::sync::Mutex;
23use std::sync::RwLock;
24use std::sync::atomic::AtomicBool;
25use std::sync::atomic::Ordering;
26use std::thread;
27use std::time::Duration;
28use tokio::process::Command;
29
30#[cfg(test)]
31use nucleo::Utf32Str;
32#[cfg(test)]
33use nucleo::pattern::AtomKind;
34#[cfg(test)]
35use nucleo::pattern::Pattern;
36
37mod cli;
38
39pub use cli::Cli;
40
41/// A single match result returned from the search.
42///
43/// * `score` – Relevance score returned by `nucleo`.
44/// * `path`  – Path to the matched entry (file or directory), relative to the
45///   search directory.
46/// * `match_type` – Whether this match is a file or directory.
47/// * `indices` – Optional list of character indices that matched the query.
48///   These are only filled when the caller of [`run`] sets
49///   `options.compute_indices` to `true`. The indices vector follows the
50///   guidance from `nucleo::pattern::Pattern::indices`: they are
51///   unique and sorted in ascending order so that callers can use
52///   them directly for highlighting.
53#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
54pub struct FileMatch {
55    pub score: u32,
56    pub path: PathBuf,
57    pub match_type: MatchType,
58    pub root: PathBuf,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub indices: Option<Vec<u32>>, // Sorted & deduplicated when present
61}
62
63#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
64#[serde(rename_all = "lowercase")]
65pub enum MatchType {
66    File,
67    Directory,
68}
69
70impl FileMatch {
71    pub fn full_path(&self) -> PathBuf {
72        self.root.join(&self.path)
73    }
74}
75
76/// Returns the final path component for a matched path, falling back to the full path.
77pub fn file_name_from_path(path: &str) -> String {
78    Path::new(path)
79        .file_name()
80        .map(|name| name.to_string_lossy().into_owned())
81        .unwrap_or_else(|| path.to_string())
82}
83
84#[derive(Debug)]
85pub struct FileSearchResults {
86    pub matches: Vec<FileMatch>,
87    pub total_match_count: usize,
88}
89
90#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
91pub struct FileSearchSnapshot {
92    pub query: String,
93    pub matches: Vec<FileMatch>,
94    pub total_match_count: usize,
95    pub scanned_file_count: usize,
96    pub walk_complete: bool,
97}
98
99#[derive(Debug, Clone)]
100pub struct FileSearchOptions {
101    pub limit: NonZero<usize>,
102    pub exclude: Vec<String>,
103    pub threads: NonZero<usize>,
104    pub compute_indices: bool,
105    /// Toggle ignore-file processing in the walker.
106    ///
107    /// When enabled, `.gitignore` files are scoped by
108    /// `WalkBuilder::require_git(true)`, so they are honored only when the
109    /// traversed path is inside a git repository. When disabled, the walker
110    /// turns off `.gitignore`, git-global/exclude rules, `.ignore`, and
111    /// parent-directory ignore scanning.
112    pub respect_gitignore: bool,
113}
114
115impl Default for FileSearchOptions {
116    fn default() -> Self {
117        Self {
118            #[expect(clippy::unwrap_used)]
119            limit: NonZero::new(20).unwrap(),
120            exclude: Vec::new(),
121            #[expect(clippy::unwrap_used)]
122            threads: NonZero::new(2).unwrap(),
123            compute_indices: false,
124            respect_gitignore: true,
125        }
126    }
127}
128
129pub trait SessionReporter: Send + Sync + 'static {
130    /// Called when the debounced top-N changes.
131    fn on_update(&self, snapshot: &FileSearchSnapshot);
132
133    /// Called when the session becomes idle or is cancelled. Guaranteed to be called at least once per update_query.
134    fn on_complete(&self);
135}
136
137pub struct FileSearchSession {
138    inner: Arc<SessionInner>,
139}
140
141impl FileSearchSession {
142    /// Update the query. This should be cheap relative to re-walking.
143    pub fn update_query(&self, pattern_text: &str) {
144        let _ = self
145            .inner
146            .work_tx
147            .send(WorkSignal::QueryUpdated(pattern_text.to_string()));
148    }
149}
150
151impl Drop for FileSearchSession {
152    fn drop(&mut self) {
153        self.inner.shutdown.store(true, Ordering::Relaxed);
154        let _ = self.inner.work_tx.send(WorkSignal::Shutdown);
155    }
156}
157
158pub fn create_session(
159    search_directories: Vec<PathBuf>,
160    options: FileSearchOptions,
161    reporter: Arc<dyn SessionReporter>,
162    cancel_flag: Option<Arc<AtomicBool>>,
163) -> anyhow::Result<FileSearchSession> {
164    let FileSearchOptions {
165        limit,
166        exclude,
167        threads,
168        compute_indices,
169        respect_gitignore,
170    } = options;
171
172    let Some(primary_search_directory) = search_directories.first() else {
173        anyhow::bail!("at least one search directory is required");
174    };
175    let override_matcher = build_override_matcher(primary_search_directory, &exclude)?;
176    let (work_tx, work_rx) = unbounded();
177
178    let notify_tx = work_tx.clone();
179    let notify = Arc::new(move || {
180        let _ = notify_tx.send(WorkSignal::NucleoNotify);
181    });
182    let nucleo = Nucleo::new(
183        Config::DEFAULT.match_paths(),
184        notify,
185        Some(threads.get()),
186        1,
187    );
188    let injector = nucleo.injector();
189
190    let cancelled = cancel_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
191
192    let inner = Arc::new(SessionInner {
193        search_directories,
194        limit: limit.get(),
195        threads: threads.get(),
196        compute_indices,
197        respect_gitignore,
198        cancelled,
199        shutdown: Arc::new(AtomicBool::new(false)),
200        reporter,
201        work_tx,
202    });
203
204    let matcher_inner = inner.clone();
205    thread::spawn(move || matcher_worker(matcher_inner, work_rx, nucleo));
206
207    let walker_inner = inner.clone();
208    thread::spawn(move || walker_worker(walker_inner, override_matcher, injector));
209
210    Ok(FileSearchSession { inner })
211}
212
213pub trait Reporter {
214    fn report_match(&self, file_match: &FileMatch);
215    fn warn_matches_truncated(&self, total_match_count: usize, shown_match_count: usize);
216    fn warn_no_search_pattern(&self, search_directory: &Path);
217}
218
219pub async fn run_main<T: Reporter>(
220    Cli {
221        pattern,
222        limit,
223        cwd,
224        compute_indices,
225        json: _,
226        exclude,
227        threads,
228    }: Cli,
229    reporter: T,
230) -> anyhow::Result<()> {
231    let search_directory = match cwd {
232        Some(dir) => dir,
233        None => std::env::current_dir()?,
234    };
235    let pattern_text = match pattern {
236        Some(pattern) => pattern,
237        None => {
238            reporter.warn_no_search_pattern(&search_directory);
239            #[cfg(unix)]
240            Command::new("ls")
241                .arg("-al")
242                .current_dir(search_directory)
243                .stdout(std::process::Stdio::inherit())
244                .stderr(std::process::Stdio::inherit())
245                .status()
246                .await?;
247            #[cfg(windows)]
248            {
249                Command::new("cmd")
250                    .arg("/c")
251                    .arg(search_directory)
252                    .stdout(std::process::Stdio::inherit())
253                    .stderr(std::process::Stdio::inherit())
254                    .status()
255                    .await?;
256            }
257            return Ok(());
258        }
259    };
260
261    let FileSearchResults {
262        total_match_count,
263        matches,
264    } = run(
265        &pattern_text,
266        vec![search_directory.to_path_buf()],
267        FileSearchOptions {
268            limit,
269            exclude,
270            threads,
271            compute_indices,
272            respect_gitignore: true,
273        },
274        /*cancel_flag*/ None,
275    )?;
276    let match_count = matches.len();
277    let matches_truncated = total_match_count > match_count;
278
279    for file_match in matches {
280        reporter.report_match(&file_match);
281    }
282    if matches_truncated {
283        reporter.warn_matches_truncated(total_match_count, match_count);
284    }
285
286    Ok(())
287}
288
289/// The worker threads will periodically check `cancel_flag` to see if they
290/// should stop processing files.
291pub fn run(
292    pattern_text: &str,
293    roots: Vec<PathBuf>,
294    options: FileSearchOptions,
295    cancel_flag: Option<Arc<AtomicBool>>,
296) -> anyhow::Result<FileSearchResults> {
297    let reporter = Arc::new(RunReporter::default());
298    let session = create_session(roots, options, reporter.clone(), cancel_flag)?;
299
300    session.update_query(pattern_text);
301
302    let snapshot = reporter.wait_for_complete();
303    Ok(FileSearchResults {
304        matches: snapshot.matches,
305        total_match_count: snapshot.total_match_count,
306    })
307}
308
309/// Sort matches in-place by descending score, then ascending path.
310#[cfg(test)]
311fn sort_matches(matches: &mut [(u32, String)]) {
312    matches.sort_by(cmp_by_score_desc_then_path_asc::<(u32, String), _, _>(
313        |t| t.0,
314        |t| t.1.as_str(),
315    ));
316}
317
318/// Returns a comparator closure suitable for `slice.sort_by(...)` that orders
319/// items by descending score and then ascending path using the provided accessors.
320pub fn cmp_by_score_desc_then_path_asc<T, FScore, FPath>(
321    score_of: FScore,
322    path_of: FPath,
323) -> impl FnMut(&T, &T) -> std::cmp::Ordering
324where
325    FScore: Fn(&T) -> u32,
326    FPath: Fn(&T) -> &str,
327{
328    use std::cmp::Ordering;
329    move |a, b| match score_of(b).cmp(&score_of(a)) {
330        Ordering::Equal => path_of(a).cmp(path_of(b)),
331        other => other,
332    }
333}
334
335#[cfg(test)]
336fn create_pattern(pattern: &str) -> Pattern {
337    Pattern::new(
338        pattern,
339        CaseMatching::Ignore,
340        Normalization::Smart,
341        AtomKind::Fuzzy,
342    )
343}
344
345struct SessionInner {
346    search_directories: Vec<PathBuf>,
347    limit: usize,
348    threads: usize,
349    compute_indices: bool,
350    respect_gitignore: bool,
351    cancelled: Arc<AtomicBool>,
352    shutdown: Arc<AtomicBool>,
353    reporter: Arc<dyn SessionReporter>,
354    work_tx: Sender<WorkSignal>,
355}
356
357enum WorkSignal {
358    QueryUpdated(String),
359    NucleoNotify,
360    WalkComplete,
361    Shutdown,
362}
363
364fn build_override_matcher(
365    search_directory: &Path,
366    exclude: &[String],
367) -> anyhow::Result<Option<ignore::overrides::Override>> {
368    if exclude.is_empty() {
369        return Ok(None);
370    }
371    let mut override_builder = OverrideBuilder::new(search_directory);
372    for exclude in exclude {
373        let exclude_pattern = format!("!{exclude}");
374        override_builder.add(&exclude_pattern)?;
375    }
376    let matcher = override_builder.build()?;
377    Ok(Some(matcher))
378}
379
380fn get_file_path<'a>(path: &'a Path, search_directories: &[PathBuf]) -> Option<(usize, &'a str)> {
381    let mut best_match: Option<(usize, &Path)> = None;
382    for (idx, root) in search_directories.iter().enumerate() {
383        if let Ok(rel_path) = path.strip_prefix(root) {
384            let root_depth = root.components().count();
385            match best_match {
386                Some((best_idx, _))
387                    if search_directories[best_idx].components().count() >= root_depth => {}
388                _ => {
389                    best_match = Some((idx, rel_path));
390                }
391            }
392        }
393    }
394
395    let (root_idx, rel_path) = best_match?;
396    rel_path.to_str().map(|p| (root_idx, p))
397}
398
399/// Walks the search directories and feeds discovered paths into `nucleo`
400/// via the injector.
401///
402/// The walker uses `require_git(true)` to match git's own ignore semantics:
403/// git never reads `.gitignore` files from directories above the repository
404/// root. Without this flag, the `ignore` crate reads `.gitignore` files from
405/// *all* ancestor directories—a deliberate divergence from git intended for
406/// non-git use cases—allowing a broad parent ignore (e.g. `~/.gitignore`
407/// containing `*`) to silently suppress every file in the walk.
408///
409/// When `respect_gitignore` is `false`, all git-related ignore processing is
410/// disabled regardless of this flag.
411fn walker_worker(
412    inner: Arc<SessionInner>,
413    override_matcher: Option<ignore::overrides::Override>,
414    injector: Injector<Arc<str>>,
415) {
416    let Some(first_root) = inner.search_directories.first() else {
417        let _ = inner.work_tx.send(WorkSignal::WalkComplete);
418        return;
419    };
420
421    let mut walk_builder = WalkBuilder::new(first_root);
422    for root in inner.search_directories.iter().skip(1) {
423        walk_builder.add(root);
424    }
425    walk_builder
426        .threads(inner.threads)
427        // Allow hidden entries.
428        .hidden(false)
429        // Follow symlinks to search their contents.
430        .follow_links(true)
431        // Keep ignore behavior aligned with git repositories: only apply
432        // gitignore rules when a git context exists.
433        .require_git(true);
434    if !inner.respect_gitignore {
435        walk_builder
436            .git_ignore(false)
437            .git_global(false)
438            .git_exclude(false)
439            .ignore(false)
440            .parents(false);
441    }
442    if let Some(override_matcher) = override_matcher {
443        walk_builder.overrides(override_matcher);
444    }
445
446    let walker = walk_builder.build_parallel();
447
448    walker.run(|| {
449        const CHECK_INTERVAL: usize = 1024;
450        let mut n = 0;
451        let search_directories = inner.search_directories.clone();
452        let injector = injector.clone();
453        let cancelled = inner.cancelled.clone();
454        let shutdown = inner.shutdown.clone();
455
456        Box::new(move |entry| {
457            let entry = match entry {
458                Ok(entry) => entry,
459                Err(_) => return ignore::WalkState::Continue,
460            };
461            let path = entry.path();
462            let Some(full_path) = path.to_str() else {
463                return ignore::WalkState::Continue;
464            };
465            if let Some((_, relative_path)) = get_file_path(path, &search_directories) {
466                injector.push(Arc::from(full_path), |_, cols| {
467                    cols[0] = Utf32String::from(relative_path);
468                });
469            }
470            n += 1;
471            if n >= CHECK_INTERVAL {
472                if cancelled.load(Ordering::Relaxed) || shutdown.load(Ordering::Relaxed) {
473                    return ignore::WalkState::Quit;
474                }
475                n = 0;
476            }
477            ignore::WalkState::Continue
478        })
479    });
480    let _ = inner.work_tx.send(WorkSignal::WalkComplete);
481}
482
483fn matcher_worker(
484    inner: Arc<SessionInner>,
485    work_rx: Receiver<WorkSignal>,
486    mut nucleo: Nucleo<Arc<str>>,
487) -> anyhow::Result<()> {
488    const TICK_TIMEOUT_MS: u64 = 10;
489    let config = Config::DEFAULT.match_paths();
490    let mut indices_matcher = inner.compute_indices.then(|| Matcher::new(config.clone()));
491    let cancel_requested = || inner.cancelled.load(Ordering::Relaxed);
492    let shutdown_requested = || inner.shutdown.load(Ordering::Relaxed);
493
494    let mut last_query = String::new();
495    let mut next_notify = never();
496    let mut will_notify = false;
497    let mut walk_complete = false;
498
499    loop {
500        select! {
501            recv(work_rx) -> signal => {
502                let Ok(signal) = signal else {
503                    break;
504                };
505                match signal {
506                    WorkSignal::QueryUpdated(query) => {
507                        let append = query.starts_with(&last_query);
508                        nucleo.pattern.reparse(
509                            0,
510                            &query,
511                            CaseMatching::Ignore,
512                            Normalization::Smart,
513                            append,
514                        );
515                        last_query = query;
516                        will_notify = true;
517                        next_notify = after(Duration::from_millis(0));
518                    }
519                    WorkSignal::NucleoNotify => {
520                        if !will_notify {
521                            will_notify = true;
522                            next_notify = after(Duration::from_millis(TICK_TIMEOUT_MS));
523                        }
524                    }
525                    WorkSignal::WalkComplete => {
526                        walk_complete = true;
527                        if !will_notify {
528                            will_notify = true;
529                            next_notify = after(Duration::from_millis(0));
530                        }
531                    }
532                    WorkSignal::Shutdown => {
533                        break;
534                    }
535                }
536            }
537            recv(next_notify) -> _ => {
538                will_notify = false;
539                let status = nucleo.tick(TICK_TIMEOUT_MS);
540                if status.changed {
541                    let snapshot = nucleo.snapshot();
542                    let limit = inner.limit.min(snapshot.matched_item_count() as usize);
543                    let pattern = snapshot.pattern().column_pattern(0);
544                    let matches: Vec<_> = snapshot
545                        .matches()
546                        .iter()
547                        .take(limit)
548                        .filter_map(|match_| {
549                            let item = snapshot.get_item(match_.idx)?;
550                            let full_path = item.data.as_ref();
551                            let (root_idx, relative_path) = get_file_path(Path::new(full_path), &inner.search_directories)?;
552                            let indices = if let Some(indices_matcher) = indices_matcher.as_mut() {
553                                let mut idx_vec = Vec::<u32>::new();
554                                let haystack = item.matcher_columns[0].slice(..);
555                                let _ = pattern.indices(haystack, indices_matcher, &mut idx_vec);
556                                idx_vec.sort_unstable();
557                                idx_vec.dedup();
558                                Some(idx_vec)
559                            } else {
560                                None
561                            };
562                            let match_type = if Path::new(full_path).is_dir() {
563                                MatchType::Directory
564                            } else {
565                                MatchType::File
566                            };
567                            Some(FileMatch {
568                                score: match_.score,
569                                path: PathBuf::from(relative_path),
570                                match_type,
571                                root: inner.search_directories[root_idx].clone(),
572                                indices,
573                            })
574                        })
575                        .collect();
576
577                    let snapshot = FileSearchSnapshot {
578                        query: last_query.clone(),
579                        matches,
580                        total_match_count: snapshot.matched_item_count() as usize,
581                        scanned_file_count: snapshot.item_count() as usize,
582                        walk_complete,
583                    };
584                    inner.reporter.on_update(&snapshot);
585                }
586                if !status.running && walk_complete {
587                    inner.reporter.on_complete();
588                }
589            }
590            default(Duration::from_millis(100)) => {
591                // Occasionally check the cancel flag.
592            }
593        }
594
595        if cancel_requested() || shutdown_requested() {
596            break;
597        }
598    }
599
600    // If we cancelled or otherwise exited the loop, make sure the reporter is notified.
601    inner.reporter.on_complete();
602
603    Ok(())
604}
605
606#[derive(Default)]
607struct RunReporter {
608    snapshot: RwLock<FileSearchSnapshot>,
609    completed: (Condvar, Mutex<bool>),
610}
611
612impl SessionReporter for RunReporter {
613    fn on_update(&self, snapshot: &FileSearchSnapshot) {
614        #[allow(clippy::unwrap_used)]
615        let mut guard = self.snapshot.write().unwrap();
616        *guard = snapshot.clone();
617    }
618
619    fn on_complete(&self) {
620        let (cv, mutex) = &self.completed;
621        #[allow(clippy::unwrap_used)]
622        let mut completed = mutex.lock().unwrap();
623        *completed = true;
624        cv.notify_all();
625    }
626}
627
628impl RunReporter {
629    fn wait_for_complete(&self) -> FileSearchSnapshot {
630        let (cv, mutex) = &self.completed;
631        #[allow(clippy::unwrap_used)]
632        let mut completed = mutex.lock().unwrap();
633        while !*completed {
634            #[allow(clippy::unwrap_used)]
635            {
636                completed = cv.wait(completed).unwrap();
637            }
638        }
639        #[allow(clippy::unwrap_used)]
640        self.snapshot.read().unwrap().clone()
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    #![allow(clippy::unwrap_used)]
647
648    use super::*;
649    use pretty_assertions::assert_eq;
650    use std::fs;
651    use std::sync::Arc;
652    use std::sync::Condvar;
653    use std::sync::Mutex;
654    use std::sync::atomic::AtomicBool;
655    use std::thread;
656    use std::time::Duration;
657    use std::time::Instant;
658    use tempfile::TempDir;
659
660    #[test]
661    fn verify_score_is_none_for_non_match() {
662        let mut utf32buf = Vec::<char>::new();
663        let line = "hello";
664        let mut matcher = Matcher::new(Config::DEFAULT);
665        let haystack: Utf32Str<'_> = Utf32Str::new(line, &mut utf32buf);
666        let pattern = create_pattern("zzz");
667        let score = pattern.score(haystack, &mut matcher);
668        assert_eq!(score, None);
669    }
670
671    #[test]
672    fn tie_breakers_sort_by_path_when_scores_equal() {
673        let mut matches = vec![
674            (100, "b_path".to_string()),
675            (100, "a_path".to_string()),
676            (90, "zzz".to_string()),
677        ];
678
679        sort_matches(&mut matches);
680
681        // Highest score first; ties broken alphabetically.
682        let expected = vec![
683            (100, "a_path".to_string()),
684            (100, "b_path".to_string()),
685            (90, "zzz".to_string()),
686        ];
687
688        assert_eq!(matches, expected);
689    }
690
691    #[test]
692    fn file_name_from_path_uses_basename() {
693        assert_eq!(file_name_from_path("foo/bar.txt"), "bar.txt");
694    }
695
696    #[test]
697    fn file_name_from_path_falls_back_to_full_path() {
698        assert_eq!(file_name_from_path(""), "");
699    }
700
701    #[derive(Default)]
702    struct RecordingReporter {
703        updates: Mutex<Vec<FileSearchSnapshot>>,
704        complete_times: Mutex<Vec<Instant>>,
705        complete_cv: Condvar,
706        update_cv: Condvar,
707    }
708
709    impl RecordingReporter {
710        fn wait_until<T, F>(
711            &self,
712            mutex: &Mutex<T>,
713            cv: &Condvar,
714            timeout: Duration,
715            mut predicate: F,
716        ) -> bool
717        where
718            F: FnMut(&T) -> bool,
719        {
720            let deadline = Instant::now() + timeout;
721            let mut state = mutex.lock().unwrap();
722            loop {
723                if predicate(&state) {
724                    return true;
725                }
726                let remaining = deadline.saturating_duration_since(Instant::now());
727                if remaining.is_zero() {
728                    return false;
729                }
730                let (next_state, wait_result) = cv.wait_timeout(state, remaining).unwrap();
731                state = next_state;
732                if wait_result.timed_out() {
733                    return predicate(&state);
734                }
735            }
736        }
737
738        fn wait_for_complete(&self, timeout: Duration) -> bool {
739            self.wait_until(
740                &self.complete_times,
741                &self.complete_cv,
742                timeout,
743                |completes| !completes.is_empty(),
744            )
745        }
746        fn clear(&self) {
747            self.updates.lock().unwrap().clear();
748            self.complete_times.lock().unwrap().clear();
749        }
750
751        fn updates(&self) -> Vec<FileSearchSnapshot> {
752            self.updates.lock().unwrap().clone()
753        }
754
755        fn wait_for_updates_at_least(&self, min_len: usize, timeout: Duration) -> bool {
756            self.wait_until(&self.updates, &self.update_cv, timeout, |updates| {
757                updates.len() >= min_len
758            })
759        }
760
761        fn snapshot(&self) -> FileSearchSnapshot {
762            self.updates
763                .lock()
764                .unwrap()
765                .last()
766                .cloned()
767                .unwrap_or_default()
768        }
769    }
770
771    impl SessionReporter for RecordingReporter {
772        fn on_update(&self, snapshot: &FileSearchSnapshot) {
773            let mut updates = self.updates.lock().unwrap();
774            updates.push(snapshot.clone());
775            self.update_cv.notify_all();
776        }
777
778        fn on_complete(&self) {
779            {
780                let mut complete_times = self.complete_times.lock().unwrap();
781                complete_times.push(Instant::now());
782            }
783            self.complete_cv.notify_all();
784        }
785    }
786
787    fn create_temp_tree(file_count: usize) -> TempDir {
788        let dir = tempfile::tempdir().unwrap();
789        for i in 0..file_count {
790            let path = dir.path().join(format!("file-{i:04}.txt"));
791            fs::write(path, format!("contents {i}")).unwrap();
792        }
793        dir
794    }
795
796    #[test]
797    fn session_scanned_file_count_is_monotonic_across_queries() {
798        let dir = create_temp_tree(/*file_count*/ 200);
799        let reporter = Arc::new(RecordingReporter::default());
800        let session = create_session(
801            vec![dir.path().to_path_buf()],
802            FileSearchOptions::default(),
803            reporter.clone(),
804            /*cancel_flag*/ None,
805        )
806        .expect("session");
807
808        session.update_query("file-00");
809        thread::sleep(Duration::from_millis(20));
810        let first_snapshot = reporter.snapshot();
811        session.update_query("file-01");
812        thread::sleep(Duration::from_millis(20));
813        let second_snapshot = reporter.snapshot();
814        let _ = reporter.wait_for_complete(Duration::from_secs(5));
815        let completed_snapshot = reporter.snapshot();
816
817        assert!(second_snapshot.scanned_file_count >= first_snapshot.scanned_file_count);
818        assert!(completed_snapshot.scanned_file_count >= second_snapshot.scanned_file_count);
819    }
820
821    #[test]
822    fn session_streams_updates_before_walk_complete() {
823        let dir = create_temp_tree(/*file_count*/ 600);
824        let reporter = Arc::new(RecordingReporter::default());
825        let session = create_session(
826            vec![dir.path().to_path_buf()],
827            FileSearchOptions::default(),
828            reporter.clone(),
829            /*cancel_flag*/ None,
830        )
831        .expect("session");
832
833        session.update_query("file-0");
834        let completed = reporter.wait_for_complete(Duration::from_secs(5));
835
836        assert!(completed);
837        let updates = reporter.updates();
838        assert!(updates.iter().any(|snapshot| !snapshot.walk_complete));
839    }
840
841    #[test]
842    fn session_accepts_query_updates_after_walk_complete() {
843        let dir = tempfile::tempdir().unwrap();
844        fs::write(dir.path().join("alpha.txt"), "alpha").unwrap();
845        fs::write(dir.path().join("beta.txt"), "beta").unwrap();
846        let reporter = Arc::new(RecordingReporter::default());
847        let session = create_session(
848            vec![dir.path().to_path_buf()],
849            FileSearchOptions::default(),
850            reporter.clone(),
851            /*cancel_flag*/ None,
852        )
853        .expect("session");
854
855        session.update_query("alpha");
856        assert!(reporter.wait_for_complete(Duration::from_secs(5)));
857        let updates_before = reporter.updates().len();
858
859        session.update_query("beta");
860        assert!(reporter.wait_for_updates_at_least(updates_before + 1, Duration::from_secs(5),));
861
862        let updates = reporter.updates();
863        let last_update = updates.last().cloned().expect("update");
864        assert!(
865            last_update
866                .matches
867                .iter()
868                .any(|file_match| file_match.path.to_string_lossy().contains("beta.txt"))
869        );
870    }
871
872    #[test]
873    fn session_emits_complete_when_query_changes_with_no_matches() {
874        let dir = tempfile::tempdir().unwrap();
875        fs::write(dir.path().join("alpha.txt"), "alpha").unwrap();
876        fs::write(dir.path().join("beta.txt"), "beta").unwrap();
877        let reporter = Arc::new(RecordingReporter::default());
878        let session = create_session(
879            vec![dir.path().to_path_buf()],
880            FileSearchOptions::default(),
881            reporter.clone(),
882            /*cancel_flag*/ None,
883        )
884        .expect("session");
885
886        session.update_query("asdf");
887        assert!(reporter.wait_for_complete(Duration::from_secs(5)));
888
889        let completed_snapshot = reporter.snapshot();
890        assert_eq!(completed_snapshot.matches, Vec::new());
891        assert_eq!(completed_snapshot.total_match_count, 0);
892
893        reporter.clear();
894
895        session.update_query("asdfa");
896        assert!(reporter.wait_for_complete(Duration::from_secs(5)));
897        assert!(!reporter.updates().is_empty());
898    }
899
900    #[test]
901    fn dropping_session_does_not_cancel_siblings_with_shared_cancel_flag() {
902        let root_a = create_temp_tree(/*file_count*/ 200);
903        let root_b = create_temp_tree(/*file_count*/ 4_000);
904        let cancel_flag = Arc::new(AtomicBool::new(false));
905
906        let reporter_a = Arc::new(RecordingReporter::default());
907        let session_a = create_session(
908            vec![root_a.path().to_path_buf()],
909            FileSearchOptions::default(),
910            reporter_a,
911            Some(cancel_flag.clone()),
912        )
913        .expect("session_a");
914
915        let reporter_b = Arc::new(RecordingReporter::default());
916        let session_b = create_session(
917            vec![root_b.path().to_path_buf()],
918            FileSearchOptions::default(),
919            reporter_b.clone(),
920            Some(cancel_flag),
921        )
922        .expect("session_b");
923
924        session_a.update_query("file-0");
925        session_b.update_query("file-1");
926
927        thread::sleep(Duration::from_millis(5));
928        drop(session_a);
929
930        let completed = reporter_b.wait_for_complete(Duration::from_secs(5));
931        assert_eq!(completed, true);
932    }
933
934    #[test]
935    fn session_emits_updates_when_query_changes() {
936        let dir = create_temp_tree(/*file_count*/ 200);
937        let reporter = Arc::new(RecordingReporter::default());
938        let session = create_session(
939            vec![dir.path().to_path_buf()],
940            FileSearchOptions::default(),
941            reporter.clone(),
942            /*cancel_flag*/ None,
943        )
944        .expect("session");
945
946        session.update_query("zzzzzzzz");
947        let completed = reporter.wait_for_complete(Duration::from_secs(5));
948        assert!(completed);
949
950        reporter.clear();
951
952        session.update_query("zzzzzzzzq");
953        let completed = reporter.wait_for_complete(Duration::from_secs(5));
954        assert!(completed);
955
956        let updates = reporter.updates();
957        assert_eq!(updates.len(), 1);
958    }
959
960    #[test]
961    fn run_returns_matches_for_query() {
962        let dir = create_temp_tree(/*file_count*/ 40);
963        let options = FileSearchOptions {
964            limit: NonZero::new(20).unwrap(),
965            exclude: Vec::new(),
966            threads: NonZero::new(2).unwrap(),
967            compute_indices: false,
968            respect_gitignore: true,
969        };
970        let results = run(
971            "file-000",
972            vec![dir.path().to_path_buf()],
973            options,
974            /*cancel_flag*/ None,
975        )
976        .expect("run ok");
977
978        assert!(!results.matches.is_empty());
979        assert!(results.total_match_count >= results.matches.len());
980        assert!(
981            results
982                .matches
983                .iter()
984                .any(|m| m.path.to_string_lossy().contains("file-0000.txt"))
985        );
986    }
987
988    #[test]
989    fn run_returns_directory_matches_for_query() {
990        let dir = tempfile::tempdir().unwrap();
991        fs::create_dir_all(dir.path().join("docs/guides")).unwrap();
992        fs::write(dir.path().join("docs/guides/intro.md"), "intro").unwrap();
993        fs::write(dir.path().join("docs/readme.md"), "readme").unwrap();
994
995        let results = run(
996            "guides",
997            vec![dir.path().to_path_buf()],
998            FileSearchOptions {
999                limit: NonZero::new(20).unwrap(),
1000                exclude: Vec::new(),
1001                threads: NonZero::new(2).unwrap(),
1002                compute_indices: false,
1003                respect_gitignore: true,
1004            },
1005            /*cancel_flag*/ None,
1006        )
1007        .expect("run ok");
1008
1009        assert!(results.matches.iter().any(|m| {
1010            m.path == std::path::Path::new("docs").join("guides")
1011                && m.match_type == MatchType::Directory
1012        }));
1013    }
1014
1015    #[test]
1016    fn cancel_exits_run() {
1017        let dir = create_temp_tree(/*file_count*/ 200);
1018        let cancel_flag = Arc::new(AtomicBool::new(true));
1019        let search_dir = dir.path().to_path_buf();
1020        let options = FileSearchOptions {
1021            compute_indices: false,
1022            ..Default::default()
1023        };
1024        let (tx, rx) = std::sync::mpsc::channel();
1025
1026        let handle = thread::spawn(move || {
1027            let result = run("file-", vec![search_dir], options, Some(cancel_flag));
1028            let _ = tx.send(result);
1029        });
1030
1031        let result = rx
1032            .recv_timeout(Duration::from_secs(2))
1033            .expect("run should exit after cancellation");
1034        handle.join().unwrap();
1035
1036        let results = result.expect("run ok");
1037        assert_eq!(results.matches, Vec::new());
1038        assert_eq!(results.total_match_count, 0);
1039    }
1040
1041    /// Regression test for #3493: a parent directory's `.gitignore` with `*`
1042    /// must not suppress files discovered inside a child "repo" directory.
1043    ///
1044    /// The fixture intentionally omits `git init` so that no `.git` directory
1045    /// exists. With `require_git(true)`, the walker skips all gitignore
1046    /// processing, making the parent's broad ignore harmless.
1047    #[test]
1048    fn parent_gitignore_outside_repo_does_not_hide_repo_files() {
1049        let temp = tempfile::tempdir().unwrap();
1050        let parent = temp.path().join("home");
1051        let repo = parent.join("repo");
1052        fs::create_dir_all(repo.join(".vscode")).unwrap();
1053
1054        fs::write(parent.join(".gitignore"), "*\n!.gitignore\n").unwrap();
1055        fs::write(
1056            repo.join(".gitignore"),
1057            ".vscode/*\n!.vscode/\n!.vscode/settings.json\n!package.json\n",
1058        )
1059        .unwrap();
1060        fs::write(repo.join("package.json"), "{ \"name\": \"demo\" }\n").unwrap();
1061        fs::write(repo.join(".vscode/settings.json"), "{ \"editor\": true }\n").unwrap();
1062
1063        let respect_results = run(
1064            "package",
1065            vec![repo.clone()],
1066            FileSearchOptions {
1067                limit: NonZero::new(20).unwrap(),
1068                exclude: Vec::new(),
1069                threads: NonZero::new(2).unwrap(),
1070                compute_indices: false,
1071                respect_gitignore: true,
1072            },
1073            /*cancel_flag*/ None,
1074        )
1075        .expect("run ok");
1076        assert!(
1077            respect_results
1078                .matches
1079                .iter()
1080                .any(|m| m.path.as_path() == Path::new("package.json"))
1081        );
1082
1083        let nested_file_results = run(
1084            "settings",
1085            vec![repo],
1086            FileSearchOptions {
1087                limit: NonZero::new(20).unwrap(),
1088                exclude: Vec::new(),
1089                threads: NonZero::new(2).unwrap(),
1090                compute_indices: false,
1091                respect_gitignore: true,
1092            },
1093            /*cancel_flag*/ None,
1094        )
1095        .expect("run ok");
1096        assert!(
1097            nested_file_results
1098                .matches
1099                .iter()
1100                .any(|m| m.path.as_path() == Path::new(".vscode/settings.json"))
1101        );
1102    }
1103
1104    #[test]
1105    fn git_repo_still_respects_local_gitignore_when_enabled() {
1106        let temp = tempfile::tempdir().unwrap();
1107        let parent = temp.path().join("home");
1108        let repo = parent.join("repo");
1109        fs::create_dir_all(repo.join(".vscode")).unwrap();
1110
1111        fs::write(parent.join(".gitignore"), "*\n!.gitignore\n").unwrap();
1112        fs::write(
1113            repo.join(".gitignore"),
1114            ".vscode/*\n!.vscode/\n!.vscode/settings.json\n!package.json\n",
1115        )
1116        .unwrap();
1117        fs::write(repo.join("package.json"), "{ \"name\": \"demo\" }\n").unwrap();
1118        fs::write(repo.join(".vscode/settings.json"), "{ \"editor\": true }\n").unwrap();
1119        fs::write(
1120            repo.join(".vscode/extensions.json"),
1121            "{ \"extensions\": [] }\n",
1122        )
1123        .unwrap();
1124
1125        fs::create_dir_all(repo.join(".git")).unwrap();
1126
1127        let package_results = run(
1128            "package",
1129            vec![repo.clone()],
1130            FileSearchOptions {
1131                limit: NonZero::new(20).unwrap(),
1132                exclude: Vec::new(),
1133                threads: NonZero::new(2).unwrap(),
1134                compute_indices: false,
1135                respect_gitignore: true,
1136            },
1137            /*cancel_flag*/ None,
1138        )
1139        .expect("run ok");
1140        assert!(
1141            package_results
1142                .matches
1143                .iter()
1144                .any(|m| m.path.as_path() == Path::new("package.json"))
1145        );
1146
1147        let ignored_results = run(
1148            "extensions.json",
1149            vec![repo.clone()],
1150            FileSearchOptions {
1151                limit: NonZero::new(20).unwrap(),
1152                exclude: Vec::new(),
1153                threads: NonZero::new(2).unwrap(),
1154                compute_indices: false,
1155                respect_gitignore: true,
1156            },
1157            /*cancel_flag*/ None,
1158        )
1159        .expect("run ok");
1160        assert!(
1161            !ignored_results
1162                .matches
1163                .iter()
1164                .any(|m| m.path.as_path() == Path::new(".vscode/extensions.json"))
1165        );
1166
1167        let whitelisted_results = run(
1168            "settings.json",
1169            vec![repo],
1170            FileSearchOptions {
1171                limit: NonZero::new(20).unwrap(),
1172                exclude: Vec::new(),
1173                threads: NonZero::new(2).unwrap(),
1174                compute_indices: false,
1175                respect_gitignore: true,
1176            },
1177            /*cancel_flag*/ None,
1178        )
1179        .expect("run ok");
1180        assert!(
1181            whitelisted_results
1182                .matches
1183                .iter()
1184                .any(|m| m.path.as_path() == Path::new(".vscode/settings.json"))
1185        );
1186    }
1187}