1use std::collections::HashSet;
3use std::path::Path;
4use std::path::PathBuf;
5use std::time::{Duration, Instant};
6
7use glob::Pattern;
8use ignore::WalkBuilder;
9use regex::RegexBuilder;
10
11use crate::tools::CrpMode;
12use lean_ctx::core::protocol;
13use lean_ctx::core::symbol_map::{self, SymbolMap};
14use lean_ctx::core::tokens::count_tokens;
15
16pub(crate) use lean_ctx::core::search_walk::{
17 MAX_FILE_SIZE, MAX_WALK_DEPTH, is_binary_ext, is_generated_file,
18};
19const MAX_EXPLICIT_FILE_SIZE: u64 = 64 * 1024 * 1024;
24const MAX_NAMED_SKIPS: usize = 3;
26const MAX_MATCH_LINE_WIDTH: usize = 150;
27
28pub const NATIVE_GREP_BASELINE_FACTOR: f64 = 2.5;
35
36pub struct SearchOutcome {
38 pub text: String,
40 pub modeled_baseline: usize,
43 pub observed_tokens: usize,
46 pub failed: bool,
51 pub secret_paths_withheld: usize,
54}
55
56impl SearchOutcome {
57 fn error(text: String) -> Self {
59 Self {
60 text,
61 modeled_baseline: 0,
62 observed_tokens: 0,
63 failed: true,
64 secret_paths_withheld: 0,
65 }
66 }
67
68 fn without_matches(text: String) -> Self {
70 Self {
71 text,
72 modeled_baseline: 0,
73 observed_tokens: 0,
74 failed: false,
75 secret_paths_withheld: 0,
76 }
77 }
78
79 fn from_observed(text: String, observed_tokens: usize) -> Self {
80 let modeled = (observed_tokens as f64 * NATIVE_GREP_BASELINE_FACTOR).ceil() as usize;
81 Self {
82 text,
83 modeled_baseline: modeled.max(observed_tokens),
84 observed_tokens,
85 failed: false,
86 secret_paths_withheld: 0,
87 }
88 }
89
90 fn withholding(mut self, secret_paths: usize) -> Self {
91 self.secret_paths_withheld = secret_paths;
92 self
93 }
94}
95
96fn search_deadline() -> Option<Duration> {
103 const DEFAULT_MS: u64 = 10_000;
104 let ms = std::env::var("LEAN_CTX_SEARCH_DEADLINE_MS")
105 .ok()
106 .and_then(|v| v.trim().parse::<u64>().ok())
107 .unwrap_or(DEFAULT_MS);
108 (ms > 0).then(|| Duration::from_millis(ms))
109}
110
111#[allow(clippy::too_many_arguments)]
115pub fn handle(
116 pattern: &str,
117 dir: &str,
118 include: Option<&str>,
119 max_results: usize,
120 crp_mode: CrpMode,
121 respect_gitignore: bool,
122 allow_secret_paths: bool,
123 anchored: bool,
124) -> SearchOutcome {
125 handle_filtered(
126 pattern,
127 dir,
128 include,
129 max_results,
130 crp_mode,
131 respect_gitignore,
132 allow_secret_paths,
133 anchored,
134 None,
135 None,
136 )
137}
138
139#[allow(clippy::too_many_arguments)]
154pub fn handle_filtered(
155 pattern: &str,
156 dir: &str,
157 include: Option<&str>,
158 max_results: usize,
159 crp_mode: CrpMode,
160 respect_gitignore: bool,
161 allow_secret_paths: bool,
162 anchored: bool,
163 exclude: Option<&str>,
164 exclude_pattern: Option<&str>,
165) -> SearchOutcome {
166 let literal = matches!(regex::Regex::new(pattern), Err(regex::Error::Syntax(_)))
167 .then(|| regex::escape(pattern));
168 let mut outcome = search_compiled_pattern(
169 literal.as_deref().unwrap_or(pattern),
170 dir,
171 include,
172 max_results,
173 crp_mode,
174 respect_gitignore,
175 allow_secret_paths,
176 anchored,
177 exclude,
178 exclude_pattern,
179 );
180 if literal.is_some() && !outcome.text.starts_with("ERROR:") {
181 outcome.text = format!(
182 "note: '{pattern}' is not valid regex — searched for it literally.\n{}",
183 outcome.text
184 );
185 }
186 outcome
187}
188
189#[allow(clippy::too_many_arguments)]
190fn search_compiled_pattern(
191 pattern: &str,
192 dir: &str,
193 include: Option<&str>,
194 max_results: usize,
195 _crp_mode: CrpMode,
196 respect_gitignore: bool,
197 allow_secret_paths: bool,
198 anchored: bool,
199 exclude: Option<&str>,
200 exclude_pattern: Option<&str>,
201) -> SearchOutcome {
202 let include_patterns = compile_include(include);
209 let exclude_patterns = compile_include(exclude);
212 const MAX_PATTERN_LEN: usize = 1024;
213 const MAX_REGEX_SIZE: usize = 1 << 20; let redact = lean_ctx::core::redaction::redaction_enabled_for_active_role();
216 let protected = lean_ctx::core::policy::runtime::active().is_some();
217 let protected_root = lean_ctx::core::policy::diagnostics::project();
218 let admission = lean_ctx::core::context_admission::stores::StoreAdmission::current();
221 let mut protected_budget = lean_ctx::core::limits::max_read_bytes()
222 .min(lean_ctx::core::policy::content::MAX_PROTECTED_CONTENT_BYTES);
223 let mut matched_sources: Vec<std::path::PathBuf> = Vec::new();
225 if pattern.len() > MAX_PATTERN_LEN {
226 return SearchOutcome::error(format!(
227 "ERROR: pattern too long ({} > {MAX_PATTERN_LEN} chars)",
228 pattern.len()
229 ));
230 }
231 let re = match RegexBuilder::new(pattern)
232 .size_limit(MAX_REGEX_SIZE)
233 .dfa_size_limit(MAX_REGEX_SIZE)
234 .build()
235 {
236 Ok(r) => r,
237 Err(e) => return SearchOutcome::error(format!("ERROR: invalid regex: {e}")),
238 };
239 let exclude_re = exclude_pattern.and_then(|p| {
243 RegexBuilder::new(p)
244 .size_limit(MAX_REGEX_SIZE)
245 .dfa_size_limit(MAX_REGEX_SIZE)
246 .build()
247 .ok()
248 });
249
250 let root = Path::new(dir);
251 if !root.exists() {
252 let hint = lean_ctx::core::path_suggest::hint(root);
253 return SearchOutcome::error(format!("ERROR: {dir} does not exist{hint}"));
254 }
255 if let Some(err) = crate::tools::walk_guard::deny_unsafe_walk_root(dir) {
258 return SearchOutcome::error(err);
259 }
260 let size_limit = if std::fs::metadata(root).is_ok_and(|m| m.is_file()) {
261 MAX_EXPLICIT_FILE_SIZE
262 } else {
263 MAX_FILE_SIZE
264 };
265
266 let mut files: Vec<PathBuf> = Vec::new();
267 let mut matches = Vec::new();
268 let mut raw_tokens_accum: usize = 0;
269 let mut files_searched = 0u32;
270 let mut files_skipped_size = 0u32;
271 let mut skipped_size_files: Vec<String> = Vec::new();
274 let mut files_skipped_encoding = 0u32;
275 let mut files_withheld = 0u32;
276 let mut skipped_boundary_files: Vec<(String, &'static str)> = Vec::new();
277 let mut files_skipped_special = 0u32;
278 let mut deadline_hit = false;
279 let mut cap_hit = false;
283 let mut budget_hit = false;
287
288 let mut narrowed_scope: Option<usize> = None;
298 let used_index = if !protected
299 && let Some(idx) =
300 lean_ctx::core::search_index::get_fresh(dir, respect_gitignore, allow_secret_paths)
301 {
302 let candidates = idx.candidate_paths(pattern, &include_patterns, root);
303 if matches!(
304 candidates,
305 lean_ctx::core::search_index::CandidateSet::Narrowed(_)
306 ) {
307 narrowed_scope = Some(idx.scope_count(&include_patterns, &exclude_patterns, root));
308 }
309 files = candidates.into_paths();
310 if !allow_secret_paths {
311 skipped_boundary_files = lean_ctx::core::search_index::boundary_skipped_files(
312 dir,
313 respect_gitignore,
314 &include_patterns,
315 );
316 }
317 true
318 } else {
319 false
320 };
321
322 if !used_index {
323 let walker = WalkBuilder::new(root)
328 .hidden(false)
329 .max_depth(Some(MAX_WALK_DEPTH))
330 .git_ignore(respect_gitignore)
331 .git_global(respect_gitignore)
332 .git_exclude(respect_gitignore)
333 .require_git(false)
334 .filter_entry(move |e| {
335 if respect_gitignore {
336 lean_ctx::core::walk_filter::keep_entry(e)
337 } else {
338 lean_ctx::core::cloud_files::keep_entry(e)
339 }
340 })
341 .build();
342
343 for entry in walker.filter_map(std::result::Result::ok) {
344 if entry.file_type().is_none_or(|ft| ft.is_dir()) {
345 continue;
346 }
347
348 if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
349 continue;
350 }
351
352 let path = entry.path();
353
354 if is_binary_ext(path) || is_generated_file(path) {
355 continue;
356 }
357
358 if !include_patterns.is_empty() {
359 let rel_str = filter_rel(path, root);
360 if !include_patterns.iter().any(|p| p.matches(&rel_str)) {
361 continue;
362 }
363 }
364
365 if !allow_secret_paths {
366 if let Some(reason) = lean_ctx::core::io_boundary::is_secret_like(path) {
367 skipped_boundary_files.push((filter_rel(path, root).into_owned(), reason));
368 continue;
369 }
370 }
371
372 files.push(path.to_path_buf());
376 }
377 skipped_boundary_files.sort_unstable();
378 }
379
380 if !exclude_patterns.is_empty() {
383 files.retain(|path| {
384 let rel_str = filter_rel(path, root);
385 !exclude_patterns.iter().any(|p| p.matches(&rel_str))
386 });
387 }
388
389 match lean_ctx::core::io_boundary::hive_io_rules(None) {
393 Ok(None) => {}
394 Ok(Some(rules)) => {
395 files.retain(|path| match rules.refusal(path) {
396 None => true,
397 Some(refusal) => {
398 let reason = match refusal {
399 lean_ctx::core::policy::hive_io::Refusal::OutsideRoots => {
400 "outside the Hive readable roots"
401 }
402 lean_ctx::core::policy::hive_io::Refusal::NeverSend(_) => "Hive never_send",
403 };
404 skipped_boundary_files.push((filter_rel(path, root).into_owned(), reason));
405 false
406 }
407 });
408 skipped_boundary_files.sort_unstable();
409 }
410 Err(e) => {
411 return SearchOutcome::error(format!(
412 "ERROR: [I/O BOUNDARY] search refused: {e}; reads stay refused until a valid \
413 Hive policy is installed."
414 ));
415 }
416 }
417
418 files.sort_unstable_by(|a, b| a.as_os_str().cmp(b.as_os_str()));
420
421 let files_in_scope = narrowed_scope.unwrap_or(files.len());
426 let index_pruned =
427 u32::try_from(files_in_scope.saturating_sub(files.len())).unwrap_or(u32::MAX);
428
429 let root_str = root.to_string_lossy();
430 let deadline = search_deadline().map(|budget| Instant::now() + budget);
431 for path in &files {
432 if matches.len() >= max_results {
433 cap_hit = true;
434 break;
435 }
436
437 if deadline.is_some_and(|dl| Instant::now() >= dl) {
441 deadline_hit = true;
442 break;
443 }
444
445 let file_len: u64;
450 let state = match std::fs::metadata(path) {
451 Ok(meta) if !meta.file_type().is_file() => {
452 files_skipped_special += 1;
453 continue;
454 }
455 Ok(meta) if meta.len() > size_limit => {
456 files_skipped_size += 1;
457 if skipped_size_files.len() < MAX_NAMED_SKIPS {
458 skipped_size_files.push(protocol::shorten_path_relative(
459 &path.to_string_lossy(),
460 &root_str,
461 ));
462 }
463 continue;
464 }
465 Ok(meta) => {
466 file_len = meta.len();
467 lean_ctx::core::content_cache::FileState::from_metadata(&meta)
468 }
469 Err(_) => {
470 files_skipped_encoding += 1;
471 continue;
472 }
473 };
474
475 let content: std::sync::Arc<str> = if protected {
481 if (protected_budget as u64) < file_len {
484 budget_hit = true;
485 break;
486 }
487 let admitted = protected_root.as_ref().and_then(|root| {
492 lean_ctx::core::archive::authority::without_capture(|| {
493 crate::tools::ctx_read::read_file_for_tool_rooted_budgeted(
494 path.to_str()?,
495 root.to_str()?,
496 "ctx_search",
497 &mut protected_budget,
498 )
499 .ok()
500 })
501 });
502 if let Some(text) = admitted {
503 std::sync::Arc::from(text)
504 } else {
505 files_withheld += 1;
508 continue;
509 }
510 } else if let Some(cached) = state.and_then(|s| lean_ctx::core::content_cache::get(path, s))
511 {
512 lean_ctx::core::cache::record_search_content_read(true);
513 cached
514 } else {
515 if state.is_some() {
516 lean_ctx::core::cache::record_search_content_read(false);
517 }
518 let Ok(decoded) = lean_ctx::core::text_decode::read_text_checked(path) else {
519 files_skipped_encoding += 1;
520 continue;
521 };
522 let complete = decoded.complete();
527 if !complete
528 && admission.admission_policy().gateway.mode
529 != lean_ctx_protocol::context_gateway::GatewayModeV1::Developer
530 {
531 files_withheld += 1;
532 continue;
533 }
534 let text = decoded.text;
535 let Some(text) = admission.admit(&text, path, None) else {
539 files_withheld += 1;
540 continue;
541 };
542 let arc: std::sync::Arc<str> = std::sync::Arc::from(text);
543 if complete
546 && let Some(s) = state
547 && arc.len() as u64 <= MAX_FILE_SIZE
548 {
549 lean_ctx::core::content_cache::insert(
550 path,
551 s,
552 std::sync::Arc::clone(&arc),
553 &admission,
554 );
555 }
556 arc
557 };
558
559 files_searched += 1;
560 let mut file_enclosing: Option<EnclosingIndex> = None;
563
564 for (i, line) in content.lines().enumerate() {
565 if re.is_match(line) && !exclude_re.as_ref().is_some_and(|ex| ex.is_match(line)) {
567 let short_path =
568 protocol::shorten_path_relative(&path.to_string_lossy(), &root_str);
569 let source: &Path = path.as_ref();
570 if matched_sources
571 .last()
572 .is_none_or(|last| last.as_path() != source)
573 {
574 matched_sources.push(source.to_path_buf());
575 }
576 raw_tokens_accum += count_tokens(line.trim()) + 2;
578 let mut shown = if redact {
579 lean_ctx::core::redaction::redact_text(line.trim())
580 } else {
581 line.trim().to_string()
582 };
583 if shown.len() > MAX_MATCH_LINE_WIDTH {
584 shown.truncate(shown.floor_char_boundary(MAX_MATCH_LINE_WIDTH));
585 shown.push_str("...");
586 }
587 let tag = file_enclosing
592 .get_or_insert_with(|| EnclosingIndex::for_file(path, content.as_ref()))
593 .tag_for(i + 1)
594 .unwrap_or_default();
595 if anchored {
599 matches.push(format!(
600 "{short_path}:{}:{} {}{}",
601 i + 1,
602 lean_ctx::core::anchor::line_hash(line),
603 shown,
604 tag
605 ));
606 } else {
607 matches.push(format!("{short_path}:{} {}{}", i + 1, shown, tag));
608 }
609 if matches.len() >= max_results {
610 cap_hit = true;
611 break;
612 }
613 }
614 }
615 }
616
617 let lineage_root = lean_ctx::core::policy::diagnostics::project().map_or_else(
624 || dir.to_owned(),
625 |root| root.to_string_lossy().into_owned(),
626 );
627 for source in &matched_sources {
628 crate::tools::ctx_read::publish_requested_source(
629 &source.to_string_lossy(),
630 &lineage_root,
631 "ctx_search",
632 );
633 }
634
635 if matches.len() > 1 {
638 use std::collections::HashMap;
639 let mut file_counts: HashMap<String, usize> = HashMap::new();
640 for m in &matches {
641 let file = extract_file_from_match(m).to_string();
642 *file_counts.entry(file).or_default() += 1;
643 }
644 matches.sort_by(|a, b| {
645 let fa = extract_file_from_match(a);
646 let fb = extract_file_from_match(b);
647 let ca = file_counts.get(fa).copied().unwrap_or(0);
648 let cb = file_counts.get(fb).copied().unwrap_or(0);
649 cb.cmp(&ca).then_with(|| fa.cmp(fb))
650 });
651 }
652
653 let files_covered = files_searched.saturating_add(index_pruned);
654
655 if matches.is_empty() {
656 let mut msg = format!("0 matches for '{pattern}' (scanned {files_covered} files)");
659 if files_withheld > 0 {
660 msg.push_str(&format!(
661 " ({files_withheld} sources withheld under policy)"
662 ));
663 }
664 if files_in_scope == 0 {
665 msg.push_str(&empty_scope_note(dir, include, exclude));
666 }
667 if files_skipped_size > 0 {
668 msg.push_str(&format!(
669 " ({files_skipped_size} large files skipped: {})",
670 name_skipped(&skipped_size_files, files_skipped_size)
671 ));
672 }
673 if files_skipped_encoding > 0 {
674 msg.push_str(&format!(
675 " ({files_skipped_encoding} files skipped: binary/encoding)"
676 ));
677 }
678 if let Some(note) = format_boundary_skips(&skipped_boundary_files, false) {
679 msg.push_str(¬e);
680 }
681 if files_skipped_special > 0 {
682 msg.push_str(&format!(
683 " ({files_skipped_special} special files skipped: not regular files)"
684 ));
685 }
686 if deadline_hit {
687 msg.push_str(
688 " (search stopped at the time budget — refine the pattern or scope with path=)",
689 );
690 }
691 if budget_hit {
692 msg.push_str(&budget_note(files_covered));
693 }
694 return SearchOutcome::without_matches(msg).withholding(skipped_boundary_files.len());
695 }
696
697 let matched_files: Vec<&str> = {
699 let mut seen = HashSet::new();
700 matches
701 .iter()
702 .filter_map(|m| {
703 let file = extract_file_from_match(m);
704 if seen.insert(file) { Some(file) } else { None }
705 })
706 .collect()
707 };
708
709 let mut result = format!(
713 "{} matches in {} files (scanned {files_covered})",
714 matches.len(),
715 matched_files.len()
716 );
717 if matched_files.len() > 1 {
718 if matched_files.len() <= 10 {
719 result.push_str(" [");
720 result.push_str(&matched_files.join(", "));
721 result.push(']');
722 } else {
723 let shown: Vec<&str> = matched_files.iter().take(8).copied().collect();
724 result.push_str(&format!(
725 " [{}, +{} more]",
726 shown.join(", "),
727 matched_files.len() - 8
728 ));
729 }
730 }
731 result.push_str(":\n");
732 if anchored {
734 result.push_str("[anchored: path:line:hh → edit via ctx_patch]\n");
735 }
736 result.push_str(&matches.join("\n"));
741 if files_withheld > 0 {
742 result.push_str(&format!(
743 "\n({files_withheld} sources withheld under policy)"
744 ));
745 }
746
747 if files_skipped_size > 0 {
748 result.push_str(&format!(
749 "\n({files_skipped_size} files >512KB skipped: {})",
750 name_skipped(&skipped_size_files, files_skipped_size)
751 ));
752 }
753 if files_skipped_encoding > 0 {
754 result.push_str(&format!(
755 "\n({files_skipped_encoding} files skipped: binary/encoding)"
756 ));
757 }
758 if let Some(note) = format_boundary_skips(&skipped_boundary_files, true) {
759 result.push_str(¬e);
760 }
761 if files_skipped_special > 0 {
762 result.push_str(&format!(
763 "\n({files_skipped_special} special files skipped: not regular files)"
764 ));
765 }
766 if deadline_hit {
767 result.push_str(&format!(
768 "\n(search stopped after the {}s budget — {files_covered} files scanned; \
769 refine the pattern or scope with path= for full coverage)",
770 search_deadline().map_or(0, |d| d.as_secs())
771 ));
772 }
773 if budget_hit {
774 result.push('\n');
775 result.push_str(budget_note(files_covered).trim_start());
776 }
777 if cap_hit {
782 result.push_str(&format!(
783 "\n(stopped at max_results={max_results}: {files_covered} of {files_in_scope} \
784 files scanned; counts are a floor, not a total)"
785 ));
786 }
787
788 if let Some(delta) = lean_ctx::core::search_delta::compute_delta(pattern, &matches) {
789 return SearchOutcome::from_observed(delta, raw_tokens_accum)
790 .withholding(skipped_boundary_files.len());
791 }
792
793 if symbol_map::substitution_enabled() {
794 let exts = extract_extensions(include);
795 let ext_refs: Vec<&str> = exts.iter().map(String::as_str).collect();
796 let mut sym = SymbolMap::new();
797 let idents = symbol_map::extract_identifiers(&result, &ext_refs);
798 for ident in &idents {
799 sym.register(ident);
800 }
801 if sym.len() >= 3 {
802 let sym_table = sym.format_table();
803 let compressed = sym.apply(&result);
804 let original_tok = count_tokens(&result);
805 let compressed_tok = count_tokens(&compressed) + count_tokens(&sym_table);
806 let net_saving = original_tok.saturating_sub(compressed_tok);
807 if original_tok > 0 && net_saving * 100 / original_tok >= 5 {
808 result = format!("{compressed}{sym_table}");
809 }
810 }
811 }
812
813 SearchOutcome::from_observed(result, raw_tokens_accum).withholding(skipped_boundary_files.len())
814}
815
816fn name_skipped(named: &[String], total: u32) -> String {
819 let mut out = named.join(", ");
820 let rest = (total as usize).saturating_sub(named.len());
821 if rest > 0 {
822 out.push_str(&format!(", +{rest} more"));
823 }
824 out
825}
826
827fn format_boundary_skips(skipped: &[(String, &'static str)], newline: bool) -> Option<String> {
829 if skipped.is_empty() {
830 return None;
831 }
832 const MAX_SHOW: usize = 10;
833 let n = skipped.len();
834 let details: Vec<String> = skipped
835 .iter()
836 .take(MAX_SHOW)
837 .map(|(p, r)| format!("{p} [{r}]"))
838 .collect();
839 let prefix = if newline { "\n(" } else { " (" };
840 let mut msg = format!(
841 "{prefix}{n} secret-like files skipped by boundary policy: {}",
842 details.join(", ")
843 );
844 if n > MAX_SHOW {
845 msg.push_str(&format!(", and {} more...", n - MAX_SHOW));
846 }
847 msg.push(')');
848 Some(msg)
849}
850
851struct EnclosingIndex {
859 spans: Vec<(usize, usize, String)>,
861}
862
863impl EnclosingIndex {
864 fn for_file(path: &Path, content: &str) -> Self {
865 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
866 let mut spans: Vec<(usize, usize, String)> =
867 lean_ctx::core::signatures::extract_signatures(content, ext)
868 .into_iter()
869 .filter_map(|s| match (s.start_line, s.end_line) {
870 (Some(a), Some(b)) if b > a => Some((a, b, s.name)),
871 _ => None,
872 })
873 .collect();
874 spans.sort_by(|x, y| x.0.cmp(&y.0).then(x.1.cmp(&y.1)));
875 Self { spans }
876 }
877
878 fn tag_for(&self, line: usize) -> Option<String> {
881 let mut best: Option<&(usize, usize, String)> = None;
882 for sp in &self.spans {
883 if line >= sp.0 && line <= sp.1 {
884 match best {
885 None => best = Some(sp),
886 Some(b) if (sp.1 - sp.0) < (b.1 - b.0) => best = Some(sp),
887 _ => {}
888 }
889 }
890 }
891 best.map(|(start, _, name)| format!(" ∈{name}@L{start}"))
892 }
893}
894
895const MAX_INCLUDE_GLOBS: usize = 64;
898
899fn compile_include(include: Option<&str>) -> Vec<Pattern> {
915 let Some(raw) = include else {
916 return Vec::new();
917 };
918 split_top_level_commas(raw)
919 .into_iter()
920 .flat_map(expand_braces)
921 .take(MAX_INCLUDE_GLOBS)
922 .filter(|g| !g.is_empty())
923 .map(|g| {
924 if g.contains('/') {
925 g
926 } else {
927 format!("**/{g}")
928 }
929 })
930 .filter_map(|g| Pattern::new(&g).ok())
931 .collect()
932}
933
934fn split_top_level_commas(raw: &str) -> Vec<&str> {
936 let mut pieces = Vec::new();
937 let mut depth = 0usize;
938 let mut start = 0;
939 for (i, c) in raw.char_indices() {
940 match c {
941 '{' => depth += 1,
942 '}' => depth = depth.saturating_sub(1),
943 ',' if depth == 0 => {
944 pieces.push(raw[start..i].trim());
945 start = i + 1;
946 }
947 _ => {}
948 }
949 }
950 pieces.push(raw[start..].trim());
951 pieces
952}
953
954fn budget_note(files_covered: u32) -> String {
958 format!(
959 " (search stopped after {files_covered} files: the protected-content read budget of this \
960 search is spent; the remaining files were not scanned, not withheld — narrow with \
961 path= or include= for full coverage)"
962 )
963}
964
965fn filter_rel<'a>(path: &'a Path, root: &Path) -> std::borrow::Cow<'a, str> {
969 match path.strip_prefix(root) {
970 Ok(rel) if !rel.as_os_str().is_empty() => rel.to_string_lossy(),
971 _ => path
972 .file_name()
973 .map_or_else(|| path.to_string_lossy(), |name| name.to_string_lossy()),
974 }
975}
976
977fn empty_scope_note(dir: &str, include: Option<&str>, exclude: Option<&str>) -> String {
980 match (include, exclude) {
981 (Some(include), _) => format!(
982 " — include=`{include}` admits no file under {dir}, so nothing was searched. \
983 Check the glob; a list is `*.{{go,yaml}}` or `*.go,*.yaml`."
984 ),
985 (None, Some(exclude)) => format!(
986 " — exclude=`{exclude}` removes every file under {dir}, so nothing was searched."
987 ),
988 (None, None) => format!(" — {dir} holds no searchable text file."),
989 }
990}
991
992fn expand_braces(pattern: &str) -> Vec<String> {
996 let Some(open) = pattern.find('{') else {
997 return vec![pattern.to_string()];
998 };
999 let Some(close_rel) = pattern[open..].find('}') else {
1000 return vec![pattern.to_string()];
1001 };
1002 let close = open + close_rel;
1003 let prefix = &pattern[..open];
1004 let inner = &pattern[open + 1..close];
1005 let suffix = &pattern[close + 1..];
1006
1007 let mut out = Vec::new();
1008 for alt in inner.split(',') {
1009 let alt = alt.trim();
1010 for expanded_suffix in expand_braces(suffix) {
1011 out.push(format!("{prefix}{alt}{expanded_suffix}"));
1012 if out.len() >= MAX_INCLUDE_GLOBS {
1013 return out;
1014 }
1015 }
1016 }
1017 out
1018}
1019
1020fn extract_extensions(include: Option<&str>) -> Vec<String> {
1030 let Some(list) = include else {
1031 return Vec::new();
1032 };
1033 split_top_level_commas(list)
1034 .into_iter()
1035 .flat_map(extensions_of_glob)
1036 .collect()
1037}
1038
1039fn extensions_of_glob(pattern: &str) -> Vec<String> {
1040 let filename = pattern.rsplit('/').next().unwrap_or(pattern);
1041 let Some(dot) = filename.rfind('.') else {
1042 return Vec::new();
1043 };
1044 let ext_part = &filename[dot + 1..];
1045
1046 if let Some(inner) = ext_part.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
1047 return inner
1048 .split(',')
1049 .map(|e| e.trim().to_string())
1050 .filter(|e| !e.is_empty())
1051 .collect();
1052 }
1053
1054 if ext_part.is_empty() {
1055 return Vec::new();
1056 }
1057 vec![ext_part.to_string()]
1058}
1059
1060fn extract_file_from_match(line: &str) -> &str {
1062 let start = if line.len() >= 2
1063 && line.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
1064 && line.as_bytes().get(1) == Some(&b':')
1065 {
1066 2
1067 } else {
1068 0
1069 };
1070 match line[start..].find(':') {
1071 Some(pos) => &line[..start + pos],
1072 None => line,
1073 }
1074}
1075
1076#[cfg(test)]
1077#[path = "tests.rs"]
1078mod tests;