返回 DeepSeek-TUI-2026
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::spec::{
7 ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_bool, optional_str,
8 optional_u64, required_str,
9 };
10 use async_trait::async_trait;
11 use regex::Regex;
12 use serde::{Deserialize, Serialize};
13 use serde_json::{Value, json};
14 use std::fs;
15 use std::path::{Path, PathBuf};
16
17 /// Maximum number of results to return to avoid overwhelming output
18 const MAX_RESULTS: usize = 100;
19
20 /// Maximum file size to search (skip large binaries)
21 const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; // 10MB
22
23 /// Result of a grep match
24 #[derive(Debug, Clone, Serialize, Deserialize)]
25 pub struct GrepMatch {
26 pub file: String,
27 pub line_number: usize,
28 pub line: String,
29 pub context_before: Vec<String>,
30 pub context_after: Vec<String>,
31 }
32
33 /// Tool for searching files using regex patterns
34 pub struct GrepFilesTool;
35
36 #[async_trait]
37 impl ToolSpec for GrepFilesTool {
38 fn name(&self) -> &'static str {
39 "grep_files"
40 }
41
42 fn description(&self) -> &'static str {
43 "Search for a regex pattern in files within the workspace. Returns matching lines with context."
44 }
45
46 fn input_schema(&self) -> Value {
47 json!({
48 "type": "object",
49 "properties": {
50 "pattern": {
51 "type": "string",
52 "description": "Regular expression pattern to search for"
53 },
54 "path": {
55 "type": "string",
56 "description": "Directory or file to search (relative to workspace, default: .)"
57 },
58 "include": {
59 "type": "array",
60 "items": {"type": "string"},
61 "description": "Glob patterns for files to include (e.g., ['*.rs', '*.ts'])"
62 },
63 "exclude": {
64 "type": "array",
65 "items": {"type": "string"},
66 "description": "Glob patterns for files to exclude (e.g., ['*.min.js', 'node_modules/*'])"
67 },
68 "context_lines": {
69 "type": "integer",
70 "description": "Number of context lines before and after each match (default: 2)"
71 },
72 "case_insensitive": {
73 "type": "boolean",
74 "description": "Whether to perform case-insensitive matching (default: false)"
75 },
76 "max_results": {
77 "type": "integer",
78 "description": "Maximum number of results to return (default: 100)"
79 }
80 },
81 "required": ["pattern"]
82 })
83 }
84
85 fn capabilities(&self) -> Vec<ToolCapability> {
86 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
87 }
88
89 fn supports_parallel(&self) -> bool {
90 true
91 }
92
93 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
94 let pattern_str = required_str(&input, "pattern")?;
95 let path_str = optional_str(&input, "path").unwrap_or(".");
96 let context_lines =
97 usize::try_from(optional_u64(&input, "context_lines", 2)).unwrap_or(usize::MAX);
98 let case_insensitive = optional_bool(&input, "case_insensitive", false);
99 let max_results = usize::try_from(optional_u64(&input, "max_results", MAX_RESULTS as u64))
100 .unwrap_or(MAX_RESULTS);
101
102 // Parse include patterns
103 let include_patterns: Vec<String> = input
104 .get("include")
105 .and_then(|v| v.as_array())
106 .map(|arr| {
107 arr.iter()
108 .filter_map(|v| v.as_str().map(String::from))
109 .collect()
110 })
111 .unwrap_or_default();
112
113 // Parse exclude patterns
114 let exclude_patterns: Vec<String> =
115 input.get("exclude").and_then(|v| v.as_array()).map_or_else(
116 || {
117 // Default exclusions for common non-code directories
118 vec![
119 "node_modules/*".to_string(),
120 ".git/*".to_string(),
121 "target/*".to_string(),
122 "*.min.js".to_string(),
123 "*.min.css".to_string(),
124 "dist/*".to_string(),
125 "build/*".to_string(),
126 "__pycache__/*".to_string(),
127 ".venv/*".to_string(),
128 "venv/*".to_string(),
129 ]
130 },
131 |arr| {
132 arr.iter()
133 .filter_map(|v| v.as_str().map(String::from))
134 .collect()
135 },
136 );
137
138 // Build regex
139 let regex_pattern = if case_insensitive {
140 format!("(?i){pattern_str}")
141 } else {
142 pattern_str.to_string()
143 };
144
145 let regex = Regex::new(&regex_pattern)
146 .map_err(|e| ToolError::invalid_input(format!("Invalid regex pattern: {e}")))?;
147
148 // Resolve search path
149 let search_path = context.resolve_path(path_str)?;
150
151 // Collect files to search
152 let files = collect_files(&search_path, &include_patterns, &exclude_patterns)?;
153
154 // Search files
155 let mut results: Vec<GrepMatch> = Vec::new();
156 let mut files_searched = 0;
157 let mut total_matches = 0;
158
159 for file_path in files {
160 if results.len() >= max_results {
161 break;
162 }
163
164 // Skip files that are too large
165 if let Ok(metadata) = fs::metadata(&file_path)
166 && metadata.len() > MAX_FILE_SIZE
167 {
168 continue;
169 }
170
171 // Read file content
172 let Ok(file_content) = fs::read_to_string(&file_path) else {
173 continue; // Skip binary or unreadable files
174 };
175
176 files_searched += 1;
177 let lines: Vec<&str> = file_content.lines().collect();
178
179 for (line_idx, line) in lines.iter().enumerate() {
180 if regex.is_match(line) {
181 total_matches += 1;
182
183 // Get context lines
184 let context_before: Vec<String> = (line_idx.saturating_sub(context_lines)
185 ..line_idx)
186 .filter_map(|i| lines.get(i).map(|s| (*s).to_string()))
187 .collect();
188
189 let context_after: Vec<String> = ((line_idx + 1)
190 ..=(line_idx + context_lines).min(lines.len() - 1))
191 .filter_map(|i| lines.get(i).map(|s| (*s).to_string()))
192 .collect();
193
194 // Get relative path from workspace
195 let relative_path = file_path
196 .strip_prefix(&context.workspace)
197 .unwrap_or(&file_path)
198 .to_string_lossy()
199 .to_string();
200
201 results.push(GrepMatch {
202 file: relative_path,
203 line_number: line_idx + 1,
204 line: (*line).to_string(),
205 context_before,
206 context_after,
207 });
208
209 if results.len() >= max_results {
210 break;
211 }
212 }
213 }
214 }
215
216 // Build result
217 let result = json!({
218 "matches": results,
219 "total_matches": total_matches,
220 "files_searched": files_searched,
221 "truncated": total_matches > max_results,
222 });
223
224 ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))
225 }
226 }
227
228 /// Collect files to search based on include/exclude patterns
229 fn collect_files(
230 root: &Path,
231 include_patterns: &[String],
232 exclude_patterns: &[String],
233 ) -> Result<Vec<PathBuf>, ToolError> {
234 let mut files = Vec::new();
235
236 if root.is_file() {
237 files.push(root.to_path_buf());
238 return Ok(files);
239 }
240
241 collect_files_recursive(root, root, include_patterns, exclude_patterns, &mut files)?;
242 Ok(files)
243 }
244
245 fn collect_files_recursive(
246 root: &Path,
247 current: &Path,
248 include_patterns: &[String],
249 exclude_patterns: &[String],
250 files: &mut Vec<PathBuf>,
251 ) -> Result<(), ToolError> {
252 let entries = fs::read_dir(current).map_err(|e| {
253 ToolError::execution_failed(format!(
254 "Failed to read directory {}: {}",
255 current.display(),
256 e
257 ))
258 })?;
259
260 for entry in entries {
261 let entry = entry.map_err(|e| ToolError::execution_failed(e.to_string()))?;
262 let path = entry.path();
263
264 // Get relative path for pattern matching
265 let relative = path.strip_prefix(root).unwrap_or(&path);
266 let relative_str = relative.to_string_lossy();
267
268 // Check exclusions
269 if should_exclude(&relative_str, exclude_patterns) {
270 continue;
271 }
272
273 if path.is_dir() {
274 collect_files_recursive(root, &path, include_patterns, exclude_patterns, files)?;
275 } else if path.is_file() {
276 // Check inclusions (if any specified)
277 if include_patterns.is_empty() || should_include(&relative_str, include_patterns) {
278 files.push(path);
279 }
280 }
281 }
282
283 Ok(())
284 }
285
286 /// Check if a path matches any of the exclude patterns
287 fn should_exclude(path: &str, patterns: &[String]) -> bool {
288 for pattern in patterns {
289 if matches_glob(path, pattern) {
290 return true;
291 }
292 }
293 false
294 }
295
296 /// Check if a path matches any of the include patterns
297 fn should_include(path: &str, patterns: &[String]) -> bool {
298 for pattern in patterns {
299 if matches_glob(path, pattern) {
300 return true;
301 }
302 }
303 false
304 }
305
306 /// Simple glob pattern matching
307 /// Supports: * (any chars), ** (any path), ? (single char)
308 fn matches_glob(path: &str, pattern: &str) -> bool {
309 // Handle ** for any path
310 if pattern.contains("**") {
311 let parts: Vec<&str> = pattern.split("**").collect();
312 if parts.len() == 2 {
313 let prefix = parts[0].trim_end_matches('/');
314 let suffix = parts[1].trim_start_matches('/');
315
316 if !prefix.is_empty() && !path.starts_with(prefix) {
317 return false;
318 }
319 if !suffix.is_empty() {
320 return path.ends_with(suffix)
321 || path
322 .split('/')
323 .any(|part| matches_simple_glob(part, suffix));
324 }
325 return path.starts_with(prefix) || prefix.is_empty();
326 }
327 }
328
329 // Handle patterns like "*.rs" - match against filename only
330 if pattern.starts_with('*') && !pattern.contains('/') {
331 let filename = path.rsplit('/').next().unwrap_or(path);
332 return matches_simple_glob(filename, pattern);
333 }
334
335 // Handle patterns with path components
336 if pattern.contains('/') {
337 return matches_simple_glob(path, pattern);
338 }
339
340 // Match against filename
341 let filename = path.rsplit('/').next().unwrap_or(path);
342 matches_simple_glob(filename, pattern)
343 }
344
345 /// Simple glob matching for single path component
346 fn matches_simple_glob(text: &str, pattern: &str) -> bool {
347 let mut text_chars = text.chars().peekable();
348 let mut pattern_chars = pattern.chars().peekable();
349
350 while let Some(p) = pattern_chars.next() {
351 match p {
352 '*' => {
353 // Match zero or more characters
354 let next_pattern: String = pattern_chars.collect();
355 if next_pattern.is_empty() {
356 return true;
357 }
358
359 // Try matching at each position (use char-indices to stay on
360 // UTF-8 boundaries — byte-index slicing panics on multi-byte
361 // characters like 冰糖, see #249).
362 let remaining: String = text_chars.collect();
363 for (i, _) in remaining.char_indices() {
364 if matches_simple_glob(&remaining[i..], &next_pattern) {
365 return true;
366 }
367 }
368 // Also try the empty suffix at end of string
369 if matches_simple_glob("", &next_pattern) {
370 return true;
371 }
372 return false;
373 }
374 '?' => {
375 // Match exactly one character
376 if text_chars.next().is_none() {
377 return false;
378 }
379 }
380 c => {
381 // Match literal character
382 if text_chars.next() != Some(c) {
383 return false;
384 }
385 }
386 }
387 }
388
389 text_chars.next().is_none()
390 }
391
392 // === Unit Tests ===
393
394 #[cfg(test)]
395 mod tests {
396 use std::fs;
397
398 use serde_json::{Value, json};
399 use tempfile::tempdir;
400
401 use crate::tools::spec::{ApprovalRequirement, ToolContext, ToolSpec};
402
403 use super::{GrepFilesTool, matches_glob};
404
405 #[test]
406 fn test_matches_glob_star() {
407 assert!(matches_glob("test.rs", "*.rs"));
408 assert!(matches_glob("foo.rs", "*.rs"));
409 assert!(!matches_glob("test.ts", "*.rs"));
410 assert!(!matches_glob("test.rs.bak", "*.rs"));
411 }
412
413 #[test]
414 fn test_matches_glob_question() {
415 assert!(matches_glob("test.rs", "test.??"));
416 assert!(!matches_glob("test.rs", "test.?"));
417 }
418
419 #[test]
420 fn test_matches_glob_double_star() {
421 assert!(matches_glob("src/main.rs", "src/**"));
422 assert!(matches_glob("src/lib/mod.rs", "src/**"));
423 assert!(matches_glob("node_modules/pkg/index.js", "node_modules/*"));
424 }
425
426 #[test]
427 fn test_matches_glob_path() {
428 assert!(matches_glob("src/main.rs", "src/*.rs"));
429 assert!(!matches_glob("lib/main.rs", "src/*.rs"));
430 }
431
432 /// Regression for #249: byte-index slicing panics on multi-byte
433 /// characters inside filenames like `dialogue_line__冰糖.mp3`.
434 #[test]
435 fn test_matches_glob_unicode_filename() {
436 let filename = "dialogue_line__冰糖.mp3";
437 // The filename should match *.mp3 without panicking.
438 assert!(matches_glob(filename, "*.mp3"));
439 // Asterisk matching against multi-byte characters must succeed.
440 assert!(matches_glob(filename, "dialogue_line__*"));
441 // Literal multi-byte characters inside the pattern must match.
442 assert!(matches_glob(filename, "*冰糖*"));
443 // Non-matching pattern must not panic either.
444 assert!(!matches_glob(filename, "nonexistent*"));
445 }
446
447 #[tokio::test]
448 async fn test_grep_files_basic() {
449 let tmp = tempdir().expect("tempdir");
450 let ctx = ToolContext::new(tmp.path().to_path_buf());
451
452 // Create test files
453 fs::write(
454 tmp.path().join("test.rs"),
455 "fn main() {\n println!(\"hello\");\n}\n",
456 )
457 .expect("write");
458 fs::write(
459 tmp.path().join("lib.rs"),
460 "pub fn hello() {}\npub fn world() {}\n",
461 )
462 .expect("write");
463
464 let tool = GrepFilesTool;
465 let result = tool
466 .execute(json!({"pattern": "fn"}), &ctx)
467 .await
468 .expect("execute");
469
470 assert!(result.success);
471 assert!(result.content.contains("main"));
472 assert!(result.content.contains("hello"));
473 }
474
475 #[tokio::test]
476 async fn test_grep_files_with_context() {
477 let tmp = tempdir().expect("tempdir");
478 let ctx = ToolContext::new(tmp.path().to_path_buf());
479
480 fs::write(
481 tmp.path().join("test.txt"),
482 "line1\nline2\nMATCH\nline4\nline5\n",
483 )
484 .expect("write");
485
486 let tool = GrepFilesTool;
487 let result = tool
488 .execute(json!({"pattern": "MATCH", "context_lines": 1}), &ctx)
489 .await
490 .expect("execute");
491
492 assert!(result.success);
493 assert!(result.content.contains("line2")); // context before
494 assert!(result.content.contains("line4")); // context after
495 }
496
497 #[tokio::test]
498 async fn test_grep_files_case_insensitive() {
499 let tmp = tempdir().expect("tempdir");
500 let ctx = ToolContext::new(tmp.path().to_path_buf());
501
502 fs::write(
503 tmp.path().join("test.txt"),
504 "Hello World\nHELLO WORLD\nhello world\n",
505 )
506 .expect("write");
507
508 let tool = GrepFilesTool;
509 let result = tool
510 .execute(json!({"pattern": "hello", "case_insensitive": true}), &ctx)
511 .await
512 .expect("execute");
513
514 assert!(result.success);
515 // Should find all 3 lines
516 let parsed: Value = serde_json::from_str(&result.content).unwrap();
517 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 3);
518 }
519
520 #[tokio::test]
521 async fn test_grep_files_include_filter() {
522 let tmp = tempdir().expect("tempdir");
523 let ctx = ToolContext::new(tmp.path().to_path_buf());
524
525 fs::write(tmp.path().join("test.rs"), "fn test() {}\n").expect("write");
526 fs::write(tmp.path().join("test.js"), "function test() {}\n").expect("write");
527
528 let tool = GrepFilesTool;
529 let result = tool
530 .execute(json!({"pattern": "test", "include": ["*.rs"]}), &ctx)
531 .await
532 .expect("execute");
533
534 assert!(result.success);
535 // Should only match .rs file
536 let parsed: Value = serde_json::from_str(&result.content).unwrap();
537 let matches = parsed["matches"].as_array().unwrap();
538 assert_eq!(matches.len(), 1);
539 let file = matches[0]["file"].as_str().unwrap();
540 assert!(
541 file.rsplit('.')
542 .next()
543 .is_some_and(|ext| ext.eq_ignore_ascii_case("rs"))
544 );
545 }
546
547 #[tokio::test]
548 async fn test_grep_files_invalid_regex() {
549 let tmp = tempdir().expect("tempdir");
550 let ctx = ToolContext::new(tmp.path().to_path_buf());
551
552 let tool = GrepFilesTool;
553 let result = tool.execute(json!({"pattern": "[invalid"}), &ctx).await;
554
555 assert!(result.is_err());
556 }
557
558 #[test]
559 fn test_grep_files_tool_properties() {
560 let tool = GrepFilesTool;
561 assert_eq!(tool.name(), "grep_files");
562 assert!(tool.is_read_only());
563 assert!(tool.is_sandboxable());
564 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
565 }
566
567 #[test]
568 fn test_parallel_support_flags() {
569 let tool = GrepFilesTool;
570 assert!(tool.supports_parallel());
571 }
572 }
573
573 lines RUST