Skip to main content

aft/
grep_executor.rs

1use std::collections::HashSet;
2use std::env;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use ignore::WalkBuilder;
9use rayon::prelude::*;
10
11use crate::commands::multi_path::{
12    canonical_key, dedupe_nested_paths, resolve_path_or_multi, SearchPathResolution,
13};
14use crate::context::AppContext;
15use crate::pattern_compile::{CompiledPattern, LiteralSearch};
16use crate::protocol::Response;
17use crate::search_index::{
18    build_path_filters, decompose_grep_pattern, has_any_project_file_from, read_searchable_text,
19    resolve_search_scope, sort_grep_matches_by_mtime_desc, sort_walked_paths_by_mtime_desc,
20    try_read_with_budget, GrepMatch, GrepPathExclusion, GrepQueryPhaseTimings, GrepResult,
21    IndexStatus, PathFilters, RegexQuery, INTERACTIVE_ARTIFACT_READ_BUDGET,
22};
23
24/// Maximum files enumerated during grep/glob index-unavailable fallback walks.
25pub(crate) const MAX_FALLBACK_WALK_FILES: usize = 50_000;
26/// Wall-clock budget for grep/glob index-unavailable fallback walks on the dispatch thread.
27pub(crate) const FALLBACK_WALK_BUDGET: Duration = Duration::from_secs(10);
28
29#[derive(Clone, Debug)]
30pub struct FallbackWalkOutcome {
31    pub files: Vec<PathBuf>,
32    pub walk_truncated: bool,
33    /// Foreign filesystem mounts skipped before a recursive fallback could open them.
34    pub skipped_foreign_mounts: usize,
35    pub entries_visited: usize,
36}
37
38#[derive(Clone, Copy, Debug, Default)]
39struct FallbackWalkProgress {
40    walk_truncated: bool,
41    skipped_foreign_mounts: usize,
42}
43
44#[derive(Clone, Debug)]
45pub struct GrepParams {
46    pub include: Vec<String>,
47    pub exclude: Vec<String>,
48    pub max_results: usize,
49    pub path_exclusion: Option<GrepPathExclusion>,
50}
51
52#[derive(Clone, Copy, Debug, Default)]
53pub(crate) struct GrepExecutionPhaseTimings {
54    pub snapshot_acquire: Duration,
55    /// Regex decomposition is paid once per grep request, not once per root.
56    pub query_decomposition: Duration,
57    pub query: GrepQueryPhaseTimings,
58    pub indexed_scope_has_files: Option<bool>,
59}
60
61impl GrepExecutionPhaseTimings {
62    fn add(&mut self, other: Self) {
63        self.snapshot_acquire += other.snapshot_acquire;
64        self.query.trigram_lookup += other.query.trigram_lookup;
65        self.query.pread_verify += other.query.pread_verify;
66        self.query.post_filter += other.query.post_filter;
67        self.query.candidate_count += other.query.candidate_count;
68        self.query.bytes_verified += other.query.bytes_verified;
69        self.indexed_scope_has_files =
70            match (self.indexed_scope_has_files, other.indexed_scope_has_files) {
71                (Some(left), Some(right)) => Some(left || right),
72                _ => None,
73            };
74    }
75}
76
77#[derive(Clone, Debug)]
78pub struct GrepScope {
79    pub roots: Vec<ResolvedRoot>,
80    pub multi_root: bool,
81    pub per_root_max: usize,
82}
83
84#[derive(Clone, Debug)]
85pub struct ResolvedRoot {
86    pub search_root: PathBuf,
87    pub filter_root: PathBuf,
88    pub use_index: bool,
89    pub is_external: bool,
90}
91
92pub fn project_root(ctx: &AppContext) -> PathBuf {
93    let project_root = ctx
94        .config()
95        .project_root
96        .clone()
97        .unwrap_or_else(|| env::current_dir().unwrap_or_default());
98    std::fs::canonicalize(&project_root).unwrap_or(project_root)
99}
100
101pub fn resolve_grep_scope(
102    ctx: &AppContext,
103    paths: Option<&serde_json::Value>,
104    max_results: usize,
105    req_id: &str,
106) -> Result<GrepScope, Response> {
107    let project_root = project_root(ctx);
108    let search_roots = resolve_roots(ctx, paths, &project_root, req_id)?;
109
110    if let Some(missing_root) = search_roots.iter().find(|root| !root.exists()) {
111        return Err(Response::error(
112            req_id,
113            "path_not_found",
114            format!(
115                "grep: search path does not exist: {}",
116                missing_root.display()
117            ),
118        ));
119    }
120
121    let roots = search_roots
122        .into_iter()
123        .map(|search_root| {
124            let scope = resolve_search_scope(&project_root, Some(&search_root.to_string_lossy()));
125            let is_external = !scope.use_index;
126            let filter_root =
127                compute_filter_root(&project_root, &scope.root, scope.use_index, is_external);
128            ResolvedRoot {
129                search_root: scope.root,
130                filter_root,
131                use_index: scope.use_index,
132                is_external,
133            }
134        })
135        .collect::<Vec<_>>();
136
137    let multi_root = roots.len() > 1;
138    let per_root_max = if multi_root {
139        max_results.saturating_mul(2).max(max_results)
140    } else {
141        max_results
142    };
143
144    Ok(GrepScope {
145        roots,
146        multi_root,
147        per_root_max,
148    })
149}
150
151pub fn compute_filter_root(
152    project_root: &Path,
153    search_root: &Path,
154    use_index: bool,
155    is_external: bool,
156) -> PathBuf {
157    if is_external && !use_index {
158        search_root.to_path_buf()
159    } else {
160        project_root.to_path_buf()
161    }
162}
163
164pub fn scope_has_files(project_root: &Path, scope: &GrepScope) -> bool {
165    scope.roots.iter().any(|root| {
166        // An explicitly-named existing file is always in scope (it's searched
167        // directly even if gitignored / .aftignored), so don't report it as
168        // "no files matched scope".
169        if root.search_root.is_file() {
170            return true;
171        }
172        let catch_all =
173            build_path_filters(&["**/*".to_string()], &[]).expect("valid catch-all glob");
174        has_any_project_file_from(&root.filter_root, &root.search_root, &catch_all)
175            || has_any_project_file_from(project_root, &root.search_root, &catch_all)
176    })
177}
178
179pub fn execute(
180    ctx: &AppContext,
181    pattern: &CompiledPattern,
182    scope: &GrepScope,
183    params: &GrepParams,
184) -> GrepResult {
185    execute_profiled(ctx, pattern, scope, params).0
186}
187
188pub(crate) fn execute_profiled(
189    ctx: &AppContext,
190    pattern: &CompiledPattern,
191    scope: &GrepScope,
192    params: &GrepParams,
193) -> (GrepResult, GrepExecutionPhaseTimings) {
194    let filters = build_path_filters(&params.include, &params.exclude).unwrap_or_default();
195    execute_profiled_with_filters(ctx, pattern, scope, params, &filters)
196}
197
198pub(crate) fn execute_profiled_with_filters(
199    ctx: &AppContext,
200    pattern: &CompiledPattern,
201    scope: &GrepScope,
202    params: &GrepParams,
203    filters: &PathFilters,
204) -> (GrepResult, GrepExecutionPhaseTimings) {
205    let project_root = project_root(ctx);
206    let query_started = Instant::now();
207    let query = decompose_grep_pattern(pattern);
208    let query_decomposition = query_started.elapsed();
209    if scope.roots.len() == 1 {
210        let (result, mut phases) = execute_root_profiled(
211            ctx,
212            pattern,
213            &query,
214            &scope.roots[0],
215            params,
216            filters,
217            params.max_results,
218            &project_root,
219        );
220        phases.query_decomposition = query_decomposition;
221        return (result, phases);
222    }
223
224    let mut results = Vec::new();
225    let mut phases: Option<GrepExecutionPhaseTimings> = None;
226    for root in &scope.roots {
227        let (result, root_phases) = execute_root_profiled(
228            ctx,
229            pattern,
230            &query,
231            root,
232            params,
233            filters,
234            scope.per_root_max,
235            &project_root,
236        );
237        results.push(result);
238        if let Some(phases) = phases.as_mut() {
239            phases.add(root_phases);
240        } else {
241            phases = Some(root_phases);
242        }
243    }
244    let mut phases = phases.unwrap_or_default();
245    phases.query_decomposition = query_decomposition;
246    (
247        merge_grep_results(results, &project_root, params.max_results),
248        phases,
249    )
250}
251
252fn resolve_roots(
253    ctx: &AppContext,
254    paths: Option<&serde_json::Value>,
255    project_root: &Path,
256    req_id: &str,
257) -> Result<Vec<PathBuf>, Response> {
258    let Some(paths) = paths else {
259        return Ok(vec![resolve_search_scope(project_root, None).root]);
260    };
261    if paths.is_null() {
262        return Ok(vec![resolve_search_scope(project_root, None).root]);
263    }
264    if let Some(path) = paths.as_str() {
265        return match resolve_path_or_multi(
266            path,
267            project_root,
268            |candidate| ctx.validate_path(req_id, candidate),
269            req_id,
270        )? {
271            SearchPathResolution::Single(root) => Ok(vec![root]),
272            SearchPathResolution::Multi(roots) => Ok(roots),
273        };
274    }
275    if let Some(items) = paths.as_array() {
276        let mut roots = Vec::with_capacity(items.len());
277        for item in items {
278            let Some(path) = item.as_str() else {
279                return Err(Response::error(
280                    req_id,
281                    "invalid_request",
282                    "grep: path array entries must be strings",
283                ));
284            };
285            let validated = ctx.validate_path(req_id, Path::new(path))?;
286            let raw = validated.to_string_lossy();
287            roots.push(resolve_search_scope(project_root, Some(raw.as_ref())).root);
288        }
289        let roots = dedupe_nested_paths(roots);
290        if roots.is_empty() {
291            Ok(vec![resolve_search_scope(project_root, None).root])
292        } else {
293            Ok(roots)
294        }
295    } else {
296        Err(Response::error(
297            req_id,
298            "invalid_request",
299            "grep: path must be a string, array of strings, or null",
300        ))
301    }
302}
303
304fn execute_root_profiled(
305    ctx: &AppContext,
306    pattern: &CompiledPattern,
307    query: &RegexQuery,
308    root: &ResolvedRoot,
309    params: &GrepParams,
310    filters: &PathFilters,
311    max_results: usize,
312    project_root: &Path,
313) -> (GrepResult, GrepExecutionPhaseTimings) {
314    // Explicit single-file scope: search the named file directly, bypassing the
315    // trigram index and the gitignore/.aftignore-aware walk. Matches ripgrep,
316    // where naming a file explicitly searches it even when it is gitignored,
317    // .aftignored, or not yet indexed. Binary + UTF-8 guards still apply.
318    if root.search_root.is_file() {
319        if root.use_index {
320            crate::commands::configure::trigger_search_index_reload_if_evicted(ctx);
321        }
322        let index_status = if root.use_index {
323            current_index_status(ctx)
324        } else {
325            IndexStatus::Fallback
326        };
327        let result = if params
328            .path_exclusion
329            .is_some_and(|exclude| exclude(&root.search_root, project_root))
330        {
331            empty_grep_result(index_status, false)
332        } else {
333            grep_explicit_file(&root.search_root, pattern, max_results, index_status)
334        };
335        return (result, GrepExecutionPhaseTimings::default());
336    }
337
338    let snapshot_started = Instant::now();
339    let mut snapshot_timed_out = false;
340    let indexed_snapshot =
341        match try_read_with_budget(ctx.search_index(), INTERACTIVE_ARTIFACT_READ_BUDGET) {
342            Some(search_index) => match search_index.as_ref() {
343                Some(index) if index.ready && root.use_index => Some(index.snapshot()),
344                _ => None,
345            },
346            None => {
347                snapshot_timed_out = true;
348                None
349            }
350        };
351    let snapshot_acquire = snapshot_started.elapsed();
352    if let Some(snapshot) = indexed_snapshot {
353        let scope_started = Instant::now();
354        let indexed_scope_has_files = snapshot.has_file_in_scope(&root.search_root);
355        let scope_elapsed = scope_started.elapsed();
356        let (result, mut query_timings) = snapshot.search_grep_profiled_with_filters_and_query(
357            pattern,
358            query,
359            filters,
360            &root.search_root,
361            max_results,
362            params.path_exclusion,
363        );
364        query_timings.post_filter += scope_elapsed;
365        return (
366            result,
367            GrepExecutionPhaseTimings {
368                snapshot_acquire,
369                query_decomposition: Duration::ZERO,
370                query: query_timings,
371                indexed_scope_has_files: Some(indexed_scope_has_files),
372            },
373        );
374    }
375
376    if root.use_index {
377        crate::commands::configure::trigger_search_index_reload_if_evicted(ctx);
378    }
379    let index_status = if root.use_index {
380        if snapshot_timed_out {
381            IndexStatus::Fallback
382        } else {
383            current_index_status(ctx)
384        }
385    } else {
386        IndexStatus::Fallback
387    };
388    (
389        fallback_grep(
390            project_root,
391            &root.search_root,
392            &root.filter_root,
393            pattern,
394            filters,
395            max_results,
396            index_status,
397            params.path_exclusion,
398        ),
399        GrepExecutionPhaseTimings {
400            snapshot_acquire,
401            ..GrepExecutionPhaseTimings::default()
402        },
403    )
404}
405
406fn empty_grep_result(index_status: IndexStatus, fully_degraded: bool) -> GrepResult {
407    GrepResult {
408        matches: Vec::new(),
409        total_matches: 0,
410        files_searched: 0,
411        files_with_matches: 0,
412        index_status,
413        truncated: false,
414        fully_degraded,
415        engine_capped: false,
416        walk_truncated: false,
417        skipped_foreign_mounts: 0,
418    }
419}
420
421/// Grep a single explicitly-named file directly, bypassing the trigram index
422/// and the gitignore/.aftignore-aware walk. Used when the caller's `path`
423/// resolves to one existing file — ripgrep semantics: an explicitly-named file
424/// is searched even when it is gitignored, `.aftignore`d, or not yet indexed.
425/// Binary detection and UTF-8 guards still apply (via `read_searchable_text`
426/// inside `fallback_search_file`).
427fn grep_explicit_file(
428    file: &Path,
429    pattern: &CompiledPattern,
430    max_results: usize,
431    index_status: IndexStatus,
432) -> GrepResult {
433    let total_matches = AtomicUsize::new(0);
434    let files_searched = AtomicUsize::new(0);
435    let files_with_matches = AtomicUsize::new(0);
436    let truncated = AtomicBool::new(false);
437    let engine_capped = AtomicBool::new(false);
438    let stop_after = max_results.saturating_mul(2);
439    let job_cancellation = crate::executor::current_job_cancellation();
440
441    let matches = fallback_search_file(
442        &file.to_path_buf(),
443        pattern,
444        max_results,
445        stop_after,
446        &total_matches,
447        &files_searched,
448        &files_with_matches,
449        &truncated,
450        &engine_capped,
451        job_cancellation.as_ref(),
452        None,
453    );
454
455    GrepResult {
456        total_matches: total_matches.load(Ordering::Relaxed),
457        matches,
458        files_searched: files_searched.load(Ordering::Relaxed),
459        files_with_matches: files_with_matches.load(Ordering::Relaxed),
460        index_status,
461        truncated: truncated.load(Ordering::Relaxed),
462        fully_degraded: false,
463        engine_capped: engine_capped.load(Ordering::Relaxed),
464        walk_truncated: false,
465        skipped_foreign_mounts: 0,
466    }
467}
468
469pub fn merge_grep_results(
470    results: Vec<GrepResult>,
471    project_root: &Path,
472    max_results: usize,
473) -> GrepResult {
474    let mut matches = Vec::new();
475    let mut total_matches = 0usize;
476    let mut files_searched = 0usize;
477    let mut files_with_matches = 0usize;
478    let mut index_status = IndexStatus::Ready;
479    let mut any_child_truncated = false;
480    let mut fully_degraded = false;
481    let mut engine_capped = false;
482    let mut walk_truncated = false;
483    let mut skipped_foreign_mounts = 0usize;
484    let mut seen_match_keys = HashSet::new();
485
486    for result in results {
487        total_matches += result.total_matches;
488        files_searched += result.files_searched;
489        files_with_matches += result.files_with_matches;
490        index_status = weakest_index_status(index_status, result.index_status);
491        any_child_truncated |= result.truncated;
492        fully_degraded |= result.fully_degraded;
493        engine_capped |= result.engine_capped;
494        walk_truncated |= result.walk_truncated;
495        skipped_foreign_mounts += result.skipped_foreign_mounts;
496
497        for grep_match in result.matches {
498            let file_key = canonical_key(&grep_match.file);
499            let match_key = (file_key, grep_match.line, grep_match.column);
500            if seen_match_keys.insert(match_key) {
501                matches.push(grep_match);
502            }
503        }
504    }
505
506    sort_grep_matches_by_mtime_desc(&mut matches, project_root);
507    if matches.len() > max_results {
508        matches.truncate(max_results);
509    }
510
511    GrepResult {
512        matches,
513        total_matches,
514        files_searched,
515        files_with_matches,
516        index_status,
517        truncated: any_child_truncated || total_matches > max_results,
518        fully_degraded,
519        engine_capped,
520        walk_truncated,
521        skipped_foreign_mounts,
522    }
523}
524
525fn fallback_project_walk_builder(
526    search_root: &Path,
527    skipped_foreign_mounts: Arc<AtomicUsize>,
528) -> WalkBuilder {
529    let mut builder = WalkBuilder::new(search_root);
530    let boundary = crate::walk_boundary::DeviceBoundary::for_root(search_root).ok();
531    // A disappearing child mount can make ReadDir::drop panic on ENXIO and abort
532    // the daemon, so never open directories outside this walk root's filesystem.
533    builder
534        .same_file_system(true)
535        .hidden(false)
536        .git_ignore(true)
537        .git_global(true)
538        .git_exclude(true)
539        .add_custom_ignore_filename(".aftignore")
540        .filter_entry(move |entry| {
541            if entry.depth() > 0 && entry.file_type().map_or(false, |ft| ft.is_dir()) {
542                match boundary
543                    .as_ref()
544                    .map(|boundary| boundary.should_descend(entry.path()))
545                {
546                    Some(Ok(false)) => {
547                        skipped_foreign_mounts.fetch_add(1, Ordering::Relaxed);
548                        return false;
549                    }
550                    Some(Err(_)) => return false,
551                    _ => {}
552                }
553            }
554            let name = entry.file_name().to_string_lossy();
555            if entry.file_type().map_or(false, |ft| ft.is_dir()) {
556                return !matches!(
557                    name.as_ref(),
558                    "node_modules"
559                        | "target"
560                        | "venv"
561                        | ".venv"
562                        | ".git"
563                        | "__pycache__"
564                        | ".tox"
565                        | "dist"
566                        | "build"
567                );
568            }
569            true
570        });
571    builder
572}
573
574/// Bounded project walk used when the trigram index is unavailable (grep/glob fallback).
575pub(crate) fn bounded_fallback_walk_files(
576    filter_root: &Path,
577    search_root: &Path,
578    filters: &PathFilters,
579) -> FallbackWalkOutcome {
580    bounded_fallback_walk_files_with_limits(
581        filter_root,
582        search_root,
583        filters,
584        MAX_FALLBACK_WALK_FILES,
585        FALLBACK_WALK_BUDGET,
586    )
587}
588
589fn bounded_fallback_walk_files_with_limits(
590    filter_root: &Path,
591    search_root: &Path,
592    filters: &PathFilters,
593    max_files: usize,
594    budget: Duration,
595) -> FallbackWalkOutcome {
596    let started = Instant::now();
597    let mut files = Vec::new();
598    let mut walk_truncated = false;
599    let mut entries_visited = 0usize;
600    let skipped_foreign_mounts = Arc::new(AtomicUsize::new(0));
601    let builder = fallback_project_walk_builder(search_root, Arc::clone(&skipped_foreign_mounts));
602
603    for entry in builder.build().filter_map(|entry| entry.ok()) {
604        entries_visited += 1;
605        if started.elapsed() >= budget {
606            walk_truncated = true;
607            break;
608        }
609        if !entry
610            .file_type()
611            .map_or(false, |file_type| file_type.is_file())
612        {
613            continue;
614        }
615        let path = entry.into_path();
616        if filters.matches(filter_root, &path) {
617            files.push(path);
618            if files.len() > max_files {
619                walk_truncated = true;
620                files.truncate(max_files);
621                break;
622            }
623        }
624    }
625
626    sort_walked_paths_by_mtime_desc(&mut files, filter_root);
627    FallbackWalkOutcome {
628        files,
629        walk_truncated,
630        skipped_foreign_mounts: skipped_foreign_mounts.load(Ordering::Relaxed),
631        entries_visited,
632    }
633}
634
635fn for_each_bounded_fallback_walk_file<F>(
636    filter_root: &Path,
637    search_root: &Path,
638    filters: &PathFilters,
639    project_root: &Path,
640    path_exclusion: Option<GrepPathExclusion>,
641    mut on_file: F,
642) -> FallbackWalkProgress
643where
644    F: FnMut(&PathBuf),
645{
646    for_each_bounded_fallback_walk_file_with_limits(
647        filter_root,
648        search_root,
649        filters,
650        project_root,
651        path_exclusion,
652        MAX_FALLBACK_WALK_FILES,
653        FALLBACK_WALK_BUDGET,
654        &mut on_file,
655    )
656}
657
658fn for_each_bounded_fallback_walk_file_with_limits<F>(
659    filter_root: &Path,
660    search_root: &Path,
661    filters: &PathFilters,
662    project_root: &Path,
663    path_exclusion: Option<GrepPathExclusion>,
664    max_files: usize,
665    budget: Duration,
666    on_file: &mut F,
667) -> FallbackWalkProgress
668where
669    F: FnMut(&PathBuf),
670{
671    let started = Instant::now();
672    let mut files_seen = 0usize;
673    let skipped_foreign_mounts = Arc::new(AtomicUsize::new(0));
674    let builder = fallback_project_walk_builder(search_root, Arc::clone(&skipped_foreign_mounts));
675
676    for entry in builder.build().filter_map(|entry| entry.ok()) {
677        if crate::executor::current_job_cancelled() {
678            return FallbackWalkProgress {
679                walk_truncated: true,
680                skipped_foreign_mounts: skipped_foreign_mounts.load(Ordering::Relaxed),
681            };
682        }
683        if started.elapsed() >= budget {
684            return FallbackWalkProgress {
685                walk_truncated: true,
686                skipped_foreign_mounts: skipped_foreign_mounts.load(Ordering::Relaxed),
687            };
688        }
689        if !entry
690            .file_type()
691            .map_or(false, |file_type| file_type.is_file())
692        {
693            continue;
694        }
695        let path = entry.into_path();
696        if path_exclusion.is_some_and(|exclude| exclude(&path, project_root)) {
697            continue;
698        }
699        if filters.matches(filter_root, &path) {
700            files_seen += 1;
701            if files_seen > max_files {
702                return FallbackWalkProgress {
703                    walk_truncated: true,
704                    skipped_foreign_mounts: skipped_foreign_mounts.load(Ordering::Relaxed),
705                };
706            }
707            on_file(&path);
708        }
709    }
710    FallbackWalkProgress {
711        walk_truncated: false,
712        skipped_foreign_mounts: skipped_foreign_mounts.load(Ordering::Relaxed),
713    }
714}
715
716pub fn weakest_index_status(left: IndexStatus, right: IndexStatus) -> IndexStatus {
717    match (left, right) {
718        (IndexStatus::Disabled, _) | (_, IndexStatus::Disabled) => IndexStatus::Disabled,
719        (IndexStatus::Fallback, _) | (_, IndexStatus::Fallback) => IndexStatus::Fallback,
720        (IndexStatus::Building, _) | (_, IndexStatus::Building) => IndexStatus::Building,
721        (IndexStatus::Ready, IndexStatus::Ready) => IndexStatus::Ready,
722    }
723}
724
725/// Hidden entry for `search_startup_bench` timing (fallback grep path).
726#[doc(hidden)]
727pub fn fallback_grep_bench(
728    project_root: &Path,
729    search_root: &Path,
730    filter_root: &Path,
731    pattern: &CompiledPattern,
732    include: &[String],
733    exclude: &[String],
734    max_results: usize,
735) -> GrepResult {
736    let filters = build_path_filters(include, exclude).unwrap_or_default();
737    fallback_grep(
738        project_root,
739        search_root,
740        filter_root,
741        pattern,
742        &filters,
743        max_results,
744        IndexStatus::Fallback,
745        None,
746    )
747}
748
749fn fallback_grep(
750    project_root: &Path,
751    search_root: &Path,
752    filter_root: &Path,
753    pattern: &CompiledPattern,
754    filters: &PathFilters,
755    max_results: usize,
756    index_status: IndexStatus,
757    path_exclusion: Option<GrepPathExclusion>,
758) -> GrepResult {
759    let total_matches = AtomicUsize::new(0);
760    let files_searched = AtomicUsize::new(0);
761    let files_with_matches = AtomicUsize::new(0);
762    let truncated = AtomicBool::new(false);
763    let engine_capped = AtomicBool::new(false);
764    let stop_after = max_results.saturating_mul(2);
765    let stop_scan = Arc::new(AtomicBool::new(false));
766    let scan_deadline = Instant::now() + FALLBACK_WALK_BUDGET;
767    let job_cancellation = crate::executor::current_job_cancellation();
768
769    let mut matches = Vec::new();
770    let mut batch: Vec<PathBuf> = Vec::with_capacity(256);
771
772    let flush_batch = |batch: &mut Vec<PathBuf>, matches: &mut Vec<GrepMatch>| {
773        if batch.is_empty() {
774            return;
775        }
776        let chunk = std::mem::take(batch);
777        let partial: Vec<GrepMatch> = chunk
778            .par_iter()
779            .filter_map(|file| {
780                if stop_scan.load(Ordering::Relaxed)
781                    || Instant::now() >= scan_deadline
782                    || job_cancellation
783                        .as_ref()
784                        .is_some_and(|token| token.cancel_requested_before_commit())
785                {
786                    return None;
787                }
788                let file_matches = fallback_search_file(
789                    file,
790                    pattern,
791                    max_results,
792                    stop_after,
793                    &total_matches,
794                    &files_searched,
795                    &files_with_matches,
796                    &truncated,
797                    &engine_capped,
798                    job_cancellation.as_ref(),
799                    Some(scan_deadline),
800                );
801                if truncated.load(Ordering::Relaxed)
802                    && total_matches.load(Ordering::Relaxed) >= stop_after
803                {
804                    stop_scan.store(true, Ordering::Relaxed);
805                }
806                (!file_matches.is_empty()).then_some(file_matches)
807            })
808            .flatten()
809            .collect();
810        matches.extend(partial);
811    };
812
813    let progress = for_each_bounded_fallback_walk_file(
814        filter_root,
815        search_root,
816        filters,
817        project_root,
818        path_exclusion,
819        |path| {
820            if stop_scan.load(Ordering::Relaxed) {
821                return;
822            }
823            batch.push(path.clone());
824            if batch.len() >= 256 {
825                flush_batch(&mut batch, &mut matches);
826            }
827        },
828    );
829    flush_batch(&mut batch, &mut matches);
830    let mut walk_truncated = progress.walk_truncated;
831    if Instant::now() >= scan_deadline {
832        walk_truncated = true;
833        engine_capped.store(true, Ordering::Relaxed);
834    }
835
836    sort_grep_matches_by_mtime_desc(&mut matches, project_root);
837
838    GrepResult {
839        total_matches: total_matches.load(Ordering::Relaxed),
840        matches,
841        files_searched: files_searched.load(Ordering::Relaxed),
842        files_with_matches: files_with_matches.load(Ordering::Relaxed),
843        index_status,
844        truncated: truncated.load(Ordering::Relaxed),
845        fully_degraded: true,
846        engine_capped: engine_capped.load(Ordering::Relaxed),
847        walk_truncated,
848        skipped_foreign_mounts: progress.skipped_foreign_mounts,
849    }
850}
851
852fn fallback_search_file(
853    file: &PathBuf,
854    pattern: &CompiledPattern,
855    max_results: usize,
856    stop_after: usize,
857    total_matches: &AtomicUsize,
858    files_searched: &AtomicUsize,
859    files_with_matches: &AtomicUsize,
860    truncated: &AtomicBool,
861    engine_capped: &AtomicBool,
862    job_cancellation: Option<&crate::executor::JobCancellation>,
863    deadline: Option<Instant>,
864) -> Vec<GrepMatch> {
865    if deadline.is_some_and(|deadline| Instant::now() >= deadline)
866        || should_stop_fallback_search(truncated, total_matches, stop_after, job_cancellation)
867    {
868        engine_capped.store(true, Ordering::Relaxed);
869        return Vec::new();
870    }
871
872    let Some(content) = read_searchable_text(file) else {
873        return Vec::new();
874    };
875    files_searched.fetch_add(1, Ordering::Relaxed);
876
877    let line_starts = line_starts(&content);
878    let mut seen_lines = HashSet::new();
879    let mut matched_this_file = false;
880    let mut matches = Vec::new();
881
882    match pattern {
883        CompiledPattern::Literal(literal) => search_literal_in_text(
884            file,
885            &content,
886            &line_starts,
887            literal,
888            max_results,
889            stop_after,
890            total_matches,
891            &mut seen_lines,
892            truncated,
893            engine_capped,
894            &mut matched_this_file,
895            &mut matches,
896            job_cancellation,
897            deadline,
898        ),
899        CompiledPattern::Regex { compiled, .. } => {
900            for matched in compiled.find_iter(content.as_bytes()) {
901                if deadline.is_some_and(|deadline| Instant::now() >= deadline)
902                    || should_stop_fallback_search(
903                        truncated,
904                        total_matches,
905                        stop_after,
906                        job_cancellation,
907                    )
908                {
909                    engine_capped.store(true, Ordering::Relaxed);
910                    break;
911                }
912
913                let (line, column, line_text) =
914                    line_details(&content, &line_starts, matched.start());
915                if !seen_lines.insert(line) {
916                    continue;
917                }
918
919                matched_this_file = true;
920                let match_number = total_matches.fetch_add(1, Ordering::Relaxed) + 1;
921                if match_number > max_results {
922                    truncated.store(true, Ordering::Relaxed);
923                    break;
924                }
925
926                matches.push(GrepMatch {
927                    file: file.clone(),
928                    line,
929                    column,
930                    line_text,
931                    match_text: String::from_utf8_lossy(matched.as_bytes()).into_owned(),
932                });
933            }
934        }
935    }
936
937    if matched_this_file {
938        files_with_matches.fetch_add(1, Ordering::Relaxed);
939    }
940
941    matches
942}
943
944fn search_literal_in_text(
945    file: &Path,
946    content: &str,
947    line_starts: &[usize],
948    literal: &LiteralSearch,
949    max_results: usize,
950    stop_after: usize,
951    total_matches: &AtomicUsize,
952    seen_lines: &mut HashSet<u32>,
953    truncated: &AtomicBool,
954    engine_capped: &AtomicBool,
955    matched_this_file: &mut bool,
956    matches: &mut Vec<GrepMatch>,
957    job_cancellation: Option<&crate::executor::JobCancellation>,
958    deadline: Option<Instant>,
959) {
960    let content_bytes = content.as_bytes();
961    let search_content;
962    let haystack = if literal.case_insensitive_ascii {
963        search_content = content_bytes.to_ascii_lowercase();
964        search_content.as_slice()
965    } else {
966        content_bytes
967    };
968    let finder = memchr::memmem::Finder::new(&literal.needle);
969    let mut start = 0usize;
970
971    while let Some(position) = finder.find(&haystack[start..]) {
972        if deadline.is_some_and(|deadline| Instant::now() >= deadline)
973            || should_stop_fallback_search(truncated, total_matches, stop_after, job_cancellation)
974        {
975            engine_capped.store(true, Ordering::Relaxed);
976            break;
977        }
978
979        let offset = start + position;
980        start = offset + 1;
981        let (line, column, line_text) = line_details(content, line_starts, offset);
982        if !seen_lines.insert(line) {
983            continue;
984        }
985
986        *matched_this_file = true;
987        let match_number = total_matches.fetch_add(1, Ordering::Relaxed) + 1;
988        if match_number > max_results {
989            truncated.store(true, Ordering::Relaxed);
990            break;
991        }
992
993        let end = offset + literal.needle.len();
994        matches.push(GrepMatch {
995            file: file.to_path_buf(),
996            line,
997            column,
998            line_text,
999            match_text: String::from_utf8_lossy(&content_bytes[offset..end]).into_owned(),
1000        });
1001    }
1002}
1003
1004fn should_stop_fallback_search(
1005    truncated: &AtomicBool,
1006    total_matches: &AtomicUsize,
1007    stop_after: usize,
1008    job_cancellation: Option<&crate::executor::JobCancellation>,
1009) -> bool {
1010    job_cancellation.is_some_and(|token| token.cancel_requested_before_commit())
1011        || (truncated.load(Ordering::Relaxed)
1012            && total_matches.load(Ordering::Relaxed) >= stop_after)
1013}
1014
1015pub(crate) fn ripgrep_glob(
1016    search_root: &Path,
1017    pattern: &str,
1018    max_results: usize,
1019) -> Option<FallbackWalkOutcome> {
1020    let filters = build_path_filters(&[pattern.to_string()], &[]).ok()?;
1021    let mut outcome = bounded_fallback_walk_files(search_root, search_root, &filters);
1022    outcome.files.truncate(max_results);
1023    Some(outcome)
1024}
1025
1026fn current_index_status(ctx: &AppContext) -> IndexStatus {
1027    let Some(search_index) =
1028        try_read_with_budget(ctx.search_index(), INTERACTIVE_ARTIFACT_READ_BUDGET)
1029    else {
1030        return IndexStatus::Fallback;
1031    };
1032    if search_index.as_ref().is_some_and(|index| index.ready) {
1033        return IndexStatus::Ready;
1034    }
1035
1036    let build_in_progress =
1037        try_read_with_budget(ctx.search_index_rx(), INTERACTIVE_ARTIFACT_READ_BUDGET)
1038            .is_some_and(|search_index_rx| search_index_rx.is_some());
1039    if build_in_progress || search_index.is_some() {
1040        IndexStatus::Building
1041    } else {
1042        IndexStatus::Fallback
1043    }
1044}
1045
1046pub fn line_starts(content: &str) -> Vec<usize> {
1047    let mut starts = vec![0usize];
1048    for (index, byte) in content.bytes().enumerate() {
1049        if byte == b'\n' {
1050            starts.push(index + 1);
1051        }
1052    }
1053    starts
1054}
1055
1056/// Floor a byte index to the nearest valid `str` char boundary (never panics).
1057pub fn floor_char_boundary_str(content: &str, mut index: usize) -> usize {
1058    index = index.min(content.len());
1059    while index > 0 && !content.is_char_boundary(index) {
1060        index -= 1;
1061    }
1062    index
1063}
1064
1065/// Prefix of `content` with at most `max_bytes` UTF-8 bytes, truncated on a char boundary.
1066pub fn truncate_at_char_boundary(content: &str, max_bytes: usize) -> &str {
1067    let end = floor_char_boundary_str(content, max_bytes);
1068    &content[..end]
1069}
1070
1071pub fn line_details(content: &str, line_starts: &[usize], offset: usize) -> (u32, u32, String) {
1072    let offset = floor_char_boundary_str(content, offset);
1073    let line_index = match line_starts.binary_search(&offset) {
1074        Ok(index) => index,
1075        Err(index) => index.saturating_sub(1),
1076    };
1077    let line_start = line_starts.get(line_index).copied().unwrap_or(0);
1078    let line_end = content[line_start..]
1079        .find('\n')
1080        .map(|length| line_start + length)
1081        .unwrap_or(content.len());
1082    let line_text = content[line_start..line_end]
1083        .trim_end_matches('\r')
1084        .to_string();
1085    let column = content[line_start..offset].chars().count() as u32 + 1;
1086    (line_index as u32 + 1, column, line_text)
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091    use super::*;
1092
1093    fn grep_match(file: &Path, line: u32, column: u32) -> GrepMatch {
1094        GrepMatch {
1095            file: file.to_path_buf(),
1096            line,
1097            column,
1098            line_text: "needle".to_string(),
1099            match_text: "needle".to_string(),
1100        }
1101    }
1102
1103    fn result(matches: Vec<GrepMatch>, truncated: bool, status: IndexStatus) -> GrepResult {
1104        GrepResult {
1105            total_matches: matches.len(),
1106            files_searched: matches.len(),
1107            files_with_matches: matches.len(),
1108            matches,
1109            index_status: status,
1110            truncated,
1111            fully_degraded: false,
1112            engine_capped: false,
1113            walk_truncated: false,
1114            skipped_foreign_mounts: 0,
1115        }
1116    }
1117
1118    #[test]
1119    fn optional_path_exclusion_controls_visible_totals_without_affecting_default_grep() {
1120        fn excludes_tests(path: &Path, root: &Path) -> bool {
1121            path.strip_prefix(root)
1122                .is_ok_and(|relative| relative.starts_with("tests"))
1123        }
1124
1125        let project = tempfile::tempdir().expect("project");
1126        let test_file = project.path().join("tests/case.rs");
1127        let source_file = project.path().join("src/lib.rs");
1128        std::fs::create_dir_all(test_file.parent().expect("test parent")).expect("test dir");
1129        std::fs::create_dir_all(source_file.parent().expect("source parent")).expect("source dir");
1130        std::fs::write(&test_file, "const NEEDLE: &str = \"needle\";\n").expect("test file");
1131        std::fs::write(&source_file, "pub fn needle() {}\n").expect("source file");
1132        let pattern = match crate::pattern_compile::compile(
1133            "needle",
1134            crate::pattern_compile::CompileOpts {
1135                literal: true,
1136                ..crate::pattern_compile::CompileOpts::default()
1137            },
1138        ) {
1139            crate::pattern_compile::CompileResult::Ok(pattern) => pattern,
1140            other => panic!("compile literal: {other:?}"),
1141        };
1142
1143        let filters = PathFilters::default();
1144        let unfiltered = fallback_grep(
1145            project.path(),
1146            project.path(),
1147            project.path(),
1148            &pattern,
1149            &filters,
1150            10,
1151            IndexStatus::Fallback,
1152            None,
1153        );
1154        assert_eq!(unfiltered.total_matches, 2);
1155        assert_eq!(unfiltered.matches.len(), 2);
1156
1157        let visible = fallback_grep(
1158            project.path(),
1159            project.path(),
1160            project.path(),
1161            &pattern,
1162            &filters,
1163            10,
1164            IndexStatus::Fallback,
1165            Some(excludes_tests),
1166        );
1167        assert_eq!(visible.total_matches, 1);
1168        assert_eq!(visible.matches.len(), 1);
1169        assert_eq!(visible.files_searched, 1);
1170        assert_eq!(visible.files_with_matches, 1);
1171        assert_eq!(visible.matches[0].file, source_file);
1172        assert!(!visible.truncated);
1173        assert!(!visible.engine_capped);
1174    }
1175
1176    #[test]
1177    fn single_root_uses_requested_max() {
1178        let scope = GrepScope {
1179            roots: vec![ResolvedRoot {
1180                search_root: PathBuf::from("/project"),
1181                filter_root: PathBuf::from("/project"),
1182                use_index: true,
1183                is_external: false,
1184            }],
1185            multi_root: false,
1186            per_root_max: 10,
1187        };
1188        assert!(!scope.multi_root);
1189        assert_eq!(scope.per_root_max, 10);
1190    }
1191
1192    #[test]
1193    fn multi_root_uses_double_per_root_max() {
1194        let project = tempfile::tempdir().expect("project");
1195        let ctx = AppContext::new(
1196            Box::new(crate::parser::TreeSitterProvider::new()),
1197            crate::config::Config {
1198                project_root: Some(project.path().to_path_buf()),
1199                ..crate::config::Config::default()
1200            },
1201        );
1202        let left = project.path().join("left");
1203        let right = project.path().join("right");
1204        std::fs::create_dir_all(&left).expect("left");
1205        std::fs::create_dir_all(&right).expect("right");
1206        let paths = serde_json::json!([left.display().to_string(), right.display().to_string()]);
1207
1208        let scope = resolve_grep_scope(&ctx, Some(&paths), 10, "test").expect("scope");
1209
1210        assert!(scope.multi_root);
1211        assert_eq!(scope.per_root_max, 20);
1212    }
1213
1214    #[test]
1215    fn bounded_fallback_walk_truncates_at_file_cap() {
1216        let dir = tempfile::tempdir().expect("tempdir");
1217        let root = dir.path();
1218        for i in 0..25 {
1219            let path = root.join(format!("file_{i:03}.txt"));
1220            std::fs::write(path, "needle\n").expect("write");
1221        }
1222        let filters = build_path_filters(&["**/*.txt".to_string()], &[]).expect("filters");
1223        let outcome = bounded_fallback_walk_files_with_limits(
1224            root,
1225            root,
1226            &filters,
1227            20,
1228            Duration::from_secs(60),
1229        );
1230        assert!(outcome.walk_truncated);
1231        assert_eq!(outcome.files.len(), 20);
1232    }
1233
1234    #[test]
1235    fn bounded_fallback_walk_small_tree_not_truncated() {
1236        let dir = tempfile::tempdir().expect("tempdir");
1237        let root = dir.path();
1238        std::fs::write(root.join("a.txt"), "x\n").expect("write");
1239        std::fs::write(root.join("b.txt"), "x\n").expect("write");
1240        let filters = build_path_filters(&["**/*.txt".to_string()], &[]).expect("filters");
1241        let outcome = bounded_fallback_walk_files(root, root, &filters);
1242        assert!(!outcome.walk_truncated);
1243        assert_eq!(outcome.files.len(), 2);
1244    }
1245
1246    #[test]
1247    fn filter_root_is_project_for_in_project_and_search_root_for_external_unindexed() {
1248        let project = PathBuf::from("/project");
1249        let in_project = compute_filter_root(&project, Path::new("/project/src"), true, false);
1250        let external = compute_filter_root(&project, Path::new("/tmp/external"), false, true);
1251        assert_eq!(in_project, project);
1252        assert_eq!(external, PathBuf::from("/tmp/external"));
1253    }
1254
1255    #[test]
1256    fn weakest_status_orders_disabled_fallback_building_ready() {
1257        assert_eq!(
1258            weakest_index_status(IndexStatus::Ready, IndexStatus::Building),
1259            IndexStatus::Building
1260        );
1261        assert_eq!(
1262            weakest_index_status(IndexStatus::Building, IndexStatus::Fallback),
1263            IndexStatus::Fallback
1264        );
1265        assert_eq!(
1266            weakest_index_status(IndexStatus::Fallback, IndexStatus::Disabled),
1267            IndexStatus::Disabled
1268        );
1269    }
1270
1271    #[test]
1272    fn merge_dedupes_by_canonical_file_line_column() {
1273        let temp = tempfile::tempdir().expect("temp");
1274        let file = temp.path().join("file.rs");
1275        std::fs::write(&file, "needle").expect("write");
1276        let symlink = temp.path().join("link.rs");
1277        #[cfg(unix)]
1278        std::os::unix::fs::symlink(&file, &symlink).expect("symlink");
1279        #[cfg(windows)]
1280        std::os::windows::fs::symlink_file(&file, &symlink).expect("symlink");
1281
1282        let merged = merge_grep_results(
1283            vec![
1284                result(vec![grep_match(&file, 1, 1)], false, IndexStatus::Ready),
1285                result(vec![grep_match(&symlink, 1, 1)], false, IndexStatus::Ready),
1286            ],
1287            temp.path(),
1288            10,
1289        );
1290
1291        assert_eq!(merged.matches.len(), 1);
1292    }
1293
1294    #[test]
1295    fn merge_truncated_when_child_truncated_or_pre_merge_exceeds_max() {
1296        let root = Path::new("/project");
1297        let child = merge_grep_results(
1298            vec![result(
1299                vec![grep_match(Path::new("/project/a.rs"), 1, 1)],
1300                true,
1301                IndexStatus::Ready,
1302            )],
1303            root,
1304            10,
1305        );
1306        assert!(child.truncated);
1307
1308        let many = merge_grep_results(
1309            vec![
1310                result(
1311                    vec![grep_match(Path::new("/project/a.rs"), 1, 1)],
1312                    false,
1313                    IndexStatus::Ready,
1314                ),
1315                result(
1316                    vec![grep_match(Path::new("/project/b.rs"), 1, 1)],
1317                    false,
1318                    IndexStatus::Ready,
1319                ),
1320            ],
1321            root,
1322            1,
1323        );
1324        assert!(many.truncated);
1325    }
1326
1327    #[test]
1328    fn line_details_floors_offset_inside_multibyte_char() {
1329        let content = "before—after";
1330        let starts = line_starts(content);
1331        let dash_byte = content.find('—').expect("em dash");
1332        let mid_byte = dash_byte + 1;
1333        assert!(!content.is_char_boundary(mid_byte));
1334        let (line, column, line_text) = line_details(content, &starts, mid_byte);
1335        assert_eq!(line, 1);
1336        assert_eq!(column, content[..dash_byte].chars().count() as u32 + 1);
1337        assert!(line_text.contains('—'));
1338    }
1339
1340    #[test]
1341    fn line_details_clamps_offset_past_end() {
1342        let content = "short";
1343        let starts = line_starts(content);
1344        let (line, column, _) = line_details(content, &starts, content.len() + 100);
1345        assert_eq!(line, 1);
1346        assert_eq!(column, 6);
1347    }
1348
1349    #[test]
1350    fn truncate_at_char_boundary_floors_mid_multibyte_at_byte_cap() {
1351        let mut prefix = "a".repeat(38);
1352        prefix.push('—');
1353        prefix.push_str("tail");
1354        assert_eq!(prefix.len(), 45);
1355        assert!(!prefix.is_char_boundary(40));
1356        let truncated = truncate_at_char_boundary(&prefix, 40);
1357        assert!(truncated.is_char_boundary(truncated.len()));
1358        assert!(truncated.ends_with('a'));
1359        assert!(!truncated.contains('—'));
1360    }
1361
1362    #[test]
1363    fn regex_byte_match_start_mid_char_does_not_panic_in_line_details() {
1364        use crate::pattern_compile::{CompileOpts, CompileResult};
1365
1366        let content = "xy—zz";
1367        let starts = line_starts(content);
1368        let compiled = match crate::pattern_compile::compile(
1369            ".",
1370            CompileOpts {
1371                multi_line: false,
1372                ..CompileOpts::default()
1373            },
1374        ) {
1375            CompileResult::Ok(compiled) => compiled,
1376            other => panic!("expected compiled pattern, got {other:?}"),
1377        };
1378        let crate::pattern_compile::CompiledPattern::Regex { compiled, .. } = compiled else {
1379            panic!("expected regex pattern");
1380        };
1381        for matched in compiled.find_iter(content.as_bytes()) {
1382            let _ = line_details(content, &starts, matched.start());
1383        }
1384    }
1385
1386    fn compiled_regex(pattern: &str) -> CompiledPattern {
1387        match crate::pattern_compile::compile(
1388            pattern,
1389            crate::pattern_compile::CompileOpts::default(),
1390        ) {
1391            crate::pattern_compile::CompileResult::Ok(compiled) => compiled,
1392            other => panic!("compile regex {pattern:?}: {other:?}"),
1393        }
1394    }
1395
1396    fn grep_result_bytes(result: &GrepResult) -> Vec<u8> {
1397        serde_json::to_vec(&serde_json::json!({
1398            "matches": result.matches.iter().map(|matched| serde_json::json!({
1399                "file": matched.file,
1400                "line": matched.line,
1401                "column": matched.column,
1402                "line_text": matched.line_text,
1403                "match_text": matched.match_text,
1404            })).collect::<Vec<_>>(),
1405            "total_matches": result.total_matches,
1406            "files_searched": result.files_searched,
1407            "files_with_matches": result.files_with_matches,
1408            "index_status": result.index_status.as_str(),
1409            "truncated": result.truncated,
1410            "fully_degraded": result.fully_degraded,
1411            "engine_capped": result.engine_capped,
1412            "walk_truncated": result.walk_truncated,
1413        }))
1414        .expect("serialize grep result projection")
1415    }
1416
1417    #[test]
1418    fn multi_root_shared_query_matches_per_root_query_bytes() {
1419        let project = tempfile::tempdir().expect("project");
1420        let root_names = ["api", "cli", "daemon", "worker"];
1421        let roots = root_names
1422            .iter()
1423            .map(|name| {
1424                let root = project.path().join(name);
1425                std::fs::create_dir_all(&root).expect("create root");
1426                std::fs::write(
1427                    root.join("service.rs"),
1428                    "fn needle_alpha_12() {}\nfn needle_beta_34() {}\n",
1429                )
1430                .expect("write fixture");
1431                std::fs::canonicalize(root).expect("canonicalize root")
1432            })
1433            .collect::<Vec<_>>();
1434        let pattern = compiled_regex(r"needle_(?:alpha|beta)_\d+");
1435        let filters = PathFilters::default();
1436        let params = GrepParams {
1437            include: Vec::new(),
1438            exclude: Vec::new(),
1439            max_results: 100,
1440            path_exclusion: None,
1441        };
1442        let scope = GrepScope {
1443            roots: roots
1444                .iter()
1445                .map(|root| ResolvedRoot {
1446                    search_root: root.clone(),
1447                    filter_root: project.path().to_path_buf(),
1448                    use_index: true,
1449                    is_external: false,
1450                })
1451                .collect(),
1452            multi_root: true,
1453            per_root_max: 200,
1454        };
1455        let index =
1456            crate::search_index::SearchIndex::build_with_limit_serial(project.path(), 1_048_576);
1457        let snapshot = index.snapshot();
1458        let expected = merge_grep_results(
1459            scope
1460                .roots
1461                .iter()
1462                .map(|root| {
1463                    snapshot
1464                        .search_grep_profiled_with_filters(
1465                            &pattern,
1466                            &filters,
1467                            &root.search_root,
1468                            scope.per_root_max,
1469                            None,
1470                        )
1471                        .0
1472                })
1473                .collect(),
1474            project.path(),
1475            params.max_results,
1476        );
1477        let ctx = AppContext::new(
1478            Box::new(crate::parser::TreeSitterProvider::new()),
1479            crate::config::Config {
1480                project_root: Some(project.path().to_path_buf()),
1481                ..crate::config::Config::default()
1482            },
1483        );
1484        *ctx.search_index().write().expect("lock search index") = Some(index);
1485
1486        let (actual, phases) =
1487            execute_profiled_with_filters(&ctx, &pattern, &scope, &params, &filters);
1488
1489        assert_eq!(
1490            grep_result_bytes(&actual),
1491            grep_result_bytes(&expected),
1492            "shared query search must preserve the legacy per-root result bytes"
1493        );
1494        assert!(
1495            !phases.query_decomposition.is_zero(),
1496            "the indexed request must record its one-time query decomposition"
1497        );
1498    }
1499
1500    /// Manual release-mode probe for a warm indexed TypeScript corpus searched across four roots.
1501    #[test]
1502    #[ignore = "manual release-mode issue #219 multi-root query performance probe"]
1503    fn issue_219_multi_root_query_reuse_perf_probe() {
1504        const ROOTS: usize = 4;
1505        const FILES_PER_ROOT: usize = 1_000;
1506        const SAMPLES: usize = 9;
1507        const ITERATIONS: usize = 300;
1508
1509        let project_root = PathBuf::from("/tmp/aft-issue-219-multi-root");
1510        let mut index = crate::search_index::SearchIndex::new();
1511        let roots = (0..ROOTS)
1512            .map(|root_index| project_root.join(format!("packages/root-{root_index}")))
1513            .collect::<Vec<_>>();
1514        for root in &roots {
1515            for file_index in 0..FILES_PER_ROOT {
1516                index.index_file(
1517                    &root.join(format!("src/module-{file_index:04}.ts")),
1518                    b"export const indexed_value = 'warm corpus';\n",
1519                );
1520            }
1521        }
1522        let snapshot = index.snapshot();
1523        let pattern =
1524            compiled_regex(r"(?:(?:parse|format|validate)_[A-Za-z0-9_]+_)?issue_219_never_present");
1525        let filters = PathFilters::default();
1526
1527        let per_root_once = || {
1528            for root in &roots {
1529                let result = snapshot
1530                    .search_grep_profiled_with_filters(&pattern, &filters, root, 100, None)
1531                    .0;
1532                std::hint::black_box(result.total_matches);
1533            }
1534        };
1535        let shared_query_once = || {
1536            let query = decompose_grep_pattern(&pattern);
1537            for root in &roots {
1538                let result = snapshot
1539                    .search_grep_profiled_with_filters_and_query(
1540                        &pattern, &query, &filters, root, 100, None,
1541                    )
1542                    .0;
1543                std::hint::black_box(result.total_matches);
1544            }
1545        };
1546
1547        let mut per_root_ns = Vec::with_capacity(SAMPLES);
1548        let mut shared_query_ns = Vec::with_capacity(SAMPLES);
1549        for sample in 0..SAMPLES {
1550            let measure = |operation: &dyn Fn()| {
1551                let started = Instant::now();
1552                for _ in 0..ITERATIONS {
1553                    operation();
1554                }
1555                started.elapsed().as_nanos() / ITERATIONS as u128
1556            };
1557            if sample % 2 == 0 {
1558                per_root_ns.push(measure(&per_root_once));
1559                shared_query_ns.push(measure(&shared_query_once));
1560            } else {
1561                shared_query_ns.push(measure(&shared_query_once));
1562                per_root_ns.push(measure(&per_root_once));
1563            }
1564        }
1565        per_root_ns.sort_unstable();
1566        shared_query_ns.sort_unstable();
1567        let per_root_median = per_root_ns[SAMPLES / 2];
1568        let shared_query_median = shared_query_ns[SAMPLES / 2];
1569        let speedup = per_root_median as f64 / shared_query_median as f64;
1570
1571        eprintln!(
1572            "issue #219 multi-root regex query: roots={ROOTS} files_per_root={FILES_PER_ROOT} samples={SAMPLES} iterations={ITERATIONS}"
1573        );
1574        eprintln!("per-root decomposition ns/op samples: {per_root_ns:?}");
1575        eprintln!("shared decomposition ns/op samples: {shared_query_ns:?}");
1576        eprintln!(
1577            "median: per-root={per_root_median}ns shared={shared_query_median}ns speedup={speedup:.2}x"
1578        );
1579    }
1580}