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