Skip to main content

lean_ctx/tools/
ctx_search.rs

1use std::collections::HashSet;
2use std::path::Path;
3use std::path::PathBuf;
4use std::time::{Duration, Instant};
5
6use glob::Pattern;
7use ignore::WalkBuilder;
8use regex::RegexBuilder;
9
10use crate::core::protocol;
11use crate::core::symbol_map::{self, SymbolMap};
12use crate::core::tokens::count_tokens;
13use crate::tools::CrpMode;
14
15pub(crate) const MAX_FILE_SIZE: u64 = 512_000;
16pub(crate) const MAX_WALK_DEPTH: usize = 20;
17const MAX_MATCH_LINE_WIDTH: usize = 150;
18
19/// Modeled baseline for the *estimated* savings series (GL #479 D1): a native
20/// agent grep tool ships matches with surrounding context lines, per-file
21/// headers and line numbers, which is roughly 2.5x the tokens of the bare
22/// match lines lean-ctx observes. This factor is a documented model
23/// assumption — it feeds `stats.json` ("estimated") only. The signed savings
24/// ledger ("verified") records `observed_tokens` without any factor applied.
25pub const NATIVE_GREP_BASELINE_FACTOR: f64 = 2.5;
26
27/// Result of a search: the rendered output plus both baseline figures.
28pub struct SearchOutcome {
29    /// Rendered, compressed search output.
30    pub text: String,
31    /// Modeled native-tool baseline (`observed_tokens` x [`NATIVE_GREP_BASELINE_FACTOR`]).
32    /// Feeds the estimated stats series.
33    pub modeled_baseline: usize,
34    /// Tokens actually measured in the raw match lines — no model applied.
35    /// Feeds the verified savings ledger.
36    pub observed_tokens: usize,
37}
38
39impl SearchOutcome {
40    fn error(text: String) -> Self {
41        Self {
42            text,
43            modeled_baseline: 0,
44            observed_tokens: 0,
45        }
46    }
47
48    fn from_observed(text: String, observed_tokens: usize) -> Self {
49        let modeled = (observed_tokens as f64 * NATIVE_GREP_BASELINE_FACTOR).ceil() as usize;
50        Self {
51            text,
52            modeled_baseline: modeled.max(observed_tokens),
53            observed_tokens,
54        }
55    }
56}
57
58/// Wall-clock budget for a single `ctx_search` call. The regular-file guard in
59/// the read loop removes the known infinite block — `read_to_string` on a
60/// FIFO/socket/device (#336) — while this deadline is the backstop for any
61/// *other* pathological case (a gigantic corpus, a stuck network mount): the
62/// tool returns partial results with a hint instead of appearing to hang.
63/// Tunable via `LEAN_CTX_SEARCH_DEADLINE_MS` (`0` disables). Default 10s.
64fn search_deadline() -> Option<Duration> {
65    const DEFAULT_MS: u64 = 10_000;
66    let ms = std::env::var("LEAN_CTX_SEARCH_DEADLINE_MS")
67        .ok()
68        .and_then(|v| v.trim().parse::<u64>().ok())
69        .unwrap_or(DEFAULT_MS);
70    (ms > 0).then(|| Duration::from_millis(ms))
71}
72
73/// Back-compat shim: the pre-#870 8-arg entry point. Delegates to
74/// [`handle_filtered`] with no exclude filters. New callers pass the two
75/// `exclude` args directly.
76#[allow(clippy::too_many_arguments)]
77pub fn handle(
78    pattern: &str,
79    dir: &str,
80    include: Option<&str>,
81    max_results: usize,
82    crp_mode: CrpMode,
83    respect_gitignore: bool,
84    allow_secret_paths: bool,
85    anchored: bool,
86) -> SearchOutcome {
87    handle_filtered(
88        pattern,
89        dir,
90        include,
91        max_results,
92        crp_mode,
93        respect_gitignore,
94        allow_secret_paths,
95        anchored,
96        None,
97        None,
98    )
99}
100
101/// Searches files for a regex pattern with compressed output and monorepo scope hints.
102///
103/// `anchored` (opt-in, #1008) appends a `:hh` line-hash to every match
104/// (`path:line:hh content`) so a hit can be edited directly with `ctx_patch`
105/// without a separate `ctx_read(mode="anchored")`. Default output is byte-for-byte
106/// unchanged (#498).
107///
108/// `exclude` (path glob, complement of `include`) and `exclude_pattern` (regex
109/// on result lines, like `grep -v`) are the negative filters added in #870.
110#[allow(clippy::too_many_arguments)]
111pub fn handle_filtered(
112    pattern: &str,
113    dir: &str,
114    include: Option<&str>,
115    max_results: usize,
116    _crp_mode: CrpMode,
117    respect_gitignore: bool,
118    allow_secret_paths: bool,
119    anchored: bool,
120    exclude: Option<&str>,
121    exclude_pattern: Option<&str>,
122) -> SearchOutcome {
123    // `include` is a glob matched against each file's path *relative to* `dir`
124    // (e.g. `*.ts`, `*.{rs,ts}`, `src/**/*.tsx`). Bare globs without `/` match
125    // at any directory depth (like `rg --glob`), so `*.ts` finds `a/b.ts` too.
126    // Brace alternation is expanded here because the `glob` crate has no native
127    // support for it. An empty result (no `include`, or only unparsable globs)
128    // means "no filter", so a typo never silently drops every match.
129    let include_patterns = compile_include(include);
130    // `exclude` is the negative complement of `include`: a file matching any
131    // exclude glob is dropped, even when it also matches `include` (#870).
132    let exclude_patterns = compile_include(exclude);
133    const MAX_PATTERN_LEN: usize = 1024;
134    const MAX_REGEX_SIZE: usize = 1 << 20; // 1 MiB DFA limit
135
136    let redact = crate::core::redaction::redaction_enabled_for_active_role();
137    if pattern.len() > MAX_PATTERN_LEN {
138        return SearchOutcome::error(format!(
139            "ERROR: pattern too long ({} > {MAX_PATTERN_LEN} chars)",
140            pattern.len()
141        ));
142    }
143    let re = match RegexBuilder::new(pattern)
144        .size_limit(MAX_REGEX_SIZE)
145        .dfa_size_limit(MAX_REGEX_SIZE)
146        .build()
147    {
148        Ok(r) => r,
149        Err(e) => return SearchOutcome::error(format!("ERROR: invalid regex: {e}")),
150    };
151    // #870: `exclude_pattern` drops any *result line* matching this regex — the
152    // in-pipeline equivalent of `grep -v`. An unparsable pattern is ignored
153    // (no exclusion), so a typo never silently hides every match.
154    let exclude_re = exclude_pattern.and_then(|p| {
155        RegexBuilder::new(p)
156            .size_limit(MAX_REGEX_SIZE)
157            .dfa_size_limit(MAX_REGEX_SIZE)
158            .build()
159            .ok()
160    });
161
162    let root = Path::new(dir);
163    if !root.exists() {
164        return SearchOutcome::error(format!("ERROR: {dir} does not exist"));
165    }
166    // Broad-root guard (#356 class): with cwd == $HOME a defaulted `path`
167    // would walk the whole home dir and trip macOS TCC privacy prompts.
168    if let Some(err) = crate::tools::walk_guard::deny_unsafe_walk_root(dir) {
169        return SearchOutcome::error(err);
170    }
171
172    let mut files: Vec<PathBuf> = Vec::new();
173    let mut matches = Vec::new();
174    let mut raw_tokens_accum: usize = 0;
175    let mut files_searched = 0u32;
176    let mut files_skipped_size = 0u32;
177    let mut files_skipped_encoding = 0u32;
178    let mut files_skipped_boundary = 0u32;
179    let mut files_skipped_special = 0u32;
180    let mut deadline_hit = false;
181    // Set when any hit gained an `∈name@Lstart` enclosing tag, so the
182    // self-describing legend is emitted only when it is actually needed (#580).
183    let mut any_enclosing = false;
184
185    // Fast path: a warm resident trigram index narrows the candidate files in
186    // memory, eliminating the per-call directory walk + full-corpus read. The
187    // index covers the exact same file universe as the walk below, and matches
188    // are still verified line-by-line with the same regex — so results are
189    // identical. Missing/stale index → returns None and triggers a background
190    // (re)build; this call uses the walk fallback.
191    let used_index = if let Some(idx) =
192        crate::core::search_index::get_fresh(dir, respect_gitignore, allow_secret_paths)
193    {
194        files = idx
195            .candidate_paths(pattern, &include_patterns, root)
196            .into_paths();
197        true
198    } else {
199        false
200    };
201
202    if !used_index {
203        // Vendor dirs (node_modules, …) follow the gitignore toggle: explicitly
204        // disabling gitignore is the escape hatch to look inside them (#400).
205        let walker = WalkBuilder::new(root)
206            .hidden(true)
207            .max_depth(Some(MAX_WALK_DEPTH))
208            .git_ignore(respect_gitignore)
209            .git_global(respect_gitignore)
210            .git_exclude(respect_gitignore)
211            .require_git(false)
212            .filter_entry(move |e| {
213                if respect_gitignore {
214                    crate::core::walk_filter::keep_entry(e)
215                } else {
216                    crate::core::cloud_files::keep_entry(e)
217                }
218            })
219            .build();
220
221        for entry in walker.filter_map(std::result::Result::ok) {
222            if entry.file_type().is_none_or(|ft| ft.is_dir()) {
223                continue;
224            }
225
226            if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
227                continue;
228            }
229
230            let path = entry.path();
231
232            if is_binary_ext(path) || is_generated_file(path) {
233                continue;
234            }
235
236            if !allow_secret_paths && crate::core::io_boundary::is_secret_like(path).is_some() {
237                files_skipped_boundary += 1;
238                continue;
239            }
240
241            if !include_patterns.is_empty() {
242                let rel = path.strip_prefix(root).unwrap_or(path);
243                let rel_str = rel.to_string_lossy();
244                if !include_patterns.iter().any(|p| p.matches(&rel_str)) {
245                    continue;
246                }
247            }
248
249            // Size / regular-file filtering happens once in the shared read loop
250            // below, so the walk path and the trigram-index fast path apply the
251            // exact same eligibility rules.
252            files.push(path.to_path_buf());
253        }
254    }
255
256    // #870: drop excluded paths after both the index and walk populate `files`,
257    // so the negative glob filter applies uniformly to either collection path.
258    if !exclude_patterns.is_empty() {
259        files.retain(|path| {
260            let rel = path.strip_prefix(root).unwrap_or(path);
261            let rel_str = rel.to_string_lossy();
262            !exclude_patterns.iter().any(|p| p.matches(&rel_str))
263        });
264    }
265
266    // Deterministic search: stable file ordering makes max_results truncation reproducible.
267    files.sort_unstable_by(|a, b| a.as_os_str().cmp(b.as_os_str()));
268
269    let root_str = root.to_string_lossy();
270    let deadline = search_deadline().map(|budget| Instant::now() + budget);
271    for path in &files {
272        if matches.len() >= max_results {
273            break;
274        }
275
276        // Stop gracefully instead of appearing to hang on a pathological corpus
277        // or a stuck read (#336): once the wall-clock budget is spent, return
278        // the partial results gathered so far with a hint to narrow the search.
279        if deadline.is_some_and(|dl| Instant::now() >= dl) {
280            deadline_hit = true;
281            break;
282        }
283
284        // Only ever read regular files within the size budget. A FIFO, socket or
285        // device node would block `read_to_string` forever — the root cause of
286        // #336 — and oversized or unstatable files are skipped. `metadata`
287        // (stat) never opens the file, so it cannot block on a special file.
288        let state = match std::fs::metadata(path) {
289            Ok(meta) if !meta.file_type().is_file() => {
290                files_skipped_special += 1;
291                continue;
292            }
293            Ok(meta) if meta.len() > MAX_FILE_SIZE => {
294                files_skipped_size += 1;
295                continue;
296            }
297            Ok(meta) => crate::core::content_cache::FileState::from_metadata(&meta),
298            Err(_) => {
299                files_skipped_encoding += 1;
300                continue;
301            }
302        };
303
304        // Reuse the copy the trigram-index build already read (issue #148): the
305        // corpus is read from disk once and the regex-verify pass here is an
306        // in-memory hit. On a miss (cold cache / evicted) read once and publish
307        // it for the next caller. `(mtime, size)` validation guarantees we never
308        // verify against stale bytes.
309        let content: std::sync::Arc<str> =
310            if let Some(cached) = state.and_then(|s| crate::core::content_cache::get(path, s)) {
311                cached
312            } else {
313                let Ok(text) = std::fs::read_to_string(path) else {
314                    files_skipped_encoding += 1;
315                    continue;
316                };
317                let arc: std::sync::Arc<str> = std::sync::Arc::from(text);
318                if let Some(s) = state {
319                    crate::core::content_cache::insert(path, s, std::sync::Arc::clone(&arc));
320                }
321                arc
322            };
323
324        files_searched += 1;
325        // Enclosing-symbol spans for this file, computed lazily on the first hit
326        // (never for non-matching files) and reused for every later hit here.
327        let mut file_enclosing: Option<EnclosingIndex> = None;
328
329        for (i, line) in content.lines().enumerate() {
330            // #870: `exclude_pattern` filters out matching lines (grep -v).
331            if re.is_match(line) && !exclude_re.as_ref().is_some_and(|ex| ex.is_match(line)) {
332                let short_path =
333                    protocol::shorten_path_relative(&path.to_string_lossy(), &root_str);
334                // Count raw tokens incrementally (avoids separate Vec + join)
335                raw_tokens_accum += count_tokens(line.trim()) + 2;
336                let mut shown = if redact {
337                    crate::core::redaction::redact_text(line.trim())
338                } else {
339                    line.trim().to_string()
340                };
341                if shown.len() > MAX_MATCH_LINE_WIDTH {
342                    shown.truncate(shown.floor_char_boundary(MAX_MATCH_LINE_WIDTH));
343                    shown.push_str("...");
344                }
345                // grep-ast enrichment (#608): name the enclosing symbol + its
346                // handle anchor so the hit is actionable without a follow-up
347                // read. Appended after `shown`, so the `path:line content` prefix
348                // every caller/test relies on is untouched.
349                let tag = file_enclosing
350                    .get_or_insert_with(|| EnclosingIndex::for_file(path, content.as_ref()))
351                    .tag_for(i + 1);
352                if tag.is_some() {
353                    any_enclosing = true;
354                }
355                let tag = tag.unwrap_or_default();
356                // The anchor hash is over the RAW line (matching ctx_read/ctx_patch
357                // which both hash `content.lines()`), never the trimmed/truncated
358                // display text — otherwise ctx_patch would always see a mismatch.
359                if anchored {
360                    matches.push(format!(
361                        "{short_path}:{}:{} {}{}",
362                        i + 1,
363                        crate::core::anchor::line_hash(line),
364                        shown,
365                        tag
366                    ));
367                } else {
368                    matches.push(format!("{short_path}:{} {}{}", i + 1, shown, tag));
369                }
370                if matches.len() >= max_results {
371                    break;
372                }
373            }
374        }
375    }
376
377    // #883: sort matches by file match-density — files with more hits surface
378    // first, which is a proxy for relevance when the pattern contains common tokens.
379    if matches.len() > 1 {
380        use std::collections::HashMap;
381        let mut file_counts: HashMap<String, usize> = HashMap::new();
382        for m in &matches {
383            let file = extract_file_from_match(m).to_string();
384            *file_counts.entry(file).or_default() += 1;
385        }
386        matches.sort_by(|a, b| {
387            let fa = extract_file_from_match(a);
388            let fb = extract_file_from_match(b);
389            let ca = file_counts.get(fa).copied().unwrap_or(0);
390            let cb = file_counts.get(fb).copied().unwrap_or(0);
391            cb.cmp(&ca).then_with(|| fa.cmp(fb))
392        });
393    }
394
395    if matches.is_empty() {
396        let mut msg = format!("0 matches for '{pattern}' in {files_searched} files");
397        if files_skipped_size > 0 {
398            msg.push_str(&format!(" ({files_skipped_size} large files skipped)"));
399        }
400        if files_skipped_encoding > 0 {
401            msg.push_str(&format!(
402                " ({files_skipped_encoding} files skipped: binary/encoding)"
403            ));
404        }
405        if files_skipped_boundary > 0 {
406            msg.push_str(&format!(
407                " ({files_skipped_boundary} secret-like files skipped by boundary policy)"
408            ));
409        }
410        if files_skipped_special > 0 {
411            msg.push_str(&format!(
412                " ({files_skipped_special} special files skipped: not regular files)"
413            ));
414        }
415        if deadline_hit {
416            msg.push_str(
417                " (search stopped at the time budget — refine the pattern or scope with path=)",
418            );
419        }
420        return SearchOutcome::error(msg);
421    }
422
423    // Prefix-cache-friendly: structural file list before per-query match content
424    let matched_files: Vec<&str> = {
425        let mut seen = HashSet::new();
426        matches
427            .iter()
428            .filter_map(|m| {
429                let file = extract_file_from_match(m);
430                if seen.insert(file) { Some(file) } else { None }
431            })
432            .collect()
433    };
434
435    let mut result = format!("{} matches in {} files", matches.len(), files_searched);
436    if matched_files.len() > 1 {
437        if matched_files.len() <= 10 {
438            result.push_str(" [");
439            result.push_str(&matched_files.join(", "));
440            result.push(']');
441        } else {
442            let shown: Vec<&str> = matched_files.iter().take(8).copied().collect();
443            result.push_str(&format!(
444                " [{}, +{} more]",
445                shown.join(", "),
446                matched_files.len() - 8
447            ));
448        }
449    }
450    result.push_str(":\n");
451    // Self-describing output (GL #580): the anchor notation ships its own legend.
452    if anchored {
453        result.push_str("[anchored: path:line:hh → edit via ctx_patch]\n");
454    }
455    // Self-describing output (GL #580 / #608): explain the `∈` enclosing tag and
456    // how to turn it into a handle. Emitted only when at least one hit carries it.
457    if any_enclosing {
458        result.push_str(
459            "[∈ enclosing symbol → ctx_search(action=symbol, handle=\"path#name@Lstart\")]\n",
460        );
461    }
462    result.push_str(&matches.join("\n"));
463
464    if files_skipped_size > 0 {
465        result.push_str(&format!("\n({files_skipped_size} files >512KB skipped)"));
466    }
467    if files_skipped_encoding > 0 {
468        result.push_str(&format!(
469            "\n({files_skipped_encoding} files skipped: binary/encoding)"
470        ));
471    }
472    if files_skipped_boundary > 0 {
473        result.push_str(&format!(
474            "\n({files_skipped_boundary} secret-like files skipped by boundary policy)"
475        ));
476    }
477    if files_skipped_special > 0 {
478        result.push_str(&format!(
479            "\n({files_skipped_special} special files skipped: not regular files)"
480        ));
481    }
482    if deadline_hit {
483        result.push_str(&format!(
484            "\n(search stopped after the {}s budget — {files_searched} files scanned; \
485             refine the pattern or scope with path= for full coverage)",
486            search_deadline().map_or(0, |d| d.as_secs())
487        ));
488    }
489
490    // Determinism contract (#498): the hint must be a pure function of the
491    // results. A show-once AtomicBool here made the first call differ from
492    // every repeat, breaking byte-stability for provider prompt caches.
493    let scope_hint = monorepo_scope_hint(&matches, dir);
494
495    if let Some(delta) = crate::core::search_delta::compute_delta(pattern, &matches) {
496        return SearchOutcome::from_observed(delta, raw_tokens_accum);
497    }
498
499    if symbol_map::substitution_enabled() {
500        let exts = extract_extensions(include);
501        let ext_refs: Vec<&str> = exts.iter().map(String::as_str).collect();
502        let mut sym = SymbolMap::new();
503        let idents = symbol_map::extract_identifiers(&result, &ext_refs);
504        for ident in &idents {
505            sym.register(ident);
506        }
507        if sym.len() >= 3 {
508            let sym_table = sym.format_table();
509            let compressed = sym.apply(&result);
510            let original_tok = count_tokens(&result);
511            let compressed_tok = count_tokens(&compressed) + count_tokens(&sym_table);
512            let net_saving = original_tok.saturating_sub(compressed_tok);
513            if original_tok > 0 && net_saving * 100 / original_tok >= 5 {
514                result = format!("{compressed}{sym_table}");
515            }
516        }
517    }
518
519    if let Some(hint) = scope_hint {
520        result.push_str(&hint);
521    }
522
523    SearchOutcome::from_observed(result, raw_tokens_accum)
524}
525
526pub(crate) fn is_binary_ext(path: &Path) -> bool {
527    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
528    matches!(
529        ext,
530        "png"
531            | "jpg"
532            | "jpeg"
533            | "gif"
534            | "webp"
535            | "ico"
536            | "svg"
537            | "woff"
538            | "woff2"
539            | "ttf"
540            | "eot"
541            | "pdf"
542            | "zip"
543            | "tar"
544            | "gz"
545            | "br"
546            | "zst"
547            | "bz2"
548            | "xz"
549            | "mp3"
550            | "mp4"
551            | "webm"
552            | "ogg"
553            | "wasm"
554            | "so"
555            | "dylib"
556            | "dll"
557            | "exe"
558            | "lock"
559            | "map"
560            | "snap"
561            | "patch"
562            | "db"
563            | "sqlite"
564            | "parquet"
565            | "arrow"
566            | "bin"
567            | "o"
568            | "a"
569            | "class"
570            | "pyc"
571            | "pyo"
572    )
573}
574
575pub(crate) fn is_generated_file(path: &Path) -> bool {
576    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
577    name.ends_with(".min.js")
578        || name.ends_with(".min.css")
579        || name.ends_with(".bundle.js")
580        || name.ends_with(".chunk.js")
581        || name.ends_with(".d.ts")
582        || name.ends_with(".js.map")
583        || name.ends_with(".css.map")
584}
585
586/// Per-file map from a line number to its narrowest enclosing symbol, built
587/// once per matched file from the signature spans. Powers the `∈name@Lstart`
588/// tag on each `ctx_search` hit (grep-ast pattern): the agent sees which
589/// function/class a match lives in — and the handle to fetch it — without a
590/// follow-up read. Tree-sitter-gated by construction: the regex-fallback
591/// extractor yields single-line spans (`end == start`), which are filtered out
592/// here, so a non-tree-sitter build emits byte-identical output (#498).
593struct EnclosingIndex {
594    /// `(start_line, end_line, name)` for multi-line symbols, sorted by start.
595    spans: Vec<(usize, usize, String)>,
596}
597
598impl EnclosingIndex {
599    fn for_file(path: &Path, content: &str) -> Self {
600        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
601        let mut spans: Vec<(usize, usize, String)> =
602            crate::core::signatures::extract_signatures(content, ext)
603                .into_iter()
604                .filter_map(|s| match (s.start_line, s.end_line) {
605                    (Some(a), Some(b)) if b > a => Some((a, b, s.name)),
606                    _ => None,
607                })
608                .collect();
609        spans.sort_by(|x, y| x.0.cmp(&y.0).then(x.1.cmp(&y.1)));
610        Self { spans }
611    }
612
613    /// The narrowest span containing `line`, rendered as the compact
614    /// ` ∈name@Lstart` tag, or `None` when no multi-line symbol encloses it.
615    fn tag_for(&self, line: usize) -> Option<String> {
616        let mut best: Option<&(usize, usize, String)> = None;
617        for sp in &self.spans {
618            if line >= sp.0 && line <= sp.1 {
619                match best {
620                    None => best = Some(sp),
621                    Some(b) if (sp.1 - sp.0) < (b.1 - b.0) => best = Some(sp),
622                    _ => {}
623                }
624            }
625        }
626        best.map(|(start, _, name)| format!(" ∈{name}@L{start}"))
627    }
628}
629
630/// Upper bound on the number of globs a single `include` may expand to, so a
631/// pathological brace pattern (`{a,b}{c,d}{e,f}…`) can never blow up.
632const MAX_INCLUDE_GLOBS: usize = 64;
633
634/// Compile an `include` filter into one or more matchers.
635///
636/// Brace alternation (`*.{rs,ts}`) is expanded to multiple globs (`*.rs`,
637/// `*.ts`) because the `glob` crate matches `{` / `}` literally. A file is
638/// included when it matches *any* of the returned patterns. An empty vec means
639/// "no filter": `include` was `None`, or every expansion failed to parse.
640///
641/// Bare globs without a `/` (e.g. `pathjail.rs`, `*.rs`) are auto-prefixed
642/// with `**/` to match at any directory depth — matching `rg --glob` and
643/// `git grep` behaviour. Globs that already contain `/` are used as-is, so
644/// `src/**/*.rs` only matches under `src/`.
645fn compile_include(include: Option<&str>) -> Vec<Pattern> {
646    let Some(raw) = include else {
647        return Vec::new();
648    };
649    expand_braces(raw)
650        .into_iter()
651        .take(MAX_INCLUDE_GLOBS)
652        .filter(|g| !g.is_empty())
653        .map(|g| {
654            if g.contains('/') {
655                g
656            } else {
657                format!("**/{g}")
658            }
659        })
660        .filter_map(|g| Pattern::new(&g).ok())
661        .collect()
662}
663
664/// Expand one or more `{a,b,c}` brace groups into the cartesian set of concrete
665/// globs. Patterns without braces (or with an unbalanced brace) are returned
666/// unchanged, so this is safe to call on any input.
667fn expand_braces(pattern: &str) -> Vec<String> {
668    let Some(open) = pattern.find('{') else {
669        return vec![pattern.to_string()];
670    };
671    let Some(close_rel) = pattern[open..].find('}') else {
672        return vec![pattern.to_string()];
673    };
674    let close = open + close_rel;
675    let prefix = &pattern[..open];
676    let inner = &pattern[open + 1..close];
677    let suffix = &pattern[close + 1..];
678
679    let mut out = Vec::new();
680    for alt in inner.split(',') {
681        let alt = alt.trim();
682        for expanded_suffix in expand_braces(suffix) {
683            out.push(format!("{prefix}{alt}{expanded_suffix}"));
684            if out.len() >= MAX_INCLUDE_GLOBS {
685                return out;
686            }
687        }
688    }
689    out
690}
691
692/// Extract the file extensions referenced by an `include` glob, used by the
693/// symbol-substitution pass (which keyword-filters per language).
694///
695/// Only the final path component is inspected, so dots inside directory
696/// segments never leak in. Handles a single trailing extension (`*.rs` → `rs`)
697/// and brace expansion (`*.{rs,ts}` → `rs`, `ts`); a glob without an extension
698/// (`src/**/*`) yields an empty list. Unknown extensions are returned verbatim —
699/// `symbol_map::is_keyword` simply treats them as "no keywords", so no allowlist
700/// has to be kept in sync here.
701fn extract_extensions(include: Option<&str>) -> Vec<String> {
702    let Some(pattern) = include else {
703        return Vec::new();
704    };
705    let filename = pattern.rsplit('/').next().unwrap_or(pattern);
706    let Some(dot) = filename.rfind('.') else {
707        return Vec::new();
708    };
709    let ext_part = &filename[dot + 1..];
710
711    if let Some(inner) = ext_part.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
712        return inner
713            .split(',')
714            .map(|e| e.trim().to_string())
715            .filter(|e| !e.is_empty())
716            .collect();
717    }
718
719    if ext_part.is_empty() {
720        return Vec::new();
721    }
722    vec![ext_part.to_string()]
723}
724
725/// Extract file path from a grep match line, handling Windows drive letters (e.g. "C:").
726fn extract_file_from_match(line: &str) -> &str {
727    let start = if line.len() >= 2
728        && line.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
729        && line.as_bytes().get(1) == Some(&b':')
730    {
731        2
732    } else {
733        0
734    };
735    match line[start..].find(':') {
736        Some(pos) => &line[..start + pos],
737        None => line,
738    }
739}
740
741fn monorepo_scope_hint(matches: &[String], search_dir: &str) -> Option<String> {
742    let top_dirs: HashSet<&str> = matches
743        .iter()
744        .filter_map(|m| {
745            let path = extract_file_from_match(m);
746            let relative = path.strip_prefix("./").unwrap_or(path);
747            let relative = relative.strip_prefix(search_dir).unwrap_or(relative);
748            let relative = relative.strip_prefix('/').unwrap_or(relative);
749            relative.split('/').next()
750        })
751        .collect();
752
753    if top_dirs.len() > 3 {
754        let mut dirs: Vec<&&str> = top_dirs.iter().collect();
755        dirs.sort();
756        let dir_list: Vec<String> = dirs.iter().take(6).map(|d| format!("'{d}'")).collect();
757        let extra = if top_dirs.len() > 6 {
758            format!(", +{} more", top_dirs.len() - 6)
759        } else {
760            String::new()
761        };
762        Some(format!(
763            "\n\nResults span {} directories ({}{}). \
764             Use the 'path' parameter to scope to a specific service, \
765             e.g. path=\"{}/\".",
766            top_dirs.len(),
767            dir_list.join(", "),
768            extra,
769            dirs[0]
770        ))
771    } else {
772        None
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use crate::tools::CrpMode;
780
781    /// Determinism contract (#498): identical search over identical files
782    /// must produce byte-identical output — a prerequisite for provider
783    /// prompt-cache hits on repeated tool results.
784    #[test]
785    fn search_output_is_byte_stable_across_calls() {
786        let dir = tempfile::tempdir().unwrap();
787        for i in 0..5 {
788            std::fs::write(
789                dir.path().join(format!("f{i}.rs")),
790                format!("fn target_{i}() {{}}\nfn other() {{}}\n"),
791            )
792            .unwrap();
793        }
794        let root = dir.path().to_string_lossy().into_owned();
795        let run = || {
796            handle(
797                "target",
798                &root,
799                Some("*.rs"),
800                20,
801                CrpMode::Off,
802                true,
803                true,
804                false,
805            )
806            .text
807        };
808        assert_eq!(run(), run(), "search output must be deterministic");
809    }
810
811    /// #1008: opt-in anchored search tags each hit with `:hh` (matching
812    /// `ctx_read`/`ctx_patch`'s line hash) and ships a legend; the default
813    /// (anchored=false) output stays byte-identical so #498 is preserved.
814    #[test]
815    fn anchored_search_emits_line_hash_per_hit_opt_in_only() {
816        let dir = tempfile::tempdir().unwrap();
817        std::fs::write(dir.path().join("a.rs"), "let needle = 1;\nother\n").unwrap();
818        let root = dir.path().to_string_lossy().into_owned();
819
820        let plain = handle(
821            "needle",
822            &root,
823            Some("*.rs"),
824            10,
825            CrpMode::Off,
826            true,
827            true,
828            false,
829        )
830        .text;
831        assert!(
832            !plain.contains("[anchored:"),
833            "default must carry no legend"
834        );
835        assert!(
836            plain.contains("a.rs:1 "),
837            "default keeps path:line content: {plain}"
838        );
839
840        let anchored = handle(
841            "needle",
842            &root,
843            Some("*.rs"),
844            10,
845            CrpMode::Off,
846            true,
847            true,
848            true,
849        )
850        .text;
851        let hh = crate::core::anchor::line_hash("let needle = 1;");
852        assert!(anchored.contains("[anchored: path:line:hh → edit via ctx_patch]"));
853        assert!(
854            anchored.contains(&format!("a.rs:1:{hh} ")),
855            "anchored hit must carry the line hash: {anchored}"
856        );
857    }
858
859    #[test]
860    #[cfg(feature = "tree-sitter")]
861    fn hits_inside_multiline_symbols_carry_enclosing_tag() {
862        // #608: a hit inside a multi-line function names its enclosing symbol +
863        // the handle anchor, and the output ships a self-describing legend.
864        let dir = tempfile::tempdir().unwrap();
865        std::fs::write(
866            dir.path().join("a.rs"),
867            "fn outer() {\n    let needle = 1;\n    needle\n}\nfn tiny() {}\n",
868        )
869        .unwrap();
870        let out = handle(
871            "needle",
872            dir.path().to_string_lossy().as_ref(),
873            Some("*.rs"),
874            10,
875            CrpMode::Off,
876            true,
877            true,
878            false,
879        )
880        .text;
881        assert!(
882            out.contains("∈outer@L1"),
883            "hit must name its enclosing fn: {out}"
884        );
885        assert!(
886            out.contains("[∈ enclosing symbol"),
887            "self-describing legend must be present: {out}"
888        );
889    }
890
891    #[test]
892    fn single_line_symbols_get_no_enclosing_tag() {
893        // #498/#608: a match whose only enclosing symbol is single-line gets no
894        // tag, so the default output stays byte-identical (no `∈`, no legend).
895        let dir = tempfile::tempdir().unwrap();
896        std::fs::write(dir.path().join("a.rs"), "fn one_liner() {}\n").unwrap();
897        let out = handle(
898            "one_liner",
899            dir.path().to_string_lossy().as_ref(),
900            Some("*.rs"),
901            10,
902            CrpMode::Off,
903            true,
904            true,
905            false,
906        )
907        .text;
908        assert!(!out.contains('∈'), "single-line symbol → no tag: {out}");
909    }
910
911    #[test]
912    fn search_results_are_deterministically_ordered_by_path() {
913        let dir = tempfile::tempdir().unwrap();
914        let a = dir.path().join("a.txt");
915        let b = dir.path().join("b.txt");
916        std::fs::write(&b, "match\n").unwrap();
917        std::fs::write(&a, "match\n").unwrap();
918
919        let out = handle(
920            "match",
921            dir.path().to_string_lossy().as_ref(),
922            Some("*.txt"),
923            10,
924            CrpMode::Off,
925            true,
926            true,
927            false,
928        )
929        .text;
930
931        let mut match_lines: Vec<&str> = out
932            .lines()
933            .filter(|l| l.contains(".txt:") && l.contains("match"))
934            .collect();
935        // Expect exactly the 2 match lines, ordered a.txt then b.txt.
936        match_lines.truncate(2);
937        assert_eq!(match_lines.len(), 2);
938        assert!(
939            match_lines[0].contains("a.txt:"),
940            "first match should come from a.txt, got: {}",
941            match_lines[0]
942        );
943        assert!(
944            match_lines[1].contains("b.txt:"),
945            "second match should come from b.txt, got: {}",
946            match_lines[1]
947        );
948    }
949
950    #[test]
951    fn warm_index_and_content_cache_path_returns_correct_matches() {
952        // Exercises the trigram-index fast path together with the shared content
953        // cache (#148): the index build reads the corpus once and publishes it,
954        // then this search reuses those bytes. Results must be byte-identical to
955        // the walk path — this asserts that correctness, independent of whether
956        // any individual file is a cache hit or a fallback re-read.
957        let dir = tempfile::tempdir().unwrap();
958        std::fs::write(
959            dir.path().join("a.rs"),
960            "fn authenticate() {}\nlet x = 1;\n",
961        )
962        .unwrap();
963        std::fs::write(dir.path().join("b.rs"), "fn connect() {}\n").unwrap();
964        let root = dir.path().to_string_lossy().to_string();
965
966        // Synchronously warm the resident trigram index (also populates the
967        // shared content cache for these paths).
968        assert!(
969            crate::core::search_index::warm_blocking(&root, true, false),
970            "index should warm for a small clean corpus"
971        );
972
973        let out = handle(
974            "authenticate",
975            &root,
976            None,
977            10,
978            CrpMode::Off,
979            true,
980            false,
981            false,
982        )
983        .text;
984        assert!(
985            out.contains("a.rs"),
986            "warm-index + cache search must find the match: {out}"
987        );
988        assert!(
989            out.contains("authenticate"),
990            "the matched line must be present: {out}"
991        );
992        assert!(
993            !out.contains("b.rs"),
994            "a non-matching file must not appear in results: {out}"
995        );
996    }
997
998    #[test]
999    fn search_finds_word_literals_added_after_index_warm() {
1000        // #624 regression: a native edit or add after the trigram index was
1001        // warmed must be found immediately. The resident index is gated by a live
1002        // corpus signature, so stale trigrams can never hide on-disk content from
1003        // a word-literal query — the very path that uses trigram narrowing.
1004        let _lock = crate::core::data_dir::test_env_lock();
1005        crate::test_env::remove_var("LEAN_CTX_DISABLE_SEARCH_INDEX");
1006        crate::test_env::remove_var("LEAN_CTX_SEARCH_INDEX_COALESCE_MS");
1007
1008        let dir = tempfile::tempdir().unwrap();
1009        std::fs::write(dir.path().join("a.rs"), "fn existing() {}\n").unwrap();
1010        std::fs::write(dir.path().join("b.rs"), "fn other() {}\n").unwrap();
1011        let root = dir.path().to_string_lossy().to_string();
1012
1013        assert!(
1014            crate::core::search_index::warm_blocking(&root, true, false),
1015            "index should warm for a small clean corpus"
1016        );
1017
1018        // (1) A brand-new file created after the warm.
1019        std::fs::write(dir.path().join("c.rs"), "fn added_after_warm_zzz() {}\n").unwrap();
1020        let out_new = handle(
1021            "added_after_warm_zzz",
1022            &root,
1023            None,
1024            10,
1025            CrpMode::Off,
1026            true,
1027            false,
1028            false,
1029        )
1030        .text;
1031        assert!(
1032            out_new.contains("c.rs") && out_new.contains("added_after_warm_zzz"),
1033            "a file created after the index warm must be found: {out_new}"
1034        );
1035
1036        // (2) An in-place edit of a file that existed at warm time.
1037        std::fs::write(
1038            dir.path().join("a.rs"),
1039            "fn existing() {}\nfn edited_after_warm_zzz() {}\n",
1040        )
1041        .unwrap();
1042        let out_edit = handle(
1043            "edited_after_warm_zzz",
1044            &root,
1045            None,
1046            10,
1047            CrpMode::Off,
1048            true,
1049            false,
1050            false,
1051        )
1052        .text;
1053        assert!(
1054            out_edit.contains("a.rs") && out_edit.contains("edited_after_warm_zzz"),
1055            "content appended after the index warm must be found: {out_edit}"
1056        );
1057    }
1058
1059    #[test]
1060    fn symbol_substitution_is_off_by_default() {
1061        let _lock = crate::core::data_dir::test_env_lock();
1062        crate::test_env::remove_var("LEAN_CTX_SYMBOL_MAP");
1063        let dir = tempfile::tempdir().unwrap();
1064        let f = dir.path().join("a.rs");
1065        std::fs::write(
1066            &f,
1067            "fn longIdentifierAlpha() {}\nfn longIdentifierBeta() {}\nfn longIdentifierGamma() {}\n",
1068        )
1069        .unwrap();
1070
1071        let out = handle(
1072            "longIdentifier",
1073            dir.path().to_string_lossy().as_ref(),
1074            Some("*.rs"),
1075            10,
1076            CrpMode::Off,
1077            true,
1078            true,
1079            false,
1080        )
1081        .text;
1082
1083        assert!(
1084            !out.contains("§MAP"),
1085            "default agent-facing output must not carry a §MAP table: {out}"
1086        );
1087        assert!(
1088            !out.contains('α'),
1089            "default agent-facing output must not carry α-symbols: {out}"
1090        );
1091        assert!(
1092            out.contains("longIdentifierAlpha"),
1093            "identifiers should appear raw by default: {out}"
1094        );
1095    }
1096
1097    #[test]
1098    fn secret_like_files_are_skipped_by_default() {
1099        let dir = tempfile::tempdir().unwrap();
1100        let secret = dir.path().join("key.pem");
1101        let ok = dir.path().join("ok.txt");
1102        std::fs::write(&secret, "match\n").unwrap();
1103        std::fs::write(&ok, "match\n").unwrap();
1104
1105        let out = handle(
1106            "match",
1107            dir.path().to_string_lossy().as_ref(),
1108            None,
1109            10,
1110            CrpMode::Off,
1111            true,
1112            false,
1113            false,
1114        )
1115        .text;
1116
1117        assert!(out.contains("ok.txt:"), "expected ok.txt match, got: {out}");
1118        assert!(
1119            !out.contains("key.pem:"),
1120            "secret-like file should be skipped, got: {out}"
1121        );
1122        assert!(
1123            out.contains("secret-like files skipped"),
1124            "expected boundary skip note, got: {out}"
1125        );
1126    }
1127
1128    #[test]
1129    #[cfg(unix)]
1130    fn search_skips_named_pipe_without_hanging() {
1131        use std::sync::mpsc;
1132        // #336: a named pipe (FIFO) in the search universe used to block
1133        // `read_to_string` forever, hanging the whole call with no output. It
1134        // must be skipped, the real file still matched, and the call must return.
1135        let dir = tempfile::tempdir().unwrap();
1136        std::fs::write(dir.path().join("real.txt"), "needle_here = 1\n").unwrap();
1137        let fifo = dir.path().join("pipe.fifo");
1138        let c = std::ffi::CString::new(fifo.to_string_lossy().as_bytes()).unwrap();
1139        assert_eq!(
1140            // SAFETY: `c` is a live CString providing a valid NUL-terminated
1141            // path pointer for the duration of the call.
1142            unsafe { libc::mkfifo(c.as_ptr(), 0o644) },
1143            0,
1144            "mkfifo failed"
1145        );
1146
1147        let dir_path = dir.path().to_string_lossy().to_string();
1148        let (tx, rx) = mpsc::channel();
1149        std::thread::spawn(move || {
1150            // Fresh temp dir → no warm index yet, so this exercises the walk path.
1151            let out = handle(
1152                "needle_here",
1153                &dir_path,
1154                None,
1155                10,
1156                CrpMode::Off,
1157                true,
1158                true,
1159                false,
1160            )
1161            .text;
1162            let _ = tx.send(out);
1163        });
1164        let out = rx
1165            .recv_timeout(Duration::from_secs(5))
1166            .expect("ctx_search hung on a FIFO (#336 regression)");
1167
1168        assert!(
1169            out.contains("real.txt"),
1170            "the real file must still match: {out}"
1171        );
1172        assert!(
1173            out.contains("special files skipped"),
1174            "the FIFO must be reported as a skipped special file: {out}"
1175        );
1176    }
1177
1178    #[test]
1179    fn search_deadline_env_override_is_respected() {
1180        let _lock = crate::core::data_dir::test_env_lock();
1181        crate::test_env::set_var("LEAN_CTX_SEARCH_DEADLINE_MS", "0");
1182        assert!(search_deadline().is_none(), "0 must disable the deadline");
1183        crate::test_env::set_var("LEAN_CTX_SEARCH_DEADLINE_MS", "250");
1184        assert_eq!(search_deadline(), Some(Duration::from_millis(250)));
1185        crate::test_env::remove_var("LEAN_CTX_SEARCH_DEADLINE_MS");
1186        assert_eq!(
1187            search_deadline(),
1188            Some(Duration::from_secs(10)),
1189            "default budget is 10s"
1190        );
1191    }
1192
1193    #[test]
1194    fn extract_extensions_handles_single_brace_and_none() {
1195        assert_eq!(extract_extensions(Some("*.rs")), vec!["rs"]);
1196        assert_eq!(extract_extensions(Some("src/**/*.tsx")), vec!["tsx"]);
1197        assert_eq!(extract_extensions(Some("*.{rs,ts}")), vec!["rs", "ts"]);
1198        assert_eq!(
1199            extract_extensions(Some("*.{rs, ts , js}")),
1200            vec!["rs", "ts", "js"]
1201        );
1202        assert_eq!(extract_extensions(None), Vec::<String>::new());
1203    }
1204
1205    #[test]
1206    fn extract_extensions_ignores_dots_in_directory_segments() {
1207        // A dot in a directory name must not be mistaken for the extension.
1208        assert_eq!(
1209            extract_extensions(Some("config.v2/src/**/*.rs")),
1210            vec!["rs"]
1211        );
1212        assert_eq!(extract_extensions(Some("src/v2.0/*.module.ts")), vec!["ts"]);
1213        // No extension on the final component → empty.
1214        assert_eq!(extract_extensions(Some("src/**/*")), Vec::<String>::new());
1215        assert_eq!(
1216            extract_extensions(Some("config.v2/Makefile")),
1217            Vec::<String>::new()
1218        );
1219    }
1220
1221    #[test]
1222    fn include_glob_filters_by_brace_expansion() {
1223        let dir = tempfile::tempdir().unwrap();
1224        std::fs::write(dir.path().join("a.rs"), "needle\n").unwrap();
1225        std::fs::write(dir.path().join("b.ts"), "needle\n").unwrap();
1226        std::fs::write(dir.path().join("c.py"), "needle\n").unwrap();
1227
1228        let out = handle(
1229            "needle",
1230            dir.path().to_string_lossy().as_ref(),
1231            Some("*.{rs,ts}"),
1232            10,
1233            CrpMode::Off,
1234            true,
1235            true,
1236            false,
1237        )
1238        .text;
1239
1240        assert!(out.contains("a.rs"), "rs file must match: {out}");
1241        assert!(out.contains("b.ts"), "ts file must match: {out}");
1242        assert!(!out.contains("c.py"), "py file must be excluded: {out}");
1243    }
1244
1245    #[test]
1246    fn bare_include_glob_matches_at_any_depth() {
1247        // rg/git grep behaviour: a bare glob without `/` should match
1248        // files at any depth, not just in the search root.
1249        let dir = tempfile::tempdir().unwrap();
1250        std::fs::create_dir_all(dir.path().join("a/deep/path")).unwrap();
1251        std::fs::write(dir.path().join("a/deep/path/file.rs"), "needle\n").unwrap();
1252        std::fs::write(dir.path().join("root.rs"), "needle\n").unwrap();
1253        std::fs::write(dir.path().join("other.py"), "needle\n").unwrap();
1254
1255        let out = handle(
1256            "needle",
1257            dir.path().to_string_lossy().as_ref(),
1258            Some("*.rs"),
1259            10,
1260            CrpMode::Off,
1261            true,
1262            true,
1263            false,
1264        )
1265        .text;
1266
1267        assert!(out.contains("root.rs"), "root .rs file must match: {out}");
1268        assert!(
1269            out.contains("file.rs"),
1270            "nested .rs file must match bare *.rs glob: {out}"
1271        );
1272        assert!(!out.contains("other.py"), ".py must be excluded: {out}");
1273
1274        // Also test bare filename glob (no wildcard at all)
1275        let out2 = handle(
1276            "needle",
1277            dir.path().to_string_lossy().as_ref(),
1278            Some("file.rs"),
1279            10,
1280            CrpMode::Off,
1281            true,
1282            true,
1283            false,
1284        )
1285        .text;
1286
1287        assert!(
1288            out2.contains("file.rs"),
1289            "bare filename glob must match nested file: {out2}"
1290        );
1291    }
1292
1293    #[test]
1294    fn include_glob_recursive_path_pattern() {
1295        let dir = tempfile::tempdir().unwrap();
1296        std::fs::create_dir_all(dir.path().join("src/inner")).unwrap();
1297        std::fs::write(dir.path().join("src/inner/deep.rs"), "needle\n").unwrap();
1298        std::fs::write(dir.path().join("top.rs"), "needle\n").unwrap();
1299
1300        let out = handle(
1301            "needle",
1302            dir.path().to_string_lossy().as_ref(),
1303            Some("src/**/*.rs"),
1304            10,
1305            CrpMode::Off,
1306            true,
1307            true,
1308            false,
1309        )
1310        .text;
1311
1312        assert!(out.contains("deep.rs"), "nested match expected: {out}");
1313        assert!(
1314            !out.contains("top.rs"),
1315            "root file outside src/ must be excluded: {out}"
1316        );
1317    }
1318
1319    #[test]
1320    fn exclude_glob_drops_paths_and_exclude_pattern_drops_lines() {
1321        // #870: `exclude` is the negative complement of `include` (path glob),
1322        // and `exclude_pattern` is a `grep -v` over result lines.
1323        let dir = tempfile::tempdir().unwrap();
1324        std::fs::write(dir.path().join("app.rs"), "needle\n// needle skip me\n").unwrap();
1325        std::fs::write(dir.path().join("app_test.rs"), "needle\n").unwrap();
1326        let root = dir.path().to_string_lossy().into_owned();
1327
1328        // exclude drops the *_test.rs file entirely.
1329        let out = handle_filtered(
1330            "needle",
1331            &root,
1332            None,
1333            10,
1334            CrpMode::Off,
1335            true,
1336            true,
1337            false,
1338            Some("*_test.rs"),
1339            None,
1340        )
1341        .text;
1342        assert!(out.contains("app.rs"), "app.rs must match: {out}");
1343        assert!(
1344            !out.contains("app_test.rs"),
1345            "excluded path must be dropped: {out}"
1346        );
1347
1348        // exclude_pattern drops the commented match line but keeps the real one.
1349        let out2 = handle_filtered(
1350            "needle",
1351            &root,
1352            Some("app.rs"),
1353            10,
1354            CrpMode::Off,
1355            true,
1356            true,
1357            false,
1358            None,
1359            Some("skip me"),
1360        )
1361        .text;
1362        assert!(out2.contains("1 matches"), "one line kept: {out2}");
1363        assert!(!out2.contains("skip me"), "grep -v line dropped: {out2}");
1364    }
1365
1366    #[test]
1367    fn invalid_exclude_pattern_is_ignored_not_fatal() {
1368        // #870: a malformed exclude regex must not hide matches — it's ignored.
1369        let dir = tempfile::tempdir().unwrap();
1370        std::fs::write(dir.path().join("a.rs"), "needle\n").unwrap();
1371        let root = dir.path().to_string_lossy().into_owned();
1372        let out = handle_filtered(
1373            "needle",
1374            &root,
1375            None,
1376            10,
1377            CrpMode::Off,
1378            true,
1379            true,
1380            false,
1381            None,
1382            Some("([unclosed"),
1383        )
1384        .text;
1385        assert!(out.contains("a.rs"), "invalid exclude → no filter: {out}");
1386    }
1387
1388    #[test]
1389    fn search_refuses_home_directory_root() {
1390        // #356 class: the MCP server often runs with cwd == $HOME; a defaulted
1391        // `path` must never walk the whole home dir (macOS TCC prompts).
1392        let home = dirs::home_dir().expect("home dir in test env");
1393        let out = handle(
1394            "needle",
1395            home.to_string_lossy().as_ref(),
1396            None,
1397            10,
1398            CrpMode::Off,
1399            true,
1400            true,
1401            false,
1402        )
1403        .text;
1404        assert!(
1405            out.starts_with("ERROR:") && out.contains("refusing to scan"),
1406            "home root must be refused: {out}"
1407        );
1408    }
1409}