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