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