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(
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 let include_patterns = compile_include(include);
89 const MAX_PATTERN_LEN: usize = 1024;
90 const MAX_REGEX_SIZE: usize = 1 << 20; 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 if let Some(err) = crate::tools::walk_guard::deny_unsafe_walk_root(dir) {
115 return SearchOutcome::error(err);
116 }
117
118 let mut files: Vec<PathBuf> = Vec::new();
119 let mut matches = Vec::new();
120 let mut raw_tokens_accum: usize = 0;
121 let mut files_searched = 0u32;
122 let mut files_skipped_size = 0u32;
123 let mut files_skipped_encoding = 0u32;
124 let mut files_skipped_boundary = 0u32;
125 let mut files_skipped_special = 0u32;
126 let mut deadline_hit = false;
127
128 let used_index = if let Some(idx) =
135 crate::core::search_index::get_fresh(dir, respect_gitignore, allow_secret_paths)
136 {
137 files = idx
138 .candidate_paths(pattern, &include_patterns, root)
139 .into_paths();
140 true
141 } else {
142 false
143 };
144
145 if !used_index {
146 let walker = WalkBuilder::new(root)
149 .hidden(true)
150 .max_depth(Some(MAX_WALK_DEPTH))
151 .git_ignore(respect_gitignore)
152 .git_global(respect_gitignore)
153 .git_exclude(respect_gitignore)
154 .require_git(false)
155 .filter_entry(move |e| {
156 if respect_gitignore {
157 crate::core::walk_filter::keep_entry(e)
158 } else {
159 crate::core::cloud_files::keep_entry(e)
160 }
161 })
162 .build();
163
164 for entry in walker.filter_map(std::result::Result::ok) {
165 if entry.file_type().is_none_or(|ft| ft.is_dir()) {
166 continue;
167 }
168
169 if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
170 continue;
171 }
172
173 let path = entry.path();
174
175 if is_binary_ext(path) || is_generated_file(path) {
176 continue;
177 }
178
179 if !allow_secret_paths && crate::core::io_boundary::is_secret_like(path).is_some() {
180 files_skipped_boundary += 1;
181 continue;
182 }
183
184 if !include_patterns.is_empty() {
185 let rel = path.strip_prefix(root).unwrap_or(path);
186 let rel_str = rel.to_string_lossy();
187 if !include_patterns.iter().any(|p| p.matches(&rel_str)) {
188 continue;
189 }
190 }
191
192 files.push(path.to_path_buf());
196 }
197 }
198
199 files.sort_unstable_by(|a, b| a.as_os_str().cmp(b.as_os_str()));
201
202 let root_str = root.to_string_lossy();
203 let deadline = search_deadline().map(|budget| Instant::now() + budget);
204 for path in &files {
205 if matches.len() >= max_results {
206 break;
207 }
208
209 if deadline.is_some_and(|dl| Instant::now() >= dl) {
213 deadline_hit = true;
214 break;
215 }
216
217 let state = match std::fs::metadata(path) {
222 Ok(meta) if !meta.file_type().is_file() => {
223 files_skipped_special += 1;
224 continue;
225 }
226 Ok(meta) if meta.len() > MAX_FILE_SIZE => {
227 files_skipped_size += 1;
228 continue;
229 }
230 Ok(meta) => crate::core::content_cache::FileState::from_metadata(&meta),
231 Err(_) => {
232 files_skipped_encoding += 1;
233 continue;
234 }
235 };
236
237 let content: std::sync::Arc<str> =
243 if let Some(cached) = state.and_then(|s| crate::core::content_cache::get(path, s)) {
244 cached
245 } else {
246 let Ok(text) = std::fs::read_to_string(path) else {
247 files_skipped_encoding += 1;
248 continue;
249 };
250 let arc: std::sync::Arc<str> = std::sync::Arc::from(text);
251 if let Some(s) = state {
252 crate::core::content_cache::insert(path, s, std::sync::Arc::clone(&arc));
253 }
254 arc
255 };
256
257 files_searched += 1;
258
259 for (i, line) in content.lines().enumerate() {
260 if re.is_match(line) {
261 let short_path =
262 protocol::shorten_path_relative(&path.to_string_lossy(), &root_str);
263 raw_tokens_accum += count_tokens(line.trim()) + 2;
265 let mut shown = if redact {
266 crate::core::redaction::redact_text(line.trim())
267 } else {
268 line.trim().to_string()
269 };
270 if shown.len() > MAX_MATCH_LINE_WIDTH {
271 shown.truncate(shown.floor_char_boundary(MAX_MATCH_LINE_WIDTH));
272 shown.push_str("...");
273 }
274 matches.push(format!("{short_path}:{} {}", i + 1, shown));
275 if matches.len() >= max_results {
276 break;
277 }
278 }
279 }
280 }
281
282 if matches.is_empty() {
283 let mut msg = format!("0 matches for '{pattern}' in {files_searched} files");
284 if files_skipped_size > 0 {
285 msg.push_str(&format!(" ({files_skipped_size} large files skipped)"));
286 }
287 if files_skipped_encoding > 0 {
288 msg.push_str(&format!(
289 " ({files_skipped_encoding} files skipped: binary/encoding)"
290 ));
291 }
292 if files_skipped_boundary > 0 {
293 msg.push_str(&format!(
294 " ({files_skipped_boundary} secret-like files skipped by boundary policy)"
295 ));
296 }
297 if files_skipped_special > 0 {
298 msg.push_str(&format!(
299 " ({files_skipped_special} special files skipped: not regular files)"
300 ));
301 }
302 if deadline_hit {
303 msg.push_str(
304 " (search stopped at the time budget — refine the pattern or scope with path=)",
305 );
306 }
307 return SearchOutcome::error(msg);
308 }
309
310 let matched_files: Vec<&str> = {
312 let mut seen = HashSet::new();
313 matches
314 .iter()
315 .filter_map(|m| {
316 let file = extract_file_from_match(m);
317 if seen.insert(file) { Some(file) } else { None }
318 })
319 .collect()
320 };
321
322 let mut result = format!("{} matches in {} files", matches.len(), files_searched);
323 if matched_files.len() > 1 {
324 if matched_files.len() <= 10 {
325 result.push_str(" [");
326 result.push_str(&matched_files.join(", "));
327 result.push(']');
328 } else {
329 let shown: Vec<&str> = matched_files.iter().take(8).copied().collect();
330 result.push_str(&format!(
331 " [{}, +{} more]",
332 shown.join(", "),
333 matched_files.len() - 8
334 ));
335 }
336 }
337 result.push_str(":\n");
338 result.push_str(&matches.join("\n"));
339
340 if files_skipped_size > 0 {
341 result.push_str(&format!("\n({files_skipped_size} files >512KB skipped)"));
342 }
343 if files_skipped_encoding > 0 {
344 result.push_str(&format!(
345 "\n({files_skipped_encoding} files skipped: binary/encoding)"
346 ));
347 }
348 if files_skipped_boundary > 0 {
349 result.push_str(&format!(
350 "\n({files_skipped_boundary} secret-like files skipped by boundary policy)"
351 ));
352 }
353 if files_skipped_special > 0 {
354 result.push_str(&format!(
355 "\n({files_skipped_special} special files skipped: not regular files)"
356 ));
357 }
358 if deadline_hit {
359 result.push_str(&format!(
360 "\n(search stopped after the {}s budget — {files_searched} files scanned; \
361 refine the pattern or scope with path= for full coverage)",
362 search_deadline().map_or(0, |d| d.as_secs())
363 ));
364 }
365
366 let scope_hint = monorepo_scope_hint(&matches, dir);
370
371 if let Some(delta) = crate::core::search_delta::compute_delta(pattern, &matches) {
372 return SearchOutcome::from_observed(delta, raw_tokens_accum);
373 }
374
375 if symbol_map::substitution_enabled() {
376 let exts = extract_extensions(include);
377 let ext_refs: Vec<&str> = exts.iter().map(String::as_str).collect();
378 let mut sym = SymbolMap::new();
379 let idents = symbol_map::extract_identifiers(&result, &ext_refs);
380 for ident in &idents {
381 sym.register(ident);
382 }
383 if sym.len() >= 3 {
384 let sym_table = sym.format_table();
385 let compressed = sym.apply(&result);
386 let original_tok = count_tokens(&result);
387 let compressed_tok = count_tokens(&compressed) + count_tokens(&sym_table);
388 let net_saving = original_tok.saturating_sub(compressed_tok);
389 if original_tok > 0 && net_saving * 100 / original_tok >= 5 {
390 result = format!("{compressed}{sym_table}");
391 }
392 }
393 }
394
395 if let Some(hint) = scope_hint {
396 result.push_str(&hint);
397 }
398
399 SearchOutcome::from_observed(result, raw_tokens_accum)
400}
401
402pub(crate) fn is_binary_ext(path: &Path) -> bool {
403 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
404 matches!(
405 ext,
406 "png"
407 | "jpg"
408 | "jpeg"
409 | "gif"
410 | "webp"
411 | "ico"
412 | "svg"
413 | "woff"
414 | "woff2"
415 | "ttf"
416 | "eot"
417 | "pdf"
418 | "zip"
419 | "tar"
420 | "gz"
421 | "br"
422 | "zst"
423 | "bz2"
424 | "xz"
425 | "mp3"
426 | "mp4"
427 | "webm"
428 | "ogg"
429 | "wasm"
430 | "so"
431 | "dylib"
432 | "dll"
433 | "exe"
434 | "lock"
435 | "map"
436 | "snap"
437 | "patch"
438 | "db"
439 | "sqlite"
440 | "parquet"
441 | "arrow"
442 | "bin"
443 | "o"
444 | "a"
445 | "class"
446 | "pyc"
447 | "pyo"
448 )
449}
450
451pub(crate) fn is_generated_file(path: &Path) -> bool {
452 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
453 name.ends_with(".min.js")
454 || name.ends_with(".min.css")
455 || name.ends_with(".bundle.js")
456 || name.ends_with(".chunk.js")
457 || name.ends_with(".d.ts")
458 || name.ends_with(".js.map")
459 || name.ends_with(".css.map")
460}
461
462const MAX_INCLUDE_GLOBS: usize = 64;
465
466fn compile_include(include: Option<&str>) -> Vec<Pattern> {
473 let Some(raw) = include else {
474 return Vec::new();
475 };
476 expand_braces(raw)
477 .into_iter()
478 .take(MAX_INCLUDE_GLOBS)
479 .filter_map(|g| Pattern::new(&g).ok())
480 .collect()
481}
482
483fn expand_braces(pattern: &str) -> Vec<String> {
487 let Some(open) = pattern.find('{') else {
488 return vec![pattern.to_string()];
489 };
490 let Some(close_rel) = pattern[open..].find('}') else {
491 return vec![pattern.to_string()];
492 };
493 let close = open + close_rel;
494 let prefix = &pattern[..open];
495 let inner = &pattern[open + 1..close];
496 let suffix = &pattern[close + 1..];
497
498 let mut out = Vec::new();
499 for alt in inner.split(',') {
500 let alt = alt.trim();
501 for expanded_suffix in expand_braces(suffix) {
502 out.push(format!("{prefix}{alt}{expanded_suffix}"));
503 if out.len() >= MAX_INCLUDE_GLOBS {
504 return out;
505 }
506 }
507 }
508 out
509}
510
511fn extract_extensions(include: Option<&str>) -> Vec<String> {
521 let Some(pattern) = include else {
522 return Vec::new();
523 };
524 let filename = pattern.rsplit('/').next().unwrap_or(pattern);
525 let Some(dot) = filename.rfind('.') else {
526 return Vec::new();
527 };
528 let ext_part = &filename[dot + 1..];
529
530 if let Some(inner) = ext_part.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
531 return inner
532 .split(',')
533 .map(|e| e.trim().to_string())
534 .filter(|e| !e.is_empty())
535 .collect();
536 }
537
538 if ext_part.is_empty() {
539 return Vec::new();
540 }
541 vec![ext_part.to_string()]
542}
543
544fn extract_file_from_match(line: &str) -> &str {
546 let start = if line.len() >= 2
547 && line.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
548 && line.as_bytes().get(1) == Some(&b':')
549 {
550 2
551 } else {
552 0
553 };
554 match line[start..].find(':') {
555 Some(pos) => &line[..start + pos],
556 None => line,
557 }
558}
559
560fn monorepo_scope_hint(matches: &[String], search_dir: &str) -> Option<String> {
561 let top_dirs: HashSet<&str> = matches
562 .iter()
563 .filter_map(|m| {
564 let path = extract_file_from_match(m);
565 let relative = path.strip_prefix("./").unwrap_or(path);
566 let relative = relative.strip_prefix(search_dir).unwrap_or(relative);
567 let relative = relative.strip_prefix('/').unwrap_or(relative);
568 relative.split('/').next()
569 })
570 .collect();
571
572 if top_dirs.len() > 3 {
573 let mut dirs: Vec<&&str> = top_dirs.iter().collect();
574 dirs.sort();
575 let dir_list: Vec<String> = dirs.iter().take(6).map(|d| format!("'{d}'")).collect();
576 let extra = if top_dirs.len() > 6 {
577 format!(", +{} more", top_dirs.len() - 6)
578 } else {
579 String::new()
580 };
581 Some(format!(
582 "\n\nResults span {} directories ({}{}). \
583 Use the 'path' parameter to scope to a specific service, \
584 e.g. path=\"{}/\".",
585 top_dirs.len(),
586 dir_list.join(", "),
587 extra,
588 dirs[0]
589 ))
590 } else {
591 None
592 }
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598 use crate::tools::CrpMode;
599
600 #[test]
604 fn search_output_is_byte_stable_across_calls() {
605 let dir = tempfile::tempdir().unwrap();
606 for i in 0..5 {
607 std::fs::write(
608 dir.path().join(format!("f{i}.rs")),
609 format!("fn target_{i}() {{}}\nfn other() {{}}\n"),
610 )
611 .unwrap();
612 }
613 let root = dir.path().to_string_lossy().into_owned();
614 let run = || handle("target", &root, Some("*.rs"), 20, CrpMode::Off, true, true).text;
615 assert_eq!(run(), run(), "search output must be deterministic");
616 }
617
618 #[test]
619 fn search_results_are_deterministically_ordered_by_path() {
620 let dir = tempfile::tempdir().unwrap();
621 let a = dir.path().join("a.txt");
622 let b = dir.path().join("b.txt");
623 std::fs::write(&b, "match\n").unwrap();
624 std::fs::write(&a, "match\n").unwrap();
625
626 let out = handle(
627 "match",
628 dir.path().to_string_lossy().as_ref(),
629 Some("*.txt"),
630 10,
631 CrpMode::Off,
632 true,
633 true,
634 )
635 .text;
636
637 let mut match_lines: Vec<&str> = out
638 .lines()
639 .filter(|l| l.contains(".txt:") && l.contains("match"))
640 .collect();
641 match_lines.truncate(2);
643 assert_eq!(match_lines.len(), 2);
644 assert!(
645 match_lines[0].contains("a.txt:"),
646 "first match should come from a.txt, got: {}",
647 match_lines[0]
648 );
649 assert!(
650 match_lines[1].contains("b.txt:"),
651 "second match should come from b.txt, got: {}",
652 match_lines[1]
653 );
654 }
655
656 #[test]
657 fn warm_index_and_content_cache_path_returns_correct_matches() {
658 let dir = tempfile::tempdir().unwrap();
664 std::fs::write(
665 dir.path().join("a.rs"),
666 "fn authenticate() {}\nlet x = 1;\n",
667 )
668 .unwrap();
669 std::fs::write(dir.path().join("b.rs"), "fn connect() {}\n").unwrap();
670 let root = dir.path().to_string_lossy().to_string();
671
672 assert!(
675 crate::core::search_index::warm_blocking(&root, true, false),
676 "index should warm for a small clean corpus"
677 );
678
679 let out = handle("authenticate", &root, None, 10, CrpMode::Off, true, false).text;
680 assert!(
681 out.contains("a.rs"),
682 "warm-index + cache search must find the match: {out}"
683 );
684 assert!(
685 out.contains("authenticate"),
686 "the matched line must be present: {out}"
687 );
688 assert!(
689 !out.contains("b.rs"),
690 "a non-matching file must not appear in results: {out}"
691 );
692 }
693
694 #[test]
695 fn symbol_substitution_is_off_by_default() {
696 let _lock = crate::core::data_dir::test_env_lock();
697 crate::test_env::remove_var("LEAN_CTX_SYMBOL_MAP");
698 let dir = tempfile::tempdir().unwrap();
699 let f = dir.path().join("a.rs");
700 std::fs::write(
701 &f,
702 "fn longIdentifierAlpha() {}\nfn longIdentifierBeta() {}\nfn longIdentifierGamma() {}\n",
703 )
704 .unwrap();
705
706 let out = handle(
707 "longIdentifier",
708 dir.path().to_string_lossy().as_ref(),
709 Some("*.rs"),
710 10,
711 CrpMode::Off,
712 true,
713 true,
714 )
715 .text;
716
717 assert!(
718 !out.contains("§MAP"),
719 "default agent-facing output must not carry a §MAP table: {out}"
720 );
721 assert!(
722 !out.contains('α'),
723 "default agent-facing output must not carry α-symbols: {out}"
724 );
725 assert!(
726 out.contains("longIdentifierAlpha"),
727 "identifiers should appear raw by default: {out}"
728 );
729 }
730
731 #[test]
732 fn secret_like_files_are_skipped_by_default() {
733 let dir = tempfile::tempdir().unwrap();
734 let secret = dir.path().join("key.pem");
735 let ok = dir.path().join("ok.txt");
736 std::fs::write(&secret, "match\n").unwrap();
737 std::fs::write(&ok, "match\n").unwrap();
738
739 let out = handle(
740 "match",
741 dir.path().to_string_lossy().as_ref(),
742 None,
743 10,
744 CrpMode::Off,
745 true,
746 false,
747 )
748 .text;
749
750 assert!(out.contains("ok.txt:"), "expected ok.txt match, got: {out}");
751 assert!(
752 !out.contains("key.pem:"),
753 "secret-like file should be skipped, got: {out}"
754 );
755 assert!(
756 out.contains("secret-like files skipped"),
757 "expected boundary skip note, got: {out}"
758 );
759 }
760
761 #[test]
762 #[cfg(unix)]
763 fn search_skips_named_pipe_without_hanging() {
764 use std::sync::mpsc;
765 let dir = tempfile::tempdir().unwrap();
769 std::fs::write(dir.path().join("real.txt"), "needle_here = 1\n").unwrap();
770 let fifo = dir.path().join("pipe.fifo");
771 let c = std::ffi::CString::new(fifo.to_string_lossy().as_bytes()).unwrap();
772 assert_eq!(
773 unsafe { libc::mkfifo(c.as_ptr(), 0o644) },
776 0,
777 "mkfifo failed"
778 );
779
780 let dir_path = dir.path().to_string_lossy().to_string();
781 let (tx, rx) = mpsc::channel();
782 std::thread::spawn(move || {
783 let out = handle("needle_here", &dir_path, None, 10, CrpMode::Off, true, true).text;
785 let _ = tx.send(out);
786 });
787 let out = rx
788 .recv_timeout(Duration::from_secs(5))
789 .expect("ctx_search hung on a FIFO (#336 regression)");
790
791 assert!(
792 out.contains("real.txt"),
793 "the real file must still match: {out}"
794 );
795 assert!(
796 out.contains("special files skipped"),
797 "the FIFO must be reported as a skipped special file: {out}"
798 );
799 }
800
801 #[test]
802 fn search_deadline_env_override_is_respected() {
803 let _lock = crate::core::data_dir::test_env_lock();
804 crate::test_env::set_var("LEAN_CTX_SEARCH_DEADLINE_MS", "0");
805 assert!(search_deadline().is_none(), "0 must disable the deadline");
806 crate::test_env::set_var("LEAN_CTX_SEARCH_DEADLINE_MS", "250");
807 assert_eq!(search_deadline(), Some(Duration::from_millis(250)));
808 crate::test_env::remove_var("LEAN_CTX_SEARCH_DEADLINE_MS");
809 assert_eq!(
810 search_deadline(),
811 Some(Duration::from_secs(10)),
812 "default budget is 10s"
813 );
814 }
815
816 #[test]
817 fn extract_extensions_handles_single_brace_and_none() {
818 assert_eq!(extract_extensions(Some("*.rs")), vec!["rs"]);
819 assert_eq!(extract_extensions(Some("src/**/*.tsx")), vec!["tsx"]);
820 assert_eq!(extract_extensions(Some("*.{rs,ts}")), vec!["rs", "ts"]);
821 assert_eq!(
822 extract_extensions(Some("*.{rs, ts , js}")),
823 vec!["rs", "ts", "js"]
824 );
825 assert_eq!(extract_extensions(None), Vec::<String>::new());
826 }
827
828 #[test]
829 fn extract_extensions_ignores_dots_in_directory_segments() {
830 assert_eq!(
832 extract_extensions(Some("config.v2/src/**/*.rs")),
833 vec!["rs"]
834 );
835 assert_eq!(extract_extensions(Some("src/v2.0/*.module.ts")), vec!["ts"]);
836 assert_eq!(extract_extensions(Some("src/**/*")), Vec::<String>::new());
838 assert_eq!(
839 extract_extensions(Some("config.v2/Makefile")),
840 Vec::<String>::new()
841 );
842 }
843
844 #[test]
845 fn include_glob_filters_by_brace_expansion() {
846 let dir = tempfile::tempdir().unwrap();
847 std::fs::write(dir.path().join("a.rs"), "needle\n").unwrap();
848 std::fs::write(dir.path().join("b.ts"), "needle\n").unwrap();
849 std::fs::write(dir.path().join("c.py"), "needle\n").unwrap();
850
851 let out = handle(
852 "needle",
853 dir.path().to_string_lossy().as_ref(),
854 Some("*.{rs,ts}"),
855 10,
856 CrpMode::Off,
857 true,
858 true,
859 )
860 .text;
861
862 assert!(out.contains("a.rs"), "rs file must match: {out}");
863 assert!(out.contains("b.ts"), "ts file must match: {out}");
864 assert!(!out.contains("c.py"), "py file must be excluded: {out}");
865 }
866
867 #[test]
868 fn include_glob_recursive_path_pattern() {
869 let dir = tempfile::tempdir().unwrap();
870 std::fs::create_dir_all(dir.path().join("src/inner")).unwrap();
871 std::fs::write(dir.path().join("src/inner/deep.rs"), "needle\n").unwrap();
872 std::fs::write(dir.path().join("top.rs"), "needle\n").unwrap();
873
874 let out = handle(
875 "needle",
876 dir.path().to_string_lossy().as_ref(),
877 Some("src/**/*.rs"),
878 10,
879 CrpMode::Off,
880 true,
881 true,
882 )
883 .text;
884
885 assert!(out.contains("deep.rs"), "nested match expected: {out}");
886 assert!(
887 !out.contains("top.rs"),
888 "root file outside src/ must be excluded: {out}"
889 );
890 }
891
892 #[test]
893 fn search_refuses_home_directory_root() {
894 let home = dirs::home_dir().expect("home dir in test env");
897 let out = handle(
898 "needle",
899 home.to_string_lossy().as_ref(),
900 None,
901 10,
902 CrpMode::Off,
903 true,
904 true,
905 )
906 .text;
907 assert!(
908 out.starts_with("ERROR:") && out.contains("refusing to scan"),
909 "home root must be refused: {out}"
910 );
911 }
912}