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