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
73#[allow(clippy::too_many_arguments)]
77pub fn handle(
78 pattern: &str,
79 dir: &str,
80 include: Option<&str>,
81 max_results: usize,
82 crp_mode: CrpMode,
83 respect_gitignore: bool,
84 allow_secret_paths: bool,
85 anchored: bool,
86) -> SearchOutcome {
87 handle_filtered(
88 pattern,
89 dir,
90 include,
91 max_results,
92 crp_mode,
93 respect_gitignore,
94 allow_secret_paths,
95 anchored,
96 None,
97 None,
98 )
99}
100
101#[allow(clippy::too_many_arguments)]
111pub fn handle_filtered(
112 pattern: &str,
113 dir: &str,
114 include: Option<&str>,
115 max_results: usize,
116 _crp_mode: CrpMode,
117 respect_gitignore: bool,
118 allow_secret_paths: bool,
119 anchored: bool,
120 exclude: Option<&str>,
121 exclude_pattern: Option<&str>,
122) -> SearchOutcome {
123 let include_patterns = compile_include(include);
130 let exclude_patterns = compile_include(exclude);
133 const MAX_PATTERN_LEN: usize = 1024;
134 const MAX_REGEX_SIZE: usize = 1 << 20; let redact = crate::core::redaction::redaction_enabled_for_active_role();
137 if pattern.len() > MAX_PATTERN_LEN {
138 return SearchOutcome::error(format!(
139 "ERROR: pattern too long ({} > {MAX_PATTERN_LEN} chars)",
140 pattern.len()
141 ));
142 }
143 let re = match RegexBuilder::new(pattern)
144 .size_limit(MAX_REGEX_SIZE)
145 .dfa_size_limit(MAX_REGEX_SIZE)
146 .build()
147 {
148 Ok(r) => r,
149 Err(e) => return SearchOutcome::error(format!("ERROR: invalid regex: {e}")),
150 };
151 let exclude_re = exclude_pattern.and_then(|p| {
155 RegexBuilder::new(p)
156 .size_limit(MAX_REGEX_SIZE)
157 .dfa_size_limit(MAX_REGEX_SIZE)
158 .build()
159 .ok()
160 });
161
162 let root = Path::new(dir);
163 if !root.exists() {
164 return SearchOutcome::error(format!("ERROR: {dir} does not exist"));
165 }
166 if let Some(err) = crate::tools::walk_guard::deny_unsafe_walk_root(dir) {
169 return SearchOutcome::error(err);
170 }
171
172 let mut files: Vec<PathBuf> = Vec::new();
173 let mut matches = Vec::new();
174 let mut raw_tokens_accum: usize = 0;
175 let mut files_searched = 0u32;
176 let mut files_skipped_size = 0u32;
177 let mut files_skipped_encoding = 0u32;
178 let mut files_skipped_boundary = 0u32;
179 let mut files_skipped_special = 0u32;
180 let mut deadline_hit = false;
181 let mut any_enclosing = false;
184
185 let used_index = if let Some(idx) =
192 crate::core::search_index::get_fresh(dir, respect_gitignore, allow_secret_paths)
193 {
194 files = idx
195 .candidate_paths(pattern, &include_patterns, root)
196 .into_paths();
197 true
198 } else {
199 false
200 };
201
202 if !used_index {
203 let walker = WalkBuilder::new(root)
206 .hidden(true)
207 .max_depth(Some(MAX_WALK_DEPTH))
208 .git_ignore(respect_gitignore)
209 .git_global(respect_gitignore)
210 .git_exclude(respect_gitignore)
211 .require_git(false)
212 .filter_entry(move |e| {
213 if respect_gitignore {
214 crate::core::walk_filter::keep_entry(e)
215 } else {
216 crate::core::cloud_files::keep_entry(e)
217 }
218 })
219 .build();
220
221 for entry in walker.filter_map(std::result::Result::ok) {
222 if entry.file_type().is_none_or(|ft| ft.is_dir()) {
223 continue;
224 }
225
226 if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
227 continue;
228 }
229
230 let path = entry.path();
231
232 if is_binary_ext(path) || is_generated_file(path) {
233 continue;
234 }
235
236 if !allow_secret_paths && crate::core::io_boundary::is_secret_like(path).is_some() {
237 files_skipped_boundary += 1;
238 continue;
239 }
240
241 if !include_patterns.is_empty() {
242 let rel = path.strip_prefix(root).unwrap_or(path);
243 let rel_str = rel.to_string_lossy();
244 if !include_patterns.iter().any(|p| p.matches(&rel_str)) {
245 continue;
246 }
247 }
248
249 files.push(path.to_path_buf());
253 }
254 }
255
256 if !exclude_patterns.is_empty() {
259 files.retain(|path| {
260 let rel = path.strip_prefix(root).unwrap_or(path);
261 let rel_str = rel.to_string_lossy();
262 !exclude_patterns.iter().any(|p| p.matches(&rel_str))
263 });
264 }
265
266 files.sort_unstable_by(|a, b| a.as_os_str().cmp(b.as_os_str()));
268
269 let root_str = root.to_string_lossy();
270 let deadline = search_deadline().map(|budget| Instant::now() + budget);
271 for path in &files {
272 if matches.len() >= max_results {
273 break;
274 }
275
276 if deadline.is_some_and(|dl| Instant::now() >= dl) {
280 deadline_hit = true;
281 break;
282 }
283
284 let state = match std::fs::metadata(path) {
289 Ok(meta) if !meta.file_type().is_file() => {
290 files_skipped_special += 1;
291 continue;
292 }
293 Ok(meta) if meta.len() > MAX_FILE_SIZE => {
294 files_skipped_size += 1;
295 continue;
296 }
297 Ok(meta) => crate::core::content_cache::FileState::from_metadata(&meta),
298 Err(_) => {
299 files_skipped_encoding += 1;
300 continue;
301 }
302 };
303
304 let content: std::sync::Arc<str> =
310 if let Some(cached) = state.and_then(|s| crate::core::content_cache::get(path, s)) {
311 cached
312 } else {
313 let Ok(text) = std::fs::read_to_string(path) else {
314 files_skipped_encoding += 1;
315 continue;
316 };
317 let arc: std::sync::Arc<str> = std::sync::Arc::from(text);
318 if let Some(s) = state {
319 crate::core::content_cache::insert(path, s, std::sync::Arc::clone(&arc));
320 }
321 arc
322 };
323
324 files_searched += 1;
325 let mut file_enclosing: Option<EnclosingIndex> = None;
328
329 for (i, line) in content.lines().enumerate() {
330 if re.is_match(line) && !exclude_re.as_ref().is_some_and(|ex| ex.is_match(line)) {
332 let short_path =
333 protocol::shorten_path_relative(&path.to_string_lossy(), &root_str);
334 raw_tokens_accum += count_tokens(line.trim()) + 2;
336 let mut shown = if redact {
337 crate::core::redaction::redact_text(line.trim())
338 } else {
339 line.trim().to_string()
340 };
341 if shown.len() > MAX_MATCH_LINE_WIDTH {
342 shown.truncate(shown.floor_char_boundary(MAX_MATCH_LINE_WIDTH));
343 shown.push_str("...");
344 }
345 let tag = file_enclosing
350 .get_or_insert_with(|| EnclosingIndex::for_file(path, content.as_ref()))
351 .tag_for(i + 1);
352 if tag.is_some() {
353 any_enclosing = true;
354 }
355 let tag = tag.unwrap_or_default();
356 if anchored {
360 matches.push(format!(
361 "{short_path}:{}:{} {}{}",
362 i + 1,
363 crate::core::anchor::line_hash(line),
364 shown,
365 tag
366 ));
367 } else {
368 matches.push(format!("{short_path}:{} {}{}", i + 1, shown, tag));
369 }
370 if matches.len() >= max_results {
371 break;
372 }
373 }
374 }
375 }
376
377 if matches.len() > 1 {
380 use std::collections::HashMap;
381 let mut file_counts: HashMap<String, usize> = HashMap::new();
382 for m in &matches {
383 let file = extract_file_from_match(m).to_string();
384 *file_counts.entry(file).or_default() += 1;
385 }
386 matches.sort_by(|a, b| {
387 let fa = extract_file_from_match(a);
388 let fb = extract_file_from_match(b);
389 let ca = file_counts.get(fa).copied().unwrap_or(0);
390 let cb = file_counts.get(fb).copied().unwrap_or(0);
391 cb.cmp(&ca).then_with(|| fa.cmp(fb))
392 });
393 }
394
395 if matches.is_empty() {
396 let mut msg = format!("0 matches for '{pattern}' in {files_searched} files");
397 if files_skipped_size > 0 {
398 msg.push_str(&format!(" ({files_skipped_size} large files skipped)"));
399 }
400 if files_skipped_encoding > 0 {
401 msg.push_str(&format!(
402 " ({files_skipped_encoding} files skipped: binary/encoding)"
403 ));
404 }
405 if files_skipped_boundary > 0 {
406 msg.push_str(&format!(
407 " ({files_skipped_boundary} secret-like files skipped by boundary policy)"
408 ));
409 }
410 if files_skipped_special > 0 {
411 msg.push_str(&format!(
412 " ({files_skipped_special} special files skipped: not regular files)"
413 ));
414 }
415 if deadline_hit {
416 msg.push_str(
417 " (search stopped at the time budget — refine the pattern or scope with path=)",
418 );
419 }
420 return SearchOutcome::error(msg);
421 }
422
423 let matched_files: Vec<&str> = {
425 let mut seen = HashSet::new();
426 matches
427 .iter()
428 .filter_map(|m| {
429 let file = extract_file_from_match(m);
430 if seen.insert(file) { Some(file) } else { None }
431 })
432 .collect()
433 };
434
435 let mut result = format!("{} matches in {} files", matches.len(), files_searched);
436 if matched_files.len() > 1 {
437 if matched_files.len() <= 10 {
438 result.push_str(" [");
439 result.push_str(&matched_files.join(", "));
440 result.push(']');
441 } else {
442 let shown: Vec<&str> = matched_files.iter().take(8).copied().collect();
443 result.push_str(&format!(
444 " [{}, +{} more]",
445 shown.join(", "),
446 matched_files.len() - 8
447 ));
448 }
449 }
450 result.push_str(":\n");
451 if anchored {
453 result.push_str("[anchored: path:line:hh → edit via ctx_patch]\n");
454 }
455 if any_enclosing {
458 result.push_str(
459 "[∈ enclosing symbol → ctx_search(action=symbol, handle=\"path#name@Lstart\")]\n",
460 );
461 }
462 result.push_str(&matches.join("\n"));
463
464 if files_skipped_size > 0 {
465 result.push_str(&format!("\n({files_skipped_size} files >512KB skipped)"));
466 }
467 if files_skipped_encoding > 0 {
468 result.push_str(&format!(
469 "\n({files_skipped_encoding} files skipped: binary/encoding)"
470 ));
471 }
472 if files_skipped_boundary > 0 {
473 result.push_str(&format!(
474 "\n({files_skipped_boundary} secret-like files skipped by boundary policy)"
475 ));
476 }
477 if files_skipped_special > 0 {
478 result.push_str(&format!(
479 "\n({files_skipped_special} special files skipped: not regular files)"
480 ));
481 }
482 if deadline_hit {
483 result.push_str(&format!(
484 "\n(search stopped after the {}s budget — {files_searched} files scanned; \
485 refine the pattern or scope with path= for full coverage)",
486 search_deadline().map_or(0, |d| d.as_secs())
487 ));
488 }
489
490 let scope_hint = monorepo_scope_hint(&matches, dir);
494
495 if let Some(delta) = crate::core::search_delta::compute_delta(pattern, &matches) {
496 return SearchOutcome::from_observed(delta, raw_tokens_accum);
497 }
498
499 if symbol_map::substitution_enabled() {
500 let exts = extract_extensions(include);
501 let ext_refs: Vec<&str> = exts.iter().map(String::as_str).collect();
502 let mut sym = SymbolMap::new();
503 let idents = symbol_map::extract_identifiers(&result, &ext_refs);
504 for ident in &idents {
505 sym.register(ident);
506 }
507 if sym.len() >= 3 {
508 let sym_table = sym.format_table();
509 let compressed = sym.apply(&result);
510 let original_tok = count_tokens(&result);
511 let compressed_tok = count_tokens(&compressed) + count_tokens(&sym_table);
512 let net_saving = original_tok.saturating_sub(compressed_tok);
513 if original_tok > 0 && net_saving * 100 / original_tok >= 5 {
514 result = format!("{compressed}{sym_table}");
515 }
516 }
517 }
518
519 if let Some(hint) = scope_hint {
520 result.push_str(&hint);
521 }
522
523 SearchOutcome::from_observed(result, raw_tokens_accum)
524}
525
526pub(crate) fn is_binary_ext(path: &Path) -> bool {
527 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
528 matches!(
529 ext,
530 "png"
531 | "jpg"
532 | "jpeg"
533 | "gif"
534 | "webp"
535 | "ico"
536 | "svg"
537 | "woff"
538 | "woff2"
539 | "ttf"
540 | "eot"
541 | "pdf"
542 | "zip"
543 | "tar"
544 | "gz"
545 | "br"
546 | "zst"
547 | "bz2"
548 | "xz"
549 | "mp3"
550 | "mp4"
551 | "webm"
552 | "ogg"
553 | "wasm"
554 | "so"
555 | "dylib"
556 | "dll"
557 | "exe"
558 | "lock"
559 | "map"
560 | "snap"
561 | "patch"
562 | "db"
563 | "sqlite"
564 | "parquet"
565 | "arrow"
566 | "bin"
567 | "o"
568 | "a"
569 | "class"
570 | "pyc"
571 | "pyo"
572 )
573}
574
575pub(crate) fn is_generated_file(path: &Path) -> bool {
576 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
577 name.ends_with(".min.js")
578 || name.ends_with(".min.css")
579 || name.ends_with(".bundle.js")
580 || name.ends_with(".chunk.js")
581 || name.ends_with(".d.ts")
582 || name.ends_with(".js.map")
583 || name.ends_with(".css.map")
584}
585
586struct EnclosingIndex {
594 spans: Vec<(usize, usize, String)>,
596}
597
598impl EnclosingIndex {
599 fn for_file(path: &Path, content: &str) -> Self {
600 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
601 let mut spans: Vec<(usize, usize, String)> =
602 crate::core::signatures::extract_signatures(content, ext)
603 .into_iter()
604 .filter_map(|s| match (s.start_line, s.end_line) {
605 (Some(a), Some(b)) if b > a => Some((a, b, s.name)),
606 _ => None,
607 })
608 .collect();
609 spans.sort_by(|x, y| x.0.cmp(&y.0).then(x.1.cmp(&y.1)));
610 Self { spans }
611 }
612
613 fn tag_for(&self, line: usize) -> Option<String> {
616 let mut best: Option<&(usize, usize, String)> = None;
617 for sp in &self.spans {
618 if line >= sp.0 && line <= sp.1 {
619 match best {
620 None => best = Some(sp),
621 Some(b) if (sp.1 - sp.0) < (b.1 - b.0) => best = Some(sp),
622 _ => {}
623 }
624 }
625 }
626 best.map(|(start, _, name)| format!(" ∈{name}@L{start}"))
627 }
628}
629
630const MAX_INCLUDE_GLOBS: usize = 64;
633
634fn compile_include(include: Option<&str>) -> Vec<Pattern> {
646 let Some(raw) = include else {
647 return Vec::new();
648 };
649 expand_braces(raw)
650 .into_iter()
651 .take(MAX_INCLUDE_GLOBS)
652 .filter(|g| !g.is_empty())
653 .map(|g| {
654 if g.contains('/') {
655 g
656 } else {
657 format!("**/{g}")
658 }
659 })
660 .filter_map(|g| Pattern::new(&g).ok())
661 .collect()
662}
663
664fn expand_braces(pattern: &str) -> Vec<String> {
668 let Some(open) = pattern.find('{') else {
669 return vec![pattern.to_string()];
670 };
671 let Some(close_rel) = pattern[open..].find('}') else {
672 return vec![pattern.to_string()];
673 };
674 let close = open + close_rel;
675 let prefix = &pattern[..open];
676 let inner = &pattern[open + 1..close];
677 let suffix = &pattern[close + 1..];
678
679 let mut out = Vec::new();
680 for alt in inner.split(',') {
681 let alt = alt.trim();
682 for expanded_suffix in expand_braces(suffix) {
683 out.push(format!("{prefix}{alt}{expanded_suffix}"));
684 if out.len() >= MAX_INCLUDE_GLOBS {
685 return out;
686 }
687 }
688 }
689 out
690}
691
692fn extract_extensions(include: Option<&str>) -> Vec<String> {
702 let Some(pattern) = include else {
703 return Vec::new();
704 };
705 let filename = pattern.rsplit('/').next().unwrap_or(pattern);
706 let Some(dot) = filename.rfind('.') else {
707 return Vec::new();
708 };
709 let ext_part = &filename[dot + 1..];
710
711 if let Some(inner) = ext_part.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
712 return inner
713 .split(',')
714 .map(|e| e.trim().to_string())
715 .filter(|e| !e.is_empty())
716 .collect();
717 }
718
719 if ext_part.is_empty() {
720 return Vec::new();
721 }
722 vec![ext_part.to_string()]
723}
724
725fn extract_file_from_match(line: &str) -> &str {
727 let start = if line.len() >= 2
728 && line.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
729 && line.as_bytes().get(1) == Some(&b':')
730 {
731 2
732 } else {
733 0
734 };
735 match line[start..].find(':') {
736 Some(pos) => &line[..start + pos],
737 None => line,
738 }
739}
740
741fn monorepo_scope_hint(matches: &[String], search_dir: &str) -> Option<String> {
742 let top_dirs: HashSet<&str> = matches
743 .iter()
744 .filter_map(|m| {
745 let path = extract_file_from_match(m);
746 let relative = path.strip_prefix("./").unwrap_or(path);
747 let relative = relative.strip_prefix(search_dir).unwrap_or(relative);
748 let relative = relative.strip_prefix('/').unwrap_or(relative);
749 relative.split('/').next()
750 })
751 .collect();
752
753 if top_dirs.len() > 3 {
754 let mut dirs: Vec<&&str> = top_dirs.iter().collect();
755 dirs.sort();
756 let dir_list: Vec<String> = dirs.iter().take(6).map(|d| format!("'{d}'")).collect();
757 let extra = if top_dirs.len() > 6 {
758 format!(", +{} more", top_dirs.len() - 6)
759 } else {
760 String::new()
761 };
762 Some(format!(
763 "\n\nResults span {} directories ({}{}). \
764 Use the 'path' parameter to scope to a specific service, \
765 e.g. path=\"{}/\".",
766 top_dirs.len(),
767 dir_list.join(", "),
768 extra,
769 dirs[0]
770 ))
771 } else {
772 None
773 }
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779 use crate::tools::CrpMode;
780
781 #[test]
785 fn search_output_is_byte_stable_across_calls() {
786 let dir = tempfile::tempdir().unwrap();
787 for i in 0..5 {
788 std::fs::write(
789 dir.path().join(format!("f{i}.rs")),
790 format!("fn target_{i}() {{}}\nfn other() {{}}\n"),
791 )
792 .unwrap();
793 }
794 let root = dir.path().to_string_lossy().into_owned();
795 let run = || {
796 handle(
797 "target",
798 &root,
799 Some("*.rs"),
800 20,
801 CrpMode::Off,
802 true,
803 true,
804 false,
805 )
806 .text
807 };
808 assert_eq!(run(), run(), "search output must be deterministic");
809 }
810
811 #[test]
815 fn anchored_search_emits_line_hash_per_hit_opt_in_only() {
816 let dir = tempfile::tempdir().unwrap();
817 std::fs::write(dir.path().join("a.rs"), "let needle = 1;\nother\n").unwrap();
818 let root = dir.path().to_string_lossy().into_owned();
819
820 let plain = handle(
821 "needle",
822 &root,
823 Some("*.rs"),
824 10,
825 CrpMode::Off,
826 true,
827 true,
828 false,
829 )
830 .text;
831 assert!(
832 !plain.contains("[anchored:"),
833 "default must carry no legend"
834 );
835 assert!(
836 plain.contains("a.rs:1 "),
837 "default keeps path:line content: {plain}"
838 );
839
840 let anchored = handle(
841 "needle",
842 &root,
843 Some("*.rs"),
844 10,
845 CrpMode::Off,
846 true,
847 true,
848 true,
849 )
850 .text;
851 let hh = crate::core::anchor::line_hash("let needle = 1;");
852 assert!(anchored.contains("[anchored: path:line:hh → edit via ctx_patch]"));
853 assert!(
854 anchored.contains(&format!("a.rs:1:{hh} ")),
855 "anchored hit must carry the line hash: {anchored}"
856 );
857 }
858
859 #[test]
860 #[cfg(feature = "tree-sitter")]
861 fn hits_inside_multiline_symbols_carry_enclosing_tag() {
862 let dir = tempfile::tempdir().unwrap();
865 std::fs::write(
866 dir.path().join("a.rs"),
867 "fn outer() {\n let needle = 1;\n needle\n}\nfn tiny() {}\n",
868 )
869 .unwrap();
870 let out = handle(
871 "needle",
872 dir.path().to_string_lossy().as_ref(),
873 Some("*.rs"),
874 10,
875 CrpMode::Off,
876 true,
877 true,
878 false,
879 )
880 .text;
881 assert!(
882 out.contains("∈outer@L1"),
883 "hit must name its enclosing fn: {out}"
884 );
885 assert!(
886 out.contains("[∈ enclosing symbol"),
887 "self-describing legend must be present: {out}"
888 );
889 }
890
891 #[test]
892 fn single_line_symbols_get_no_enclosing_tag() {
893 let dir = tempfile::tempdir().unwrap();
896 std::fs::write(dir.path().join("a.rs"), "fn one_liner() {}\n").unwrap();
897 let out = handle(
898 "one_liner",
899 dir.path().to_string_lossy().as_ref(),
900 Some("*.rs"),
901 10,
902 CrpMode::Off,
903 true,
904 true,
905 false,
906 )
907 .text;
908 assert!(!out.contains('∈'), "single-line symbol → no tag: {out}");
909 }
910
911 #[test]
912 fn search_results_are_deterministically_ordered_by_path() {
913 let dir = tempfile::tempdir().unwrap();
914 let a = dir.path().join("a.txt");
915 let b = dir.path().join("b.txt");
916 std::fs::write(&b, "match\n").unwrap();
917 std::fs::write(&a, "match\n").unwrap();
918
919 let out = handle(
920 "match",
921 dir.path().to_string_lossy().as_ref(),
922 Some("*.txt"),
923 10,
924 CrpMode::Off,
925 true,
926 true,
927 false,
928 )
929 .text;
930
931 let mut match_lines: Vec<&str> = out
932 .lines()
933 .filter(|l| l.contains(".txt:") && l.contains("match"))
934 .collect();
935 match_lines.truncate(2);
937 assert_eq!(match_lines.len(), 2);
938 assert!(
939 match_lines[0].contains("a.txt:"),
940 "first match should come from a.txt, got: {}",
941 match_lines[0]
942 );
943 assert!(
944 match_lines[1].contains("b.txt:"),
945 "second match should come from b.txt, got: {}",
946 match_lines[1]
947 );
948 }
949
950 #[test]
951 fn warm_index_and_content_cache_path_returns_correct_matches() {
952 let dir = tempfile::tempdir().unwrap();
958 std::fs::write(
959 dir.path().join("a.rs"),
960 "fn authenticate() {}\nlet x = 1;\n",
961 )
962 .unwrap();
963 std::fs::write(dir.path().join("b.rs"), "fn connect() {}\n").unwrap();
964 let root = dir.path().to_string_lossy().to_string();
965
966 assert!(
969 crate::core::search_index::warm_blocking(&root, true, false),
970 "index should warm for a small clean corpus"
971 );
972
973 let out = handle(
974 "authenticate",
975 &root,
976 None,
977 10,
978 CrpMode::Off,
979 true,
980 false,
981 false,
982 )
983 .text;
984 assert!(
985 out.contains("a.rs"),
986 "warm-index + cache search must find the match: {out}"
987 );
988 assert!(
989 out.contains("authenticate"),
990 "the matched line must be present: {out}"
991 );
992 assert!(
993 !out.contains("b.rs"),
994 "a non-matching file must not appear in results: {out}"
995 );
996 }
997
998 #[test]
999 fn search_finds_word_literals_added_after_index_warm() {
1000 let _lock = crate::core::data_dir::test_env_lock();
1005 crate::test_env::remove_var("LEAN_CTX_DISABLE_SEARCH_INDEX");
1006 crate::test_env::remove_var("LEAN_CTX_SEARCH_INDEX_COALESCE_MS");
1007
1008 let dir = tempfile::tempdir().unwrap();
1009 std::fs::write(dir.path().join("a.rs"), "fn existing() {}\n").unwrap();
1010 std::fs::write(dir.path().join("b.rs"), "fn other() {}\n").unwrap();
1011 let root = dir.path().to_string_lossy().to_string();
1012
1013 assert!(
1014 crate::core::search_index::warm_blocking(&root, true, false),
1015 "index should warm for a small clean corpus"
1016 );
1017
1018 std::fs::write(dir.path().join("c.rs"), "fn added_after_warm_zzz() {}\n").unwrap();
1020 let out_new = handle(
1021 "added_after_warm_zzz",
1022 &root,
1023 None,
1024 10,
1025 CrpMode::Off,
1026 true,
1027 false,
1028 false,
1029 )
1030 .text;
1031 assert!(
1032 out_new.contains("c.rs") && out_new.contains("added_after_warm_zzz"),
1033 "a file created after the index warm must be found: {out_new}"
1034 );
1035
1036 std::fs::write(
1038 dir.path().join("a.rs"),
1039 "fn existing() {}\nfn edited_after_warm_zzz() {}\n",
1040 )
1041 .unwrap();
1042 let out_edit = handle(
1043 "edited_after_warm_zzz",
1044 &root,
1045 None,
1046 10,
1047 CrpMode::Off,
1048 true,
1049 false,
1050 false,
1051 )
1052 .text;
1053 assert!(
1054 out_edit.contains("a.rs") && out_edit.contains("edited_after_warm_zzz"),
1055 "content appended after the index warm must be found: {out_edit}"
1056 );
1057 }
1058
1059 #[test]
1060 fn symbol_substitution_is_off_by_default() {
1061 let _lock = crate::core::data_dir::test_env_lock();
1062 crate::test_env::remove_var("LEAN_CTX_SYMBOL_MAP");
1063 let dir = tempfile::tempdir().unwrap();
1064 let f = dir.path().join("a.rs");
1065 std::fs::write(
1066 &f,
1067 "fn longIdentifierAlpha() {}\nfn longIdentifierBeta() {}\nfn longIdentifierGamma() {}\n",
1068 )
1069 .unwrap();
1070
1071 let out = handle(
1072 "longIdentifier",
1073 dir.path().to_string_lossy().as_ref(),
1074 Some("*.rs"),
1075 10,
1076 CrpMode::Off,
1077 true,
1078 true,
1079 false,
1080 )
1081 .text;
1082
1083 assert!(
1084 !out.contains("§MAP"),
1085 "default agent-facing output must not carry a §MAP table: {out}"
1086 );
1087 assert!(
1088 !out.contains('α'),
1089 "default agent-facing output must not carry α-symbols: {out}"
1090 );
1091 assert!(
1092 out.contains("longIdentifierAlpha"),
1093 "identifiers should appear raw by default: {out}"
1094 );
1095 }
1096
1097 #[test]
1098 fn secret_like_files_are_skipped_by_default() {
1099 let dir = tempfile::tempdir().unwrap();
1100 let secret = dir.path().join("key.pem");
1101 let ok = dir.path().join("ok.txt");
1102 std::fs::write(&secret, "match\n").unwrap();
1103 std::fs::write(&ok, "match\n").unwrap();
1104
1105 let out = handle(
1106 "match",
1107 dir.path().to_string_lossy().as_ref(),
1108 None,
1109 10,
1110 CrpMode::Off,
1111 true,
1112 false,
1113 false,
1114 )
1115 .text;
1116
1117 assert!(out.contains("ok.txt:"), "expected ok.txt match, got: {out}");
1118 assert!(
1119 !out.contains("key.pem:"),
1120 "secret-like file should be skipped, got: {out}"
1121 );
1122 assert!(
1123 out.contains("secret-like files skipped"),
1124 "expected boundary skip note, got: {out}"
1125 );
1126 }
1127
1128 #[test]
1129 #[cfg(unix)]
1130 fn search_skips_named_pipe_without_hanging() {
1131 use std::sync::mpsc;
1132 let dir = tempfile::tempdir().unwrap();
1136 std::fs::write(dir.path().join("real.txt"), "needle_here = 1\n").unwrap();
1137 let fifo = dir.path().join("pipe.fifo");
1138 let c = std::ffi::CString::new(fifo.to_string_lossy().as_bytes()).unwrap();
1139 assert_eq!(
1140 unsafe { libc::mkfifo(c.as_ptr(), 0o644) },
1143 0,
1144 "mkfifo failed"
1145 );
1146
1147 let dir_path = dir.path().to_string_lossy().to_string();
1148 let (tx, rx) = mpsc::channel();
1149 std::thread::spawn(move || {
1150 let out = handle(
1152 "needle_here",
1153 &dir_path,
1154 None,
1155 10,
1156 CrpMode::Off,
1157 true,
1158 true,
1159 false,
1160 )
1161 .text;
1162 let _ = tx.send(out);
1163 });
1164 let out = rx
1165 .recv_timeout(Duration::from_secs(5))
1166 .expect("ctx_search hung on a FIFO (#336 regression)");
1167
1168 assert!(
1169 out.contains("real.txt"),
1170 "the real file must still match: {out}"
1171 );
1172 assert!(
1173 out.contains("special files skipped"),
1174 "the FIFO must be reported as a skipped special file: {out}"
1175 );
1176 }
1177
1178 #[test]
1179 fn search_deadline_env_override_is_respected() {
1180 let _lock = crate::core::data_dir::test_env_lock();
1181 crate::test_env::set_var("LEAN_CTX_SEARCH_DEADLINE_MS", "0");
1182 assert!(search_deadline().is_none(), "0 must disable the deadline");
1183 crate::test_env::set_var("LEAN_CTX_SEARCH_DEADLINE_MS", "250");
1184 assert_eq!(search_deadline(), Some(Duration::from_millis(250)));
1185 crate::test_env::remove_var("LEAN_CTX_SEARCH_DEADLINE_MS");
1186 assert_eq!(
1187 search_deadline(),
1188 Some(Duration::from_secs(10)),
1189 "default budget is 10s"
1190 );
1191 }
1192
1193 #[test]
1194 fn extract_extensions_handles_single_brace_and_none() {
1195 assert_eq!(extract_extensions(Some("*.rs")), vec!["rs"]);
1196 assert_eq!(extract_extensions(Some("src/**/*.tsx")), vec!["tsx"]);
1197 assert_eq!(extract_extensions(Some("*.{rs,ts}")), vec!["rs", "ts"]);
1198 assert_eq!(
1199 extract_extensions(Some("*.{rs, ts , js}")),
1200 vec!["rs", "ts", "js"]
1201 );
1202 assert_eq!(extract_extensions(None), Vec::<String>::new());
1203 }
1204
1205 #[test]
1206 fn extract_extensions_ignores_dots_in_directory_segments() {
1207 assert_eq!(
1209 extract_extensions(Some("config.v2/src/**/*.rs")),
1210 vec!["rs"]
1211 );
1212 assert_eq!(extract_extensions(Some("src/v2.0/*.module.ts")), vec!["ts"]);
1213 assert_eq!(extract_extensions(Some("src/**/*")), Vec::<String>::new());
1215 assert_eq!(
1216 extract_extensions(Some("config.v2/Makefile")),
1217 Vec::<String>::new()
1218 );
1219 }
1220
1221 #[test]
1222 fn include_glob_filters_by_brace_expansion() {
1223 let dir = tempfile::tempdir().unwrap();
1224 std::fs::write(dir.path().join("a.rs"), "needle\n").unwrap();
1225 std::fs::write(dir.path().join("b.ts"), "needle\n").unwrap();
1226 std::fs::write(dir.path().join("c.py"), "needle\n").unwrap();
1227
1228 let out = handle(
1229 "needle",
1230 dir.path().to_string_lossy().as_ref(),
1231 Some("*.{rs,ts}"),
1232 10,
1233 CrpMode::Off,
1234 true,
1235 true,
1236 false,
1237 )
1238 .text;
1239
1240 assert!(out.contains("a.rs"), "rs file must match: {out}");
1241 assert!(out.contains("b.ts"), "ts file must match: {out}");
1242 assert!(!out.contains("c.py"), "py file must be excluded: {out}");
1243 }
1244
1245 #[test]
1246 fn bare_include_glob_matches_at_any_depth() {
1247 let dir = tempfile::tempdir().unwrap();
1250 std::fs::create_dir_all(dir.path().join("a/deep/path")).unwrap();
1251 std::fs::write(dir.path().join("a/deep/path/file.rs"), "needle\n").unwrap();
1252 std::fs::write(dir.path().join("root.rs"), "needle\n").unwrap();
1253 std::fs::write(dir.path().join("other.py"), "needle\n").unwrap();
1254
1255 let out = handle(
1256 "needle",
1257 dir.path().to_string_lossy().as_ref(),
1258 Some("*.rs"),
1259 10,
1260 CrpMode::Off,
1261 true,
1262 true,
1263 false,
1264 )
1265 .text;
1266
1267 assert!(out.contains("root.rs"), "root .rs file must match: {out}");
1268 assert!(
1269 out.contains("file.rs"),
1270 "nested .rs file must match bare *.rs glob: {out}"
1271 );
1272 assert!(!out.contains("other.py"), ".py must be excluded: {out}");
1273
1274 let out2 = handle(
1276 "needle",
1277 dir.path().to_string_lossy().as_ref(),
1278 Some("file.rs"),
1279 10,
1280 CrpMode::Off,
1281 true,
1282 true,
1283 false,
1284 )
1285 .text;
1286
1287 assert!(
1288 out2.contains("file.rs"),
1289 "bare filename glob must match nested file: {out2}"
1290 );
1291 }
1292
1293 #[test]
1294 fn include_glob_recursive_path_pattern() {
1295 let dir = tempfile::tempdir().unwrap();
1296 std::fs::create_dir_all(dir.path().join("src/inner")).unwrap();
1297 std::fs::write(dir.path().join("src/inner/deep.rs"), "needle\n").unwrap();
1298 std::fs::write(dir.path().join("top.rs"), "needle\n").unwrap();
1299
1300 let out = handle(
1301 "needle",
1302 dir.path().to_string_lossy().as_ref(),
1303 Some("src/**/*.rs"),
1304 10,
1305 CrpMode::Off,
1306 true,
1307 true,
1308 false,
1309 )
1310 .text;
1311
1312 assert!(out.contains("deep.rs"), "nested match expected: {out}");
1313 assert!(
1314 !out.contains("top.rs"),
1315 "root file outside src/ must be excluded: {out}"
1316 );
1317 }
1318
1319 #[test]
1320 fn exclude_glob_drops_paths_and_exclude_pattern_drops_lines() {
1321 let dir = tempfile::tempdir().unwrap();
1324 std::fs::write(dir.path().join("app.rs"), "needle\n// needle skip me\n").unwrap();
1325 std::fs::write(dir.path().join("app_test.rs"), "needle\n").unwrap();
1326 let root = dir.path().to_string_lossy().into_owned();
1327
1328 let out = handle_filtered(
1330 "needle",
1331 &root,
1332 None,
1333 10,
1334 CrpMode::Off,
1335 true,
1336 true,
1337 false,
1338 Some("*_test.rs"),
1339 None,
1340 )
1341 .text;
1342 assert!(out.contains("app.rs"), "app.rs must match: {out}");
1343 assert!(
1344 !out.contains("app_test.rs"),
1345 "excluded path must be dropped: {out}"
1346 );
1347
1348 let out2 = handle_filtered(
1350 "needle",
1351 &root,
1352 Some("app.rs"),
1353 10,
1354 CrpMode::Off,
1355 true,
1356 true,
1357 false,
1358 None,
1359 Some("skip me"),
1360 )
1361 .text;
1362 assert!(out2.contains("1 matches"), "one line kept: {out2}");
1363 assert!(!out2.contains("skip me"), "grep -v line dropped: {out2}");
1364 }
1365
1366 #[test]
1367 fn invalid_exclude_pattern_is_ignored_not_fatal() {
1368 let dir = tempfile::tempdir().unwrap();
1370 std::fs::write(dir.path().join("a.rs"), "needle\n").unwrap();
1371 let root = dir.path().to_string_lossy().into_owned();
1372 let out = handle_filtered(
1373 "needle",
1374 &root,
1375 None,
1376 10,
1377 CrpMode::Off,
1378 true,
1379 true,
1380 false,
1381 None,
1382 Some("([unclosed"),
1383 )
1384 .text;
1385 assert!(out.contains("a.rs"), "invalid exclude → no filter: {out}");
1386 }
1387
1388 #[test]
1389 fn search_refuses_home_directory_root() {
1390 let home = dirs::home_dir().expect("home dir in test env");
1393 let out = handle(
1394 "needle",
1395 home.to_string_lossy().as_ref(),
1396 None,
1397 10,
1398 CrpMode::Off,
1399 true,
1400 true,
1401 false,
1402 )
1403 .text;
1404 assert!(
1405 out.starts_with("ERROR:") && out.contains("refusing to scan"),
1406 "home root must be refused: {out}"
1407 );
1408 }
1409}