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
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 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 files.push(path.to_path_buf());
182 }
183 }
184
185 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 if deadline.is_some_and(|dl| Instant::now() >= dl) {
199 deadline_hit = true;
200 break;
201 }
202
203 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 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 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 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 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
452const MAX_INCLUDE_GLOBS: usize = 64;
455
456fn 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
473fn 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
501fn 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
534fn 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 #[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 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 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 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 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 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 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 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 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}