返回 CodeWhale
search.rs
根目录 / crates / tui / src / tools / search.rs
1 //! Search tools: `grep_files` for code search
2 //!
3 //! These tools provide powerful code search capabilities within the workspace,
4 //! similar to ripgrep/grep functionality.
5
6 use super::file::{
7 PATH_ALIASES, SEARCH_CONTENT_ALIASES, SEARCH_CONTENT_PARAMS, apply_param_aliases,
8 };
9 use super::spec::{
10 ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_bool, optional_str,
11 optional_u64, required_str,
12 };
13 use async_trait::async_trait;
14 use regex::Regex;
15 use serde::{Deserialize, Serialize};
16 use serde_json::{Value, json};
17 use std::collections::{HashSet, VecDeque};
18 use std::fs;
19 use std::io::BufRead;
20 use std::path::{Path, PathBuf};
21 use std::time::Duration;
22 use tokio_util::sync::CancellationToken;
23
24 /// Maximum number of results to return to avoid overwhelming output
25 const MAX_RESULTS: usize = 100;
26
27 /// Maximum file size to search (skip large binaries)
28 const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; // 10MB
29
30 /// Hard cap on a single grep_files run. The directory walk plus per-file regex
31 /// is synchronous blocking work; without this it can run for minutes on a large
32 /// tree. Mirrors the file_search tool so both blocking searches behave the same.
33 const GREP_FILES_TIMEOUT: Duration = Duration::from_secs(30);
34
35 /// Result of a grep match
36 #[derive(Debug, Clone, Serialize, Deserialize)]
37 pub struct GrepMatch {
38 pub file: String,
39 pub line_number: usize,
40 pub line: String,
41 pub context_before: Vec<String>,
42 pub context_after: Vec<String>,
43 }
44
45 /// Tool for searching files using regex patterns
46 pub struct GrepFilesTool;
47
48 #[async_trait]
49 impl ToolSpec for GrepFilesTool {
50 fn name(&self) -> &'static str {
51 "grep_files"
52 }
53
54 fn model_visible(&self) -> bool {
55 false
56 }
57
58 fn description(&self) -> &'static str {
59 "Search for a regex pattern in workspace files. Use this instead of `grep -r`, `rg`, or `find ... -exec grep` in `exec_shell` — pure-Rust, faster, and skips common non-code directories (node_modules, .git, target, ...) by default. Returns matching lines with context (default: 2 lines before/after each match)."
60 }
61
62 fn input_schema(&self) -> Value {
63 json!({
64 "type": "object",
65 "properties": {
66 "pattern": {
67 "type": "string",
68 "description": "Regular expression pattern to search for"
69 },
70 "path": {
71 "type": "string",
72 "description": "Directory or file to search (relative to workspace, default: .)"
73 },
74 "include": {
75 "type": "array",
76 "items": {"type": "string"},
77 "description": "Glob patterns for files to include (e.g., ['*.rs', '*.ts'])"
78 },
79 "exclude": {
80 "type": "array",
81 "items": {"type": "string"},
82 "description": "Glob patterns for files to exclude (e.g., ['*.min.js', 'node_modules/*'])"
83 },
84 "context_lines": {
85 "type": "integer",
86 "description": "Number of context lines before and after each match (default: 2)"
87 },
88 "case_insensitive": {
89 "type": "boolean",
90 "description": "Whether to perform case-insensitive matching (default: false)"
91 },
92 "max_results": {
93 "type": "integer",
94 "description": "Maximum number of results to return (default: 100)"
95 }
96 },
97 "required": ["pattern"]
98 })
99 }
100
101 fn capabilities(&self) -> Vec<ToolCapability> {
102 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
103 }
104
105 fn supports_parallel(&self) -> bool {
106 true
107 }
108
109 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
110 let mut input = input;
111 apply_param_aliases(&mut input, PATH_ALIASES, "File search_content")?;
112 apply_param_aliases(&mut input, SEARCH_CONTENT_ALIASES, "File search_content")?;
113 SEARCH_CONTENT_PARAMS.reject_unknown(&input)?;
114
115 let pattern_str = required_str(&input, "pattern")?;
116 let path_str = optional_str(&input, "path")?.unwrap_or(".");
117 let context_lines = usize::try_from(optional_u64(&input, "context_lines", 2)?)
118 .unwrap_or(usize::MAX)
119 .min(1000);
120 let case_insensitive = optional_bool(&input, "case_insensitive", false)?;
121 let max_results = usize::try_from(optional_u64(&input, "max_results", MAX_RESULTS as u64)?)
122 .unwrap_or(MAX_RESULTS);
123
124 // Parse include patterns
125 let include_patterns: Vec<String> = input
126 .get("include")
127 .and_then(|v| v.as_array())
128 .map(|arr| {
129 arr.iter()
130 .filter_map(|v| v.as_str().map(String::from))
131 .collect()
132 })
133 .unwrap_or_default();
134
135 // Parse exclude patterns
136 let exclude_patterns: Vec<String> =
137 input.get("exclude").and_then(|v| v.as_array()).map_or_else(
138 || {
139 // Default exclusions for common non-code directories.
140 // Bare directory names skip the directory traversal entirely;
141 // `dir/*` filters files inside if the directory is already
142 // being walked (belt-and-suspenders — see #2200).
143 vec![
144 "node_modules".to_string(),
145 "node_modules/*".to_string(),
146 ".git".to_string(),
147 ".git/*".to_string(),
148 "target".to_string(),
149 "target/*".to_string(),
150 "*.min.js".to_string(),
151 "*.min.css".to_string(),
152 "dist".to_string(),
153 "dist/*".to_string(),
154 "build".to_string(),
155 "build/*".to_string(),
156 "__pycache__".to_string(),
157 "__pycache__/*".to_string(),
158 ".venv".to_string(),
159 ".venv/*".to_string(),
160 "venv".to_string(),
161 "venv/*".to_string(),
162 ]
163 },
164 |arr| {
165 arr.iter()
166 .filter_map(|v| v.as_str().map(String::from))
167 .collect()
168 },
169 );
170
171 // Build regex
172 let regex_pattern = if case_insensitive {
173 format!("(?i){pattern_str}")
174 } else {
175 pattern_str.to_string()
176 };
177
178 let regex = Regex::new(&regex_pattern)
179 .map_err(|e| ToolError::invalid_input(format!("Invalid regex pattern: {e}")))?;
180
181 // Resolve search path
182 let search_path = context.resolve_path(path_str)?;
183
184 let workspace = context.workspace.clone();
185 let cancel_token = context.cancel_token.clone();
186 let follow_symlinks = context.follow_symlinks;
187
188 // The directory walk and per-file regex are synchronous blocking work.
189 // Run them on a blocking worker bounded by a hard timeout so a huge tree
190 // can't pin the async runtime and leave the stop button unresponsive.
191 let result = run_blocking_grep(GREP_FILES_TIMEOUT, cancel_token.clone(), move || {
192 let cancel_token = cancel_token.as_ref();
193
194 // Stream the walk: each file is searched as it is discovered and
195 // the traversal stops as soon as the match budget is exhausted.
196 // Files are never materialized in a big Vec and file contents are
197 // read line-by-line, so memory stays bounded by the result set.
198 let mut results: Vec<GrepMatch> = Vec::new();
199 let mut files_searched = 0;
200 let mut total_matches = 0;
201
202 visit_files(
203 &search_path,
204 &include_patterns,
205 &exclude_patterns,
206 cancel_token,
207 follow_symlinks,
208 &mut |file_path| {
209 if results.len() >= max_results {
210 return Ok(WalkControl::Stop);
211 }
212 check_cancelled(cancel_token)?;
213
214 // Skip files that are too large
215 if let Ok(metadata) = fs::metadata(file_path)
216 && metadata.len() > MAX_FILE_SIZE
217 {
218 return Ok(WalkControl::Continue);
219 }
220
221 // Get relative path from workspace
222 let relative_path = file_path
223 .strip_prefix(&workspace)
224 .unwrap_or(file_path)
225 .to_string_lossy()
226 .to_string();
227
228 let budget = max_results - results.len();
229 let Some(file_matches) = search_file_streaming(
230 file_path,
231 &relative_path,
232 &regex,
233 context_lines,
234 budget,
235 cancel_token,
236 )?
237 else {
238 return Ok(WalkControl::Continue); // Skip binary or unreadable files
239 };
240
241 files_searched += 1;
242 total_matches += file_matches.len();
243 results.extend(file_matches);
244 Ok(WalkControl::Continue)
245 },
246 )?;
247
248 let matches_json: Vec<Value> = results
249 .iter()
250 .map(|item| grep_match_to_json(item, context_lines))
251 .collect();
252
253 // Build result. When context_lines == 1, return the single context
254 // line as a string instead of a one-item array. That keeps the common
255 // "show just the adjacent line" case easy for model callers to read.
256 Ok(json!({
257 "matches": matches_json,
258 "total_matches": total_matches,
259 "files_searched": files_searched,
260 "truncated": total_matches > max_results,
261 }))
262 })
263 .await?;
264
265 ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))
266 }
267 }
268
269 /// Run the synchronous grep walk on a blocking worker, cancellable via the
270 /// token and bounded by `timeout`. Mirrors `run_blocking_file_search`.
271 async fn run_blocking_grep<F>(
272 timeout: Duration,
273 cancel_token: Option<CancellationToken>,
274 search: F,
275 ) -> Result<Value, ToolError>
276 where
277 F: FnOnce() -> Result<Value, ToolError> + Send + 'static,
278 {
279 if cancel_token
280 .as_ref()
281 .is_some_and(CancellationToken::is_cancelled)
282 {
283 return Err(grep_cancelled());
284 }
285
286 let task = tokio::task::spawn_blocking(search);
287 let result = match cancel_token {
288 Some(token) => {
289 tokio::select! {
290 biased;
291 () = token.cancelled() => return Err(grep_cancelled()),
292 result = tokio::time::timeout(timeout, task) => result,
293 }
294 }
295 None => tokio::time::timeout(timeout, task).await,
296 };
297
298 let joined = result.map_err(|_| grep_timeout(timeout))?;
299 joined.map_err(|err| {
300 ToolError::execution_failed(format!("grep_files worker failed before completion: {err}"))
301 })?
302 }
303
304 fn grep_cancelled() -> ToolError {
305 ToolError::cancelled("grep_files cancelled before completion")
306 }
307
308 fn grep_timeout(timeout: Duration) -> ToolError {
309 ToolError::Timeout {
310 seconds: timeout.as_secs().max(1),
311 }
312 }
313
314 fn grep_match_to_json(item: &GrepMatch, context_lines: usize) -> Value {
315 if context_lines == 1 {
316 json!({
317 "file": item.file,
318 "line_number": item.line_number,
319 "line": item.line,
320 "context_before": item.context_before.first().cloned().unwrap_or_default(),
321 "context_after": item.context_after.first().cloned().unwrap_or_default(),
322 })
323 } else {
324 json!(item)
325 }
326 }
327
328 /// Search a single file line-by-line with a small ring buffer for
329 /// before-context, so file contents are never fully materialized.
330 ///
331 /// Returns `Ok(None)` when the file is unreadable or contains invalid UTF-8
332 /// anywhere — the same "skip binary or unreadable files" semantics as the
333 /// previous `read_to_string` implementation, which required the whole file to
334 /// be valid before contributing any match. At most `budget` matches are
335 /// recorded; the scan still runs to EOF so late invalid bytes disqualify the
336 /// file and pending after-context is completed.
337 fn search_file_streaming(
338 path: &Path,
339 relative_path: &str,
340 regex: &Regex,
341 context_lines: usize,
342 budget: usize,
343 cancel_token: Option<&CancellationToken>,
344 ) -> Result<Option<Vec<GrepMatch>>, ToolError> {
345 let Ok(file) = fs::File::open(path) else {
346 return Ok(None);
347 };
348 let mut reader = std::io::BufReader::new(file);
349 let mut raw: Vec<u8> = Vec::new();
350 let mut before: VecDeque<String> = VecDeque::new();
351 let mut matches: Vec<GrepMatch> = Vec::new();
352 // Matches still waiting for after-context lines: (index into `matches`,
353 // lines still needed). Entries complete in FIFO order.
354 let mut pending: VecDeque<(usize, usize)> = VecDeque::new();
355 let mut line_idx = 0usize;
356
357 loop {
358 raw.clear();
359 let n = match reader.read_until(b'\n', &mut raw) {
360 Ok(n) => n,
361 Err(_) => return Ok(None),
362 };
363 if n == 0 {
364 break;
365 }
366 check_cancelled(cancel_token)?;
367
368 // Mirror `str::lines`: strip the trailing '\n', and a '\r' only when
369 // it directly precedes that '\n'.
370 let mut end = raw.len();
371 if raw[..end].ends_with(b"\n") {
372 end -= 1;
373 if raw[..end].ends_with(b"\r") {
374 end -= 1;
375 }
376 }
377 let Ok(line) = std::str::from_utf8(&raw[..end]) else {
378 return Ok(None);
379 };
380
381 for (idx, remaining) in &mut pending {
382 matches[*idx].context_after.push(line.to_string());
383 *remaining -= 1;
384 }
385 while pending
386 .front()
387 .is_some_and(|(_, remaining)| *remaining == 0)
388 {
389 pending.pop_front();
390 }
391
392 if matches.len() < budget && regex.is_match(line) {
393 matches.push(GrepMatch {
394 file: relative_path.to_string(),
395 line_number: line_idx + 1,
396 line: line.to_string(),
397 context_before: before.iter().cloned().collect(),
398 context_after: Vec::new(),
399 });
400 if context_lines > 0 {
401 pending.push_back((matches.len() - 1, context_lines));
402 }
403 }
404
405 if context_lines > 0 {
406 if before.len() == context_lines {
407 before.pop_front();
408 }
409 before.push_back(line.to_string());
410 }
411 line_idx += 1;
412 }
413
414 Ok(Some(matches))
415 }
416
417 /// Flow control for the streaming file walk.
418 enum WalkControl {
419 Continue,
420 Stop,
421 }
422
423 /// Walk files matching the include/exclude patterns, invoking `visit` for
424 /// each one in traversal order. The walk stops early when `visit` returns
425 /// [`WalkControl::Stop`].
426 fn visit_files(
427 root: &Path,
428 include_patterns: &[String],
429 exclude_patterns: &[String],
430 cancel_token: Option<&CancellationToken>,
431 follow_symlinks: bool,
432 visit: &mut dyn FnMut(&Path) -> Result<WalkControl, ToolError>,
433 ) -> Result<(), ToolError> {
434 let mut visited_dirs: HashSet<PathBuf> = HashSet::new();
435 check_cancelled(cancel_token)?;
436
437 if root.is_file() {
438 visit(root)?;
439 return Ok(());
440 }
441
442 if follow_symlinks && let Ok(canonical_root) = root.canonicalize() {
443 visited_dirs.insert(canonical_root);
444 }
445
446 visit_files_recursive(
447 root,
448 root,
449 include_patterns,
450 exclude_patterns,
451 cancel_token,
452 &mut visited_dirs,
453 follow_symlinks,
454 visit,
455 )?;
456 Ok(())
457 }
458
459 #[allow(clippy::too_many_arguments)]
460 fn visit_files_recursive(
461 root: &Path,
462 current: &Path,
463 include_patterns: &[String],
464 exclude_patterns: &[String],
465 cancel_token: Option<&CancellationToken>,
466 visited_dirs: &mut HashSet<PathBuf>,
467 follow_symlinks: bool,
468 visit: &mut dyn FnMut(&Path) -> Result<WalkControl, ToolError>,
469 ) -> Result<WalkControl, ToolError> {
470 check_cancelled(cancel_token)?;
471
472 let entries = fs::read_dir(current).map_err(|e| {
473 ToolError::execution_failed(format!(
474 "Failed to read directory {}: {}",
475 current.display(),
476 e
477 ))
478 })?;
479
480 for entry in entries {
481 check_cancelled(cancel_token)?;
482
483 let entry = entry.map_err(|e| ToolError::execution_failed(e.to_string()))?;
484 let path = entry.path();
485 let file_type = entry.file_type().map_err(|e| {
486 ToolError::execution_failed(format!(
487 "Failed to inspect file type for {}: {}",
488 path.display(),
489 e
490 ))
491 })?;
492 if file_type.is_symlink() && !follow_symlinks {
493 continue;
494 }
495
496 // Get relative path for pattern matching
497 let relative = path.strip_prefix(root).unwrap_or(&path);
498 let relative_str = relative.to_string_lossy();
499
500 // Check exclusions
501 if should_exclude(&relative_str, exclude_patterns) {
502 continue;
503 }
504
505 // When following symlinks, resolve the target type for directories
506 // and files so symlinked dirs are traversed and symlinked files are
507 // included.
508 let effective_type = if file_type.is_symlink() && follow_symlinks {
509 match fs::metadata(&path) {
510 Ok(meta) => meta.file_type(),
511 Err(_) => continue,
512 }
513 } else {
514 file_type
515 };
516
517 if effective_type.is_dir() {
518 if follow_symlinks {
519 let canonical_dir = match path.canonicalize() {
520 Ok(canonical) => canonical,
521 Err(_) => continue,
522 };
523 if !visited_dirs.insert(canonical_dir) {
524 continue;
525 }
526 }
527 if let WalkControl::Stop = visit_files_recursive(
528 root,
529 &path,
530 include_patterns,
531 exclude_patterns,
532 cancel_token,
533 visited_dirs,
534 follow_symlinks,
535 visit,
536 )? {
537 return Ok(WalkControl::Stop);
538 }
539 } else if effective_type.is_file() {
540 // Check inclusions (if any specified)
541 if (include_patterns.is_empty() || should_include(&relative_str, include_patterns))
542 && let WalkControl::Stop = visit(&path)?
543 {
544 return Ok(WalkControl::Stop);
545 }
546 }
547 }
548
549 Ok(WalkControl::Continue)
550 }
551
552 fn check_cancelled(cancel_token: Option<&CancellationToken>) -> Result<(), ToolError> {
553 if cancel_token.is_some_and(CancellationToken::is_cancelled) {
554 return Err(ToolError::cancelled("search cancelled before completion"));
555 }
556 Ok(())
557 }
558
559 /// Check if a path matches any of the exclude patterns
560 fn should_exclude(path: &str, patterns: &[String]) -> bool {
561 for pattern in patterns {
562 if matches_glob(path, pattern) {
563 return true;
564 }
565 }
566 false
567 }
568
569 /// Check if a path matches any of the include patterns
570 fn should_include(path: &str, patterns: &[String]) -> bool {
571 for pattern in patterns {
572 if matches_glob(path, pattern) {
573 return true;
574 }
575 }
576 false
577 }
578
579 /// Simple glob pattern matching
580 /// Supports: * (any chars), ** (any path), ? (single char)
581 pub(crate) fn matches_glob(path: &str, pattern: &str) -> bool {
582 // Handle ** for any path
583 if pattern.contains("**") {
584 let parts: Vec<&str> = pattern.split("**").collect();
585 if parts.len() == 2 {
586 let prefix = parts[0].trim_end_matches('/');
587 let suffix = parts[1].trim_start_matches('/');
588
589 if !prefix.is_empty() && !path.starts_with(prefix) {
590 return false;
591 }
592 if !suffix.is_empty() {
593 return path.ends_with(suffix)
594 || path
595 .split('/')
596 .any(|part| matches_simple_glob(part, suffix));
597 }
598 return path.starts_with(prefix) || prefix.is_empty();
599 }
600 }
601
602 // Handle patterns like "*.rs" - match against filename only
603 if pattern.starts_with('*') && !pattern.contains('/') {
604 let filename = path.rsplit('/').next().unwrap_or(path);
605 return matches_simple_glob(filename, pattern);
606 }
607
608 // Handle patterns with path components
609 if pattern.contains('/') {
610 return matches_simple_glob(path, pattern);
611 }
612
613 // Match against filename
614 let filename = path.rsplit('/').next().unwrap_or(path);
615 matches_simple_glob(filename, pattern)
616 }
617
618 /// Simple glob matching for single path component
619 fn matches_simple_glob(text: &str, pattern: &str) -> bool {
620 let mut text_chars = text.chars().peekable();
621 let mut pattern_chars = pattern.chars().peekable();
622
623 while let Some(p) = pattern_chars.next() {
624 match p {
625 '*' => {
626 // Match zero or more characters
627 let next_pattern: String = pattern_chars.collect();
628 if next_pattern.is_empty() {
629 return true;
630 }
631
632 // Try matching at each position (use char-indices to stay on
633 // UTF-8 boundaries — byte-index slicing panics on multi-byte
634 // characters like 冰糖, see #249).
635 let remaining: String = text_chars.collect();
636 for (i, _) in remaining.char_indices() {
637 if matches_simple_glob(&remaining[i..], &next_pattern) {
638 return true;
639 }
640 }
641 // Also try the empty suffix at end of string
642 if matches_simple_glob("", &next_pattern) {
643 return true;
644 }
645 return false;
646 }
647 '?' => {
648 // Match exactly one character
649 if text_chars.next().is_none() {
650 return false;
651 }
652 }
653 c => {
654 // Match literal character
655 if text_chars.next() != Some(c) {
656 return false;
657 }
658 }
659 }
660 }
661
662 text_chars.next().is_none()
663 }
664
665 // === Unit Tests ===
666
667 #[cfg(test)]
668 mod tests {
669 use std::fs;
670
671 use serde_json::{Value, json};
672 use tempfile::tempdir;
673 use tokio_util::sync::CancellationToken;
674
675 use crate::tools::spec::{ApprovalRequirement, ToolContext, ToolSpec};
676
677 use super::{GrepFilesTool, matches_glob};
678
679 #[test]
680 fn grep_description_matches_default_exclusion_behavior() {
681 let description = GrepFilesTool.description();
682
683 assert!(description.contains("skips common non-code directories"));
684 assert!(!description.contains("respects `.gitignore`"));
685 }
686
687 /// Representative of the ~150 shared `optional_*` call sites outside the
688 /// three that motivated the change: a wrong type is refused by name, an
689 /// absent or null field still takes its default.
690 #[tokio::test]
691 async fn grep_refuses_mistyped_optional_parameters_by_name() {
692 let tmp = tempdir().expect("tempdir");
693 fs::write(tmp.path().join("a.txt"), "needle\n").expect("write");
694 let ctx = ToolContext::new(tmp.path());
695
696 for (field, input) in [
697 (
698 "max_results",
699 json!({"pattern": "needle", "max_results": "10"}),
700 ),
701 (
702 "case_insensitive",
703 json!({"pattern": "needle", "case_insensitive": "true"}),
704 ),
705 (
706 "context_lines",
707 json!({"pattern": "needle", "context_lines": 2.5}),
708 ),
709 ("path", json!({"pattern": "needle", "path": ["."]})),
710 ] {
711 let err = GrepFilesTool
712 .execute(input, &ctx)
713 .await
714 .expect_err("a mistyped optional parameter must be refused");
715 let err = err.to_string();
716 assert!(err.contains(field), "error must name '{field}': {err}");
717 }
718
719 GrepFilesTool
720 .execute(
721 json!({"pattern": "needle", "max_results": Value::Null, "path": Value::Null}),
722 &ctx,
723 )
724 .await
725 .expect("explicit nulls read as absent");
726 }
727
728 #[test]
729 fn test_matches_glob_star() {
730 assert!(matches_glob("test.rs", "*.rs"));
731 assert!(matches_glob("foo.rs", "*.rs"));
732 assert!(!matches_glob("test.ts", "*.rs"));
733 assert!(!matches_glob("test.rs.bak", "*.rs"));
734 }
735
736 #[test]
737 fn test_matches_glob_question() {
738 assert!(matches_glob("test.rs", "test.??"));
739 assert!(!matches_glob("test.rs", "test.?"));
740 }
741
742 #[test]
743 fn test_matches_glob_double_star() {
744 assert!(matches_glob("src/main.rs", "src/**"));
745 assert!(matches_glob("src/lib/mod.rs", "src/**"));
746 assert!(matches_glob("node_modules/pkg/index.js", "node_modules/*"));
747 }
748
749 #[test]
750 fn test_matches_glob_path() {
751 assert!(matches_glob("src/main.rs", "src/*.rs"));
752 assert!(!matches_glob("lib/main.rs", "src/*.rs"));
753 }
754
755 /// Regression for #249: byte-index slicing panics on multi-byte
756 /// characters inside filenames like `dialogue_line__冰糖.mp3`.
757 #[test]
758 fn test_matches_glob_unicode_filename() {
759 let filename = "dialogue_line__冰糖.mp3";
760 // The filename should match *.mp3 without panicking.
761 assert!(matches_glob(filename, "*.mp3"));
762 // Asterisk matching against multi-byte characters must succeed.
763 assert!(matches_glob(filename, "dialogue_line__*"));
764 // Literal multi-byte characters inside the pattern must match.
765 assert!(matches_glob(filename, "*冰糖*"));
766 // Non-matching pattern must not panic either.
767 assert!(!matches_glob(filename, "nonexistent*"));
768 }
769
770 #[tokio::test]
771 async fn test_grep_files_basic() {
772 let tmp = tempdir().expect("tempdir");
773 let ctx = ToolContext::new(tmp.path().to_path_buf());
774
775 // Create test files
776 fs::write(
777 tmp.path().join("test.rs"),
778 "fn main() {\n println!(\"hello\");\n}\n",
779 )
780 .expect("write");
781 fs::write(
782 tmp.path().join("lib.rs"),
783 "pub fn hello() {}\npub fn world() {}\n",
784 )
785 .expect("write");
786
787 let tool = GrepFilesTool;
788 let result = tool
789 .execute(json!({"pattern": "fn"}), &ctx)
790 .await
791 .expect("execute");
792
793 assert!(result.success);
794 assert!(result.content.contains("main"));
795 assert!(result.content.contains("hello"));
796 }
797
798 #[tokio::test]
799 async fn test_grep_files_with_context() {
800 let tmp = tempdir().expect("tempdir");
801 let ctx = ToolContext::new(tmp.path().to_path_buf());
802
803 fs::write(
804 tmp.path().join("test.txt"),
805 "line1\nline2\nMATCH\nline4\nline5\n",
806 )
807 .expect("write");
808
809 let tool = GrepFilesTool;
810 let result = tool
811 .execute(json!({"pattern": "MATCH", "context_lines": 1}), &ctx)
812 .await
813 .expect("execute");
814
815 assert!(result.success);
816 assert!(result.content.contains("line2")); // context before
817 assert!(result.content.contains("line4")); // context after
818
819 let parsed: Value = serde_json::from_str(&result.content).unwrap();
820 let matches = parsed["matches"].as_array().unwrap();
821 assert_eq!(matches.len(), 1);
822 assert_eq!(matches[0]["context_before"], "line2");
823 assert_eq!(matches[0]["context_after"], "line4");
824 assert!(matches[0]["context_before"].is_string());
825 assert!(matches[0]["context_after"].is_string());
826 }
827
828 #[tokio::test]
829 async fn test_grep_files_multi_line_context_remains_arrays() {
830 let tmp = tempdir().expect("tempdir");
831 let ctx = ToolContext::new(tmp.path().to_path_buf());
832
833 fs::write(tmp.path().join("test.txt"), "a\nb\nMATCH\nd\ne\n").expect("write");
834
835 let tool = GrepFilesTool;
836 let result = tool
837 .execute(json!({"pattern": "MATCH", "context_lines": 2}), &ctx)
838 .await
839 .expect("execute");
840
841 let parsed: Value = serde_json::from_str(&result.content).unwrap();
842 let matches = parsed["matches"].as_array().unwrap();
843 assert_eq!(matches.len(), 1);
844 assert_eq!(matches[0]["context_before"], json!(["a", "b"]));
845 assert_eq!(matches[0]["context_after"], json!(["d", "e"]));
846 }
847
848 #[tokio::test]
849 async fn test_grep_files_case_insensitive() {
850 let tmp = tempdir().expect("tempdir");
851 let ctx = ToolContext::new(tmp.path().to_path_buf());
852
853 fs::write(
854 tmp.path().join("test.txt"),
855 "Hello World\nHELLO WORLD\nhello world\n",
856 )
857 .expect("write");
858
859 let tool = GrepFilesTool;
860 let result = tool
861 .execute(json!({"pattern": "hello", "case_insensitive": true}), &ctx)
862 .await
863 .expect("execute");
864
865 assert!(result.success);
866 // Should find all 3 lines
867 let parsed: Value = serde_json::from_str(&result.content).unwrap();
868 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 3);
869 }
870
871 #[tokio::test]
872 async fn test_grep_files_include_filter() {
873 let tmp = tempdir().expect("tempdir");
874 let ctx = ToolContext::new(tmp.path().to_path_buf());
875
876 fs::write(tmp.path().join("test.rs"), "fn test() {}\n").expect("write");
877 fs::write(tmp.path().join("test.js"), "function test() {}\n").expect("write");
878
879 let tool = GrepFilesTool;
880 let result = tool
881 .execute(json!({"pattern": "test", "include": ["*.rs"]}), &ctx)
882 .await
883 .expect("execute");
884
885 assert!(result.success);
886 // Should only match .rs file
887 let parsed: Value = serde_json::from_str(&result.content).unwrap();
888 let matches = parsed["matches"].as_array().unwrap();
889 assert_eq!(matches.len(), 1);
890 let file = matches[0]["file"].as_str().unwrap();
891 assert!(
892 file.rsplit('.')
893 .next()
894 .is_some_and(|ext| ext.eq_ignore_ascii_case("rs"))
895 );
896 }
897
898 #[tokio::test]
899 #[cfg(unix)]
900 async fn test_grep_files_does_not_follow_symlinked_files() {
901 let tmp = tempdir().expect("tempdir");
902 let root = tmp.path().join("workspace");
903 let outside = tmp.path().join("outside");
904 std::fs::create_dir_all(&root).expect("mkdir workspace");
905 std::fs::create_dir_all(&outside).expect("mkdir outside");
906 let outside_file = outside.join("secret.txt");
907 fs::write(&outside_file, "NEEDLE\n").expect("write outside");
908 std::os::unix::fs::symlink(&outside_file, root.join("secret.txt")).expect("symlink");
909
910 let ctx = ToolContext::new(root);
911 let tool = GrepFilesTool;
912 let result = tool
913 .execute(json!({"pattern": "NEEDLE"}), &ctx)
914 .await
915 .expect("execute");
916
917 assert!(result.success);
918 let parsed: Value = serde_json::from_str(&result.content).unwrap();
919 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 0);
920 assert_eq!(parsed["files_searched"].as_u64().unwrap(), 0);
921 }
922
923 #[tokio::test]
924 #[cfg(unix)]
925 async fn test_grep_files_default_mode_skips_symlinked_directories_but_keeps_real_files() {
926 let tmp = tempdir().expect("tempdir");
927 let workspace = tmp.path().join("workspace");
928 let real_dir = workspace.join("real");
929 std::fs::create_dir_all(&real_dir).expect("mkdir workspace");
930 fs::write(real_dir.join("needle.txt"), "NEEDLE\n").expect("write real file");
931 std::os::unix::fs::symlink(&workspace, real_dir.join("loop")).expect("symlink loop");
932
933 let ctx = ToolContext::new(workspace);
934 let tool = GrepFilesTool;
935 let result = tool
936 .execute(json!({"pattern": "NEEDLE"}), &ctx)
937 .await
938 .expect("execute");
939
940 assert!(result.success);
941 let parsed: Value = serde_json::from_str(&result.content).unwrap();
942 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 1);
943 assert_eq!(parsed["files_searched"].as_u64().unwrap(), 1);
944 let matches = parsed["matches"].as_array().unwrap();
945 assert_eq!(matches.len(), 1);
946 assert!(
947 matches[0]["file"]
948 .as_str()
949 .unwrap()
950 .ends_with("real/needle.txt")
951 );
952 }
953
954 #[tokio::test]
955 #[cfg(unix)]
956 async fn test_grep_files_follow_symlinks_avoids_directory_cycles() {
957 let tmp = tempdir().expect("tempdir");
958 let workspace = tmp.path().join("workspace");
959 let real_dir = workspace.join("real");
960 fs::create_dir_all(&real_dir).expect("mkdir");
961 fs::write(real_dir.join("needle.txt"), "NEEDLE\n").expect("write");
962 std::os::unix::fs::symlink(&workspace, real_dir.join("loop")).expect("symlink loop");
963
964 let ctx = ToolContext::new(workspace).with_follow_symlinks(true);
965 let tool = GrepFilesTool;
966 let result = tool
967 .execute(json!({"pattern": "NEEDLE"}), &ctx)
968 .await
969 .expect("execute");
970
971 assert!(result.success);
972 let parsed: Value = serde_json::from_str(&result.content).unwrap();
973 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 1);
974 assert_eq!(parsed["files_searched"].as_u64().unwrap(), 1);
975 let matches = parsed["matches"].as_array().unwrap();
976 assert!(matches[0]["file"].as_str().unwrap().ends_with("needle.txt"));
977 }
978
979 #[tokio::test]
980 async fn test_grep_files_invalid_regex() {
981 let tmp = tempdir().expect("tempdir");
982 let ctx = ToolContext::new(tmp.path().to_path_buf());
983
984 let tool = GrepFilesTool;
985 let result = tool.execute(json!({"pattern": "[invalid"}), &ctx).await;
986
987 assert!(result.is_err());
988 }
989
990 #[tokio::test]
991 async fn test_grep_files_respects_cancel_token() {
992 let tmp = tempdir().expect("tempdir");
993 fs::write(tmp.path().join("test.txt"), "needle\n").expect("write");
994 let cancel_token = CancellationToken::new();
995 cancel_token.cancel();
996 let ctx = ToolContext::new(tmp.path().to_path_buf()).with_cancel_token(cancel_token);
997
998 let tool = GrepFilesTool;
999 let err = tool
1000 .execute(json!({"pattern": "needle"}), &ctx)
1001 .await
1002 .expect_err("cancelled grep should return an error");
1003
1004 assert!(
1005 format!("{err:?}").contains("cancelled"),
1006 "unexpected error: {err:?}"
1007 );
1008 }
1009
1010 #[tokio::test]
1011 async fn test_grep_files_streaming_stops_at_max_results() {
1012 let tmp = tempdir().expect("tempdir");
1013 let ctx = ToolContext::new(tmp.path().to_path_buf());
1014
1015 // Two files with many matches each; the walk must stop once the
1016 // budget is exhausted without dropping context for the last match.
1017 for name in ["a.txt", "b.txt"] {
1018 let body: String = (1..=20).map(|n| format!("needle {n}\n")).collect();
1019 fs::write(tmp.path().join(name), body).expect("write");
1020 }
1021
1022 let tool = GrepFilesTool;
1023 let result = tool
1024 .execute(json!({"pattern": "needle", "max_results": 5}), &ctx)
1025 .await
1026 .expect("execute");
1027
1028 assert!(result.success);
1029 let parsed: Value = serde_json::from_str(&result.content).unwrap();
1030 let matches = parsed["matches"].as_array().unwrap();
1031 assert_eq!(matches.len(), 5);
1032 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 5);
1033 // All five matches must come from the first file walked, in file
1034 // order (streaming preserves walk order).
1035 let first_file = matches[0]["file"].as_str().unwrap().to_string();
1036 for m in matches {
1037 assert_eq!(m["file"].as_str().unwrap(), first_file);
1038 }
1039 // The final in-budget match still gets its full after-context even
1040 // though the match budget was exhausted on it.
1041 assert_eq!(
1042 matches[4]["context_after"],
1043 json!(["needle 6", "needle 7"]),
1044 "last match must keep after-context lines"
1045 );
1046 }
1047
1048 #[tokio::test]
1049 async fn test_grep_files_ring_buffer_context_matches_full_read() {
1050 let tmp = tempdir().expect("tempdir");
1051 let ctx = ToolContext::new(tmp.path().to_path_buf());
1052
1053 // Matches at the start, middle, and end of the file exercise the
1054 // partial before-context (ring not yet full) and truncated
1055 // after-context (EOF) paths.
1056 fs::write(
1057 tmp.path().join("ctx.txt"),
1058 "MATCH first\nb1\nb2\nb3\nMATCH mid\na1\na2\na3\nMATCH last\n",
1059 )
1060 .expect("write");
1061
1062 let tool = GrepFilesTool;
1063 let result = tool
1064 .execute(json!({"pattern": "MATCH", "context_lines": 2}), &ctx)
1065 .await
1066 .expect("execute");
1067
1068 let parsed: Value = serde_json::from_str(&result.content).unwrap();
1069 let matches = parsed["matches"].as_array().unwrap();
1070 assert_eq!(matches.len(), 3);
1071 assert_eq!(matches[0]["context_before"], json!([]));
1072 assert_eq!(matches[0]["context_after"], json!(["b1", "b2"]));
1073 assert_eq!(matches[1]["context_before"], json!(["b2", "b3"]));
1074 assert_eq!(matches[1]["context_after"], json!(["a1", "a2"]));
1075 assert_eq!(matches[2]["context_before"], json!(["a2", "a3"]));
1076 assert_eq!(matches[2]["context_after"], json!([]));
1077 assert_eq!(matches[2]["line_number"].as_u64().unwrap(), 9);
1078 }
1079
1080 #[tokio::test]
1081 async fn test_grep_files_streaming_skips_invalid_utf8_files() {
1082 let tmp = tempdir().expect("tempdir");
1083 let ctx = ToolContext::new(tmp.path().to_path_buf());
1084
1085 // Invalid UTF-8 after a matching line: the whole file must be
1086 // skipped, matching the historical read_to_string behavior.
1087 fs::write(
1088 tmp.path().join("binary.txt"),
1089 [b"needle\n".as_slice(), &[0xFF, 0xFE, 0x00]].concat(),
1090 )
1091 .expect("write");
1092 fs::write(tmp.path().join("clean.txt"), "needle\n").expect("write");
1093
1094 let tool = GrepFilesTool;
1095 let result = tool
1096 .execute(json!({"pattern": "needle"}), &ctx)
1097 .await
1098 .expect("execute");
1099
1100 let parsed: Value = serde_json::from_str(&result.content).unwrap();
1101 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 1);
1102 assert_eq!(parsed["files_searched"].as_u64().unwrap(), 1);
1103 let matches = parsed["matches"].as_array().unwrap();
1104 assert!(matches[0]["file"].as_str().unwrap().ends_with("clean.txt"));
1105 }
1106
1107 #[test]
1108 fn test_grep_files_tool_properties() {
1109 let tool = GrepFilesTool;
1110 assert_eq!(tool.name(), "grep_files");
1111 assert!(tool.is_read_only());
1112 assert!(tool.is_sandboxable());
1113 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
1114 }
1115
1116 #[test]
1117 fn test_parallel_support_flags() {
1118 let tool = GrepFilesTool;
1119 assert!(tool.supports_parallel());
1120 }
1121 }
1122
1122 lines RUST