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