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
19pub const NATIVE_GREP_BASELINE_FACTOR: f64 = 2.5;
26
27pub struct SearchOutcome {
29 pub text: String,
31 pub modeled_baseline: usize,
34 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
58fn 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
73pub 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 let include_patterns = compile_include(include);
96 const MAX_PATTERN_LEN: usize = 1024;
97 const MAX_REGEX_SIZE: usize = 1 << 20; 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 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 let mut any_enclosing = false;
137
138 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 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 files.push(path.to_path_buf());
206 }
207 }
208
209 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 if deadline.is_some_and(|dl| Instant::now() >= dl) {
223 deadline_hit = true;
224 break;
225 }
226
227 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 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 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 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 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 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 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 if anchored {
377 result.push_str("[anchored: path:line:hh → edit via ctx_patch]\n");
378 }
379 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 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
510struct EnclosingIndex {
518 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 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
554const MAX_INCLUDE_GLOBS: usize = 64;
557
558fn 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
588fn 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
616fn 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
649fn 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 #[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 #[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 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 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 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 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 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 search_finds_word_literals_added_after_index_warm() {
924 let _lock = crate::core::data_dir::test_env_lock();
929 crate::test_env::remove_var("LEAN_CTX_DISABLE_SEARCH_INDEX");
930 crate::test_env::remove_var("LEAN_CTX_SEARCH_INDEX_COALESCE_MS");
931
932 let dir = tempfile::tempdir().unwrap();
933 std::fs::write(dir.path().join("a.rs"), "fn existing() {}\n").unwrap();
934 std::fs::write(dir.path().join("b.rs"), "fn other() {}\n").unwrap();
935 let root = dir.path().to_string_lossy().to_string();
936
937 assert!(
938 crate::core::search_index::warm_blocking(&root, true, false),
939 "index should warm for a small clean corpus"
940 );
941
942 std::fs::write(dir.path().join("c.rs"), "fn added_after_warm_zzz() {}\n").unwrap();
944 let out_new = handle(
945 "added_after_warm_zzz",
946 &root,
947 None,
948 10,
949 CrpMode::Off,
950 true,
951 false,
952 false,
953 )
954 .text;
955 assert!(
956 out_new.contains("c.rs") && out_new.contains("added_after_warm_zzz"),
957 "a file created after the index warm must be found: {out_new}"
958 );
959
960 std::fs::write(
962 dir.path().join("a.rs"),
963 "fn existing() {}\nfn edited_after_warm_zzz() {}\n",
964 )
965 .unwrap();
966 let out_edit = handle(
967 "edited_after_warm_zzz",
968 &root,
969 None,
970 10,
971 CrpMode::Off,
972 true,
973 false,
974 false,
975 )
976 .text;
977 assert!(
978 out_edit.contains("a.rs") && out_edit.contains("edited_after_warm_zzz"),
979 "content appended after the index warm must be found: {out_edit}"
980 );
981 }
982
983 #[test]
984 fn symbol_substitution_is_off_by_default() {
985 let _lock = crate::core::data_dir::test_env_lock();
986 crate::test_env::remove_var("LEAN_CTX_SYMBOL_MAP");
987 let dir = tempfile::tempdir().unwrap();
988 let f = dir.path().join("a.rs");
989 std::fs::write(
990 &f,
991 "fn longIdentifierAlpha() {}\nfn longIdentifierBeta() {}\nfn longIdentifierGamma() {}\n",
992 )
993 .unwrap();
994
995 let out = handle(
996 "longIdentifier",
997 dir.path().to_string_lossy().as_ref(),
998 Some("*.rs"),
999 10,
1000 CrpMode::Off,
1001 true,
1002 true,
1003 false,
1004 )
1005 .text;
1006
1007 assert!(
1008 !out.contains("§MAP"),
1009 "default agent-facing output must not carry a §MAP table: {out}"
1010 );
1011 assert!(
1012 !out.contains('α'),
1013 "default agent-facing output must not carry α-symbols: {out}"
1014 );
1015 assert!(
1016 out.contains("longIdentifierAlpha"),
1017 "identifiers should appear raw by default: {out}"
1018 );
1019 }
1020
1021 #[test]
1022 fn secret_like_files_are_skipped_by_default() {
1023 let dir = tempfile::tempdir().unwrap();
1024 let secret = dir.path().join("key.pem");
1025 let ok = dir.path().join("ok.txt");
1026 std::fs::write(&secret, "match\n").unwrap();
1027 std::fs::write(&ok, "match\n").unwrap();
1028
1029 let out = handle(
1030 "match",
1031 dir.path().to_string_lossy().as_ref(),
1032 None,
1033 10,
1034 CrpMode::Off,
1035 true,
1036 false,
1037 false,
1038 )
1039 .text;
1040
1041 assert!(out.contains("ok.txt:"), "expected ok.txt match, got: {out}");
1042 assert!(
1043 !out.contains("key.pem:"),
1044 "secret-like file should be skipped, got: {out}"
1045 );
1046 assert!(
1047 out.contains("secret-like files skipped"),
1048 "expected boundary skip note, got: {out}"
1049 );
1050 }
1051
1052 #[test]
1053 #[cfg(unix)]
1054 fn search_skips_named_pipe_without_hanging() {
1055 use std::sync::mpsc;
1056 let dir = tempfile::tempdir().unwrap();
1060 std::fs::write(dir.path().join("real.txt"), "needle_here = 1\n").unwrap();
1061 let fifo = dir.path().join("pipe.fifo");
1062 let c = std::ffi::CString::new(fifo.to_string_lossy().as_bytes()).unwrap();
1063 assert_eq!(
1064 unsafe { libc::mkfifo(c.as_ptr(), 0o644) },
1067 0,
1068 "mkfifo failed"
1069 );
1070
1071 let dir_path = dir.path().to_string_lossy().to_string();
1072 let (tx, rx) = mpsc::channel();
1073 std::thread::spawn(move || {
1074 let out = handle(
1076 "needle_here",
1077 &dir_path,
1078 None,
1079 10,
1080 CrpMode::Off,
1081 true,
1082 true,
1083 false,
1084 )
1085 .text;
1086 let _ = tx.send(out);
1087 });
1088 let out = rx
1089 .recv_timeout(Duration::from_secs(5))
1090 .expect("ctx_search hung on a FIFO (#336 regression)");
1091
1092 assert!(
1093 out.contains("real.txt"),
1094 "the real file must still match: {out}"
1095 );
1096 assert!(
1097 out.contains("special files skipped"),
1098 "the FIFO must be reported as a skipped special file: {out}"
1099 );
1100 }
1101
1102 #[test]
1103 fn search_deadline_env_override_is_respected() {
1104 let _lock = crate::core::data_dir::test_env_lock();
1105 crate::test_env::set_var("LEAN_CTX_SEARCH_DEADLINE_MS", "0");
1106 assert!(search_deadline().is_none(), "0 must disable the deadline");
1107 crate::test_env::set_var("LEAN_CTX_SEARCH_DEADLINE_MS", "250");
1108 assert_eq!(search_deadline(), Some(Duration::from_millis(250)));
1109 crate::test_env::remove_var("LEAN_CTX_SEARCH_DEADLINE_MS");
1110 assert_eq!(
1111 search_deadline(),
1112 Some(Duration::from_secs(10)),
1113 "default budget is 10s"
1114 );
1115 }
1116
1117 #[test]
1118 fn extract_extensions_handles_single_brace_and_none() {
1119 assert_eq!(extract_extensions(Some("*.rs")), vec!["rs"]);
1120 assert_eq!(extract_extensions(Some("src/**/*.tsx")), vec!["tsx"]);
1121 assert_eq!(extract_extensions(Some("*.{rs,ts}")), vec!["rs", "ts"]);
1122 assert_eq!(
1123 extract_extensions(Some("*.{rs, ts , js}")),
1124 vec!["rs", "ts", "js"]
1125 );
1126 assert_eq!(extract_extensions(None), Vec::<String>::new());
1127 }
1128
1129 #[test]
1130 fn extract_extensions_ignores_dots_in_directory_segments() {
1131 assert_eq!(
1133 extract_extensions(Some("config.v2/src/**/*.rs")),
1134 vec!["rs"]
1135 );
1136 assert_eq!(extract_extensions(Some("src/v2.0/*.module.ts")), vec!["ts"]);
1137 assert_eq!(extract_extensions(Some("src/**/*")), Vec::<String>::new());
1139 assert_eq!(
1140 extract_extensions(Some("config.v2/Makefile")),
1141 Vec::<String>::new()
1142 );
1143 }
1144
1145 #[test]
1146 fn include_glob_filters_by_brace_expansion() {
1147 let dir = tempfile::tempdir().unwrap();
1148 std::fs::write(dir.path().join("a.rs"), "needle\n").unwrap();
1149 std::fs::write(dir.path().join("b.ts"), "needle\n").unwrap();
1150 std::fs::write(dir.path().join("c.py"), "needle\n").unwrap();
1151
1152 let out = handle(
1153 "needle",
1154 dir.path().to_string_lossy().as_ref(),
1155 Some("*.{rs,ts}"),
1156 10,
1157 CrpMode::Off,
1158 true,
1159 true,
1160 false,
1161 )
1162 .text;
1163
1164 assert!(out.contains("a.rs"), "rs file must match: {out}");
1165 assert!(out.contains("b.ts"), "ts file must match: {out}");
1166 assert!(!out.contains("c.py"), "py file must be excluded: {out}");
1167 }
1168
1169 #[test]
1170 fn bare_include_glob_matches_at_any_depth() {
1171 let dir = tempfile::tempdir().unwrap();
1174 std::fs::create_dir_all(dir.path().join("a/deep/path")).unwrap();
1175 std::fs::write(dir.path().join("a/deep/path/file.rs"), "needle\n").unwrap();
1176 std::fs::write(dir.path().join("root.rs"), "needle\n").unwrap();
1177 std::fs::write(dir.path().join("other.py"), "needle\n").unwrap();
1178
1179 let out = handle(
1180 "needle",
1181 dir.path().to_string_lossy().as_ref(),
1182 Some("*.rs"),
1183 10,
1184 CrpMode::Off,
1185 true,
1186 true,
1187 false,
1188 )
1189 .text;
1190
1191 assert!(out.contains("root.rs"), "root .rs file must match: {out}");
1192 assert!(
1193 out.contains("file.rs"),
1194 "nested .rs file must match bare *.rs glob: {out}"
1195 );
1196 assert!(!out.contains("other.py"), ".py must be excluded: {out}");
1197
1198 let out2 = handle(
1200 "needle",
1201 dir.path().to_string_lossy().as_ref(),
1202 Some("file.rs"),
1203 10,
1204 CrpMode::Off,
1205 true,
1206 true,
1207 false,
1208 )
1209 .text;
1210
1211 assert!(
1212 out2.contains("file.rs"),
1213 "bare filename glob must match nested file: {out2}"
1214 );
1215 }
1216
1217 #[test]
1218 fn include_glob_recursive_path_pattern() {
1219 let dir = tempfile::tempdir().unwrap();
1220 std::fs::create_dir_all(dir.path().join("src/inner")).unwrap();
1221 std::fs::write(dir.path().join("src/inner/deep.rs"), "needle\n").unwrap();
1222 std::fs::write(dir.path().join("top.rs"), "needle\n").unwrap();
1223
1224 let out = handle(
1225 "needle",
1226 dir.path().to_string_lossy().as_ref(),
1227 Some("src/**/*.rs"),
1228 10,
1229 CrpMode::Off,
1230 true,
1231 true,
1232 false,
1233 )
1234 .text;
1235
1236 assert!(out.contains("deep.rs"), "nested match expected: {out}");
1237 assert!(
1238 !out.contains("top.rs"),
1239 "root file outside src/ must be excluded: {out}"
1240 );
1241 }
1242
1243 #[test]
1244 fn search_refuses_home_directory_root() {
1245 let home = dirs::home_dir().expect("home dir in test env");
1248 let out = handle(
1249 "needle",
1250 home.to_string_lossy().as_ref(),
1251 None,
1252 10,
1253 CrpMode::Off,
1254 true,
1255 true,
1256 false,
1257 )
1258 .text;
1259 assert!(
1260 out.starts_with("ERROR:") && out.contains("refusing to scan"),
1261 "home root must be refused: {out}"
1262 );
1263 }
1264}