返回 CodeWhale
file_search.rs
根目录 / crates / tui / src / tools / file_search.rs
1 //! File search tool with fuzzy matching and scoring.
2
3 use std::cmp::Ordering;
4 use std::path::{Path, PathBuf};
5 use std::time::Duration;
6
7 use async_trait::async_trait;
8 use ignore::WalkBuilder;
9 use serde::Serialize;
10 use serde_json::{Value, json};
11 use tokio_util::sync::CancellationToken;
12
13 use crate::tools::search::matches_glob;
14
15 use super::file::{PATH_ALIASES, SEARCH_NAME_ALIASES, SEARCH_NAME_PARAMS, apply_param_aliases};
16 use super::spec::{
17 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
18 optional_str, optional_u64, required_str,
19 };
20
21 const FILE_SEARCH_TIMEOUT: Duration = Duration::from_secs(30);
22
23 #[derive(Debug, Clone, Serialize)]
24 struct FileSearchMatch {
25 path: String,
26 name: String,
27 score: f64,
28 }
29
30 pub struct FileSearchTool;
31
32 #[async_trait]
33 impl ToolSpec for FileSearchTool {
34 fn name(&self) -> &'static str {
35 "file_search"
36 }
37
38 fn model_visible(&self) -> bool {
39 false
40 }
41
42 fn description(&self) -> &'static str {
43 "Find files by name using fuzzy matching with score-based ranking. Use this instead of `find -name` or `fd` in `exec_shell` for filename search. Pass `extensions` to filter by suffix."
44 }
45
46 fn input_schema(&self) -> Value {
47 json!({
48 "type": "object",
49 "properties": {
50 "query": {
51 "type": "string",
52 "description": "Search query (file name or path fragment)."
53 },
54 "path": {
55 "type": "string",
56 "description": "Optional base path to search (relative to workspace)."
57 },
58 "limit": {
59 "type": "integer",
60 "description": "Maximum number of results to return (default: 20)."
61 },
62 "extensions": {
63 "type": "array",
64 "items": { "type": "string" },
65 "description": "Optional list of file extensions to include (e.g. [\"rs\", \"md\"])."
66 },
67 "exclude": {
68 "type": "array",
69 "items": { "type": "string" },
70 "description": "Optional glob patterns to exclude, matching grep_files' convention (e.g. [\"target/**\", \"*.lock\"])."
71 }
72 },
73 "required": ["query"]
74 })
75 }
76
77 fn capabilities(&self) -> Vec<ToolCapability> {
78 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
79 }
80
81 fn approval_requirement(&self) -> ApprovalRequirement {
82 ApprovalRequirement::Auto
83 }
84
85 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
86 let mut input = input;
87 apply_param_aliases(&mut input, PATH_ALIASES, "File search_name")?;
88 apply_param_aliases(&mut input, SEARCH_NAME_ALIASES, "File search_name")?;
89 SEARCH_NAME_PARAMS.reject_unknown(&input)?;
90
91 let query = required_str(&input, "query")?.trim();
92 if query.is_empty() {
93 return Err(ToolError::invalid_input("query cannot be empty"));
94 }
95
96 let limit = optional_u64(&input, "limit", 20)?.clamp(1, 200) as usize;
97 let base_path = match optional_str(&input, "path")? {
98 Some(path) if !path.trim().is_empty() => context.resolve_path(path)?,
99 _ => context.workspace.clone(),
100 };
101
102 let extensions = parse_extensions(&input);
103 let exclude_patterns = parse_exclude_patterns(&input);
104 let matches = search_files_async(
105 query.to_string(),
106 base_path,
107 extensions,
108 exclude_patterns,
109 limit,
110 context.cancel_token.clone(),
111 FILE_SEARCH_TIMEOUT,
112 context.follow_symlinks,
113 )
114 .await?;
115 ToolResult::json(&matches).map_err(|e| ToolError::execution_failed(e.to_string()))
116 }
117 }
118
119 #[allow(clippy::too_many_arguments)]
120 async fn search_files_async(
121 query: String,
122 base_path: PathBuf,
123 extensions: Vec<String>,
124 exclude_patterns: Vec<String>,
125 limit: usize,
126 cancel_token: Option<CancellationToken>,
127 timeout: Duration,
128 follow_symlinks: bool,
129 ) -> Result<Vec<FileSearchMatch>, ToolError> {
130 let worker_cancel_token = cancel_token.clone();
131 run_blocking_file_search(timeout, cancel_token, move || {
132 search_files(
133 &query,
134 &base_path,
135 extensions,
136 exclude_patterns,
137 limit,
138 worker_cancel_token.as_ref(),
139 follow_symlinks,
140 )
141 })
142 .await
143 }
144
145 async fn run_blocking_file_search<F>(
146 timeout: Duration,
147 cancel_token: Option<CancellationToken>,
148 search: F,
149 ) -> Result<Vec<FileSearchMatch>, ToolError>
150 where
151 F: FnOnce() -> Result<Vec<FileSearchMatch>, ToolError> + Send + 'static,
152 {
153 if cancel_token
154 .as_ref()
155 .is_some_and(CancellationToken::is_cancelled)
156 {
157 return Err(file_search_cancelled());
158 }
159
160 let task = tokio::task::spawn_blocking(search);
161 let result = match cancel_token {
162 Some(token) => {
163 tokio::select! {
164 biased;
165 () = token.cancelled() => return Err(file_search_cancelled()),
166 result = tokio::time::timeout(timeout, task) => result,
167 }
168 }
169 None => tokio::time::timeout(timeout, task).await,
170 };
171
172 let joined = result.map_err(|_| file_search_timeout(timeout))?;
173 joined.map_err(|err| {
174 ToolError::execution_failed(format!(
175 "file_search worker failed before completion: {err}"
176 ))
177 })?
178 }
179
180 fn file_search_cancelled() -> ToolError {
181 ToolError::cancelled("file_search cancelled before completion")
182 }
183
184 fn file_search_timeout(timeout: Duration) -> ToolError {
185 ToolError::Timeout {
186 seconds: timeout.as_secs().max(1),
187 }
188 }
189
190 fn parse_extensions(input: &Value) -> Vec<String> {
191 let mut out = Vec::new();
192 if let Some(values) = input.get("extensions").and_then(|v| v.as_array()) {
193 for value in values {
194 if let Some(ext) = value.as_str() {
195 let ext = ext.trim().trim_start_matches('.').to_ascii_lowercase();
196 if !ext.is_empty() {
197 out.push(ext);
198 }
199 }
200 }
201 }
202 if out.is_empty()
203 && let Some(value) = input.get("extension").and_then(|v| v.as_str())
204 {
205 let ext = value.trim().trim_start_matches('.').to_ascii_lowercase();
206 if !ext.is_empty() {
207 out.push(ext);
208 }
209 }
210 out
211 }
212
213 fn parse_exclude_patterns(input: &Value) -> Vec<String> {
214 if let Some(values) = input.get("exclude").and_then(Value::as_array) {
215 return values
216 .iter()
217 .filter_map(Value::as_str)
218 .map(str::trim)
219 .filter(|pattern| !pattern.is_empty())
220 .map(ToOwned::to_owned)
221 .collect();
222 }
223
224 [
225 "target/**",
226 "node_modules/**",
227 ".git/**",
228 "DerivedData/**",
229 "dist/**",
230 "build/**",
231 "*.lock",
232 "*.plist",
233 ]
234 .into_iter()
235 .map(ToOwned::to_owned)
236 .collect()
237 }
238
239 fn search_files(
240 query: &str,
241 base_path: &Path,
242 extensions: Vec<String>,
243 exclude_patterns: Vec<String>,
244 limit: usize,
245 cancel_token: Option<&CancellationToken>,
246 follow_symlinks: bool,
247 ) -> Result<Vec<FileSearchMatch>, ToolError> {
248 check_cancelled(cancel_token)?;
249
250 if !base_path.exists() {
251 return Err(ToolError::invalid_input(format!(
252 "Base path does not exist: {}",
253 base_path.display()
254 )));
255 }
256
257 let query_norm = query.to_ascii_lowercase();
258 let mut results: Vec<FileSearchMatch> = Vec::new();
259
260 let mut builder = WalkBuilder::new(base_path);
261 builder
262 .hidden(false)
263 .follow_links(follow_symlinks)
264 .require_git(false);
265 let walker = builder.build();
266
267 for entry in walker {
268 check_cancelled(cancel_token)?;
269
270 let entry = match entry {
271 Ok(entry) => entry,
272 Err(_) => continue,
273 };
274 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
275 continue;
276 }
277
278 let path = entry.path();
279 let rel_path = path
280 .strip_prefix(base_path)
281 .unwrap_or(path)
282 .to_string_lossy()
283 .replace('\\', "/");
284 if should_exclude(&rel_path, &exclude_patterns) {
285 continue;
286 }
287
288 if !extensions.is_empty() && !extension_matches(path, &extensions) {
289 continue;
290 }
291
292 let name = file_name(path);
293
294 let score = match score_match(&query_norm, &rel_path, &name) {
295 Some(score) => score,
296 None => continue,
297 };
298
299 results.push(FileSearchMatch {
300 path: rel_path,
301 name,
302 score,
303 });
304 }
305
306 results.sort_by(compare_match);
307 if results.len() > limit {
308 results.truncate(limit);
309 }
310 Ok(results)
311 }
312
313 fn check_cancelled(cancel_token: Option<&CancellationToken>) -> Result<(), ToolError> {
314 if cancel_token.is_some_and(CancellationToken::is_cancelled) {
315 return Err(file_search_cancelled());
316 }
317 Ok(())
318 }
319
320 fn should_exclude(rel_path: &str, exclude_patterns: &[String]) -> bool {
321 exclude_patterns
322 .iter()
323 .any(|pattern| matches_glob(rel_path, pattern))
324 }
325
326 fn extension_matches(path: &Path, extensions: &[String]) -> bool {
327 let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
328 return false;
329 };
330 let ext = ext.to_ascii_lowercase();
331 extensions.iter().any(|wanted| wanted == &ext)
332 }
333
334 fn file_name(path: &Path) -> String {
335 path.file_name()
336 .map(|name| name.to_string_lossy().into_owned())
337 .unwrap_or_else(|| path.to_string_lossy().to_string())
338 }
339
340 fn score_match(query: &str, rel_path: &str, name: &str) -> Option<f64> {
341 let path_norm = rel_path.to_ascii_lowercase();
342 let name_norm = name.to_ascii_lowercase();
343
344 if name_norm == query {
345 return Some(1.0);
346 }
347 if path_norm == query {
348 return Some(0.98);
349 }
350
351 if name_norm.starts_with(query) {
352 return Some(0.9 + length_bonus(query, &name_norm));
353 }
354 if path_norm.starts_with(query) {
355 return Some(0.85 + length_bonus(query, &path_norm));
356 }
357
358 if name_norm.contains(query) {
359 return Some(0.75 + length_bonus(query, &name_norm));
360 }
361 if path_norm.contains(query) {
362 return Some(0.7 + length_bonus(query, &path_norm));
363 }
364
365 if let Some(score) = fuzzy_score(query, &name_norm) {
366 return Some(0.6 + 0.4 * score);
367 }
368 if let Some(score) = fuzzy_score(query, &path_norm) {
369 return Some(0.55 + 0.4 * score);
370 }
371
372 None
373 }
374
375 fn length_bonus(query: &str, target: &str) -> f64 {
376 let q_len = query.chars().count().max(1) as f64;
377 let t_len = target.chars().count().max(1) as f64;
378 (q_len / t_len).min(1.0) * 0.08
379 }
380
381 fn fuzzy_score(query: &str, target: &str) -> Option<f64> {
382 let mut positions = Vec::new();
383 let mut query_chars = query.chars();
384 let mut current = query_chars.next()?;
385
386 for (idx, ch) in target.chars().enumerate() {
387 if ch == current {
388 positions.push(idx);
389 if let Some(next) = query_chars.next() {
390 current = next;
391 } else {
392 break;
393 }
394 }
395 }
396
397 if positions.len() != query.chars().count() {
398 return None;
399 }
400
401 let first = *positions.first().unwrap_or(&0) as f64;
402 let last = *positions.last().unwrap_or(&0) as f64;
403 let span = (last - first + 1.0).max(1.0);
404 let query_len = query.chars().count().max(1) as f64;
405 let target_len = target.chars().count().max(1) as f64;
406
407 let density = (query_len / span).min(1.0);
408 let coverage = (query_len / target_len).min(1.0);
409 Some((density * 0.7 + coverage * 0.3).min(1.0))
410 }
411
412 fn compare_match(a: &FileSearchMatch, b: &FileSearchMatch) -> Ordering {
413 b.score
414 .partial_cmp(&a.score)
415 .unwrap_or(Ordering::Equal)
416 .then_with(|| a.path.cmp(&b.path))
417 }
418
419 #[cfg(test)]
420 mod tests {
421 use super::*;
422 use tempfile::tempdir;
423
424 #[tokio::test]
425 async fn test_file_search_basic() {
426 let tmp = tempdir().expect("tempdir");
427 let root = tmp.path();
428 std::fs::create_dir_all(root.join("src")).expect("mkdir");
429 std::fs::write(root.join("src").join("main.rs"), "fn main() {}\n").expect("write");
430 std::fs::write(root.join("README.md"), "docs\n").expect("write");
431
432 let ctx = ToolContext::new(root.to_path_buf());
433 let tool = FileSearchTool;
434 let result = tool
435 .execute(json!({"query": "main", "limit": 5}), &ctx)
436 .await
437 .expect("execute");
438
439 assert!(result.success);
440 assert!(result.content.contains("main.rs"));
441 }
442
443 #[tokio::test]
444 async fn test_file_search_respects_gitignore() {
445 let tmp = tempdir().expect("tempdir");
446 let root = tmp.path();
447 std::fs::write(root.join(".gitignore"), "ignored.txt\n").expect("write");
448 std::fs::write(root.join("ignored.txt"), "nope\n").expect("write");
449 std::fs::write(root.join("keep.txt"), "ok\n").expect("write");
450
451 let ctx = ToolContext::new(root.to_path_buf());
452 let tool = FileSearchTool;
453 let result = tool
454 .execute(json!({"query": "txt"}), &ctx)
455 .await
456 .expect("execute");
457
458 assert!(result.success);
459 assert!(!result.content.contains("ignored.txt"));
460 assert!(result.content.contains("keep.txt"));
461 }
462
463 #[tokio::test]
464 async fn test_file_search_extension_filter() {
465 let tmp = tempdir().expect("tempdir");
466 let root = tmp.path();
467 std::fs::write(root.join("main.rs"), "fn main() {}\n").expect("write");
468 std::fs::write(root.join("notes.md"), "docs\n").expect("write");
469
470 let ctx = ToolContext::new(root.to_path_buf());
471 let tool = FileSearchTool;
472 let result = tool
473 .execute(json!({"query": "m", "extensions": ["rs"]}), &ctx)
474 .await
475 .expect("execute");
476
477 assert!(result.success);
478 assert!(result.content.contains("main.rs"));
479 assert!(!result.content.contains("notes.md"));
480 }
481
482 #[tokio::test]
483 async fn test_file_search_exclude_filter() {
484 let tmp = tempdir().expect("tempdir");
485 let root = tmp.path();
486 std::fs::create_dir_all(root.join("fixtures")).expect("mkdir");
487 std::fs::write(root.join("fixtures").join("needle.txt"), "no\n").expect("write");
488 std::fs::write(root.join("needle.txt"), "yes\n").expect("write");
489
490 let ctx = ToolContext::new(root.to_path_buf());
491 let tool = FileSearchTool;
492 let result = tool
493 .execute(json!({"query": "needle", "exclude": ["fixtures/**"]}), &ctx)
494 .await
495 .expect("execute");
496
497 assert!(result.success);
498 let matches: Value = serde_json::from_str(&result.content).expect("search json");
499 assert!(
500 matches
501 .as_array()
502 .expect("matches")
503 .iter()
504 .any(|item| item.get("path").and_then(Value::as_str) == Some("needle.txt"))
505 );
506 assert!(!result.content.contains("fixtures/needle.txt"));
507 }
508
509 #[tokio::test]
510 async fn test_file_search_default_excludes_build_artifacts() {
511 let tmp = tempdir().expect("tempdir");
512 let root = tmp.path();
513 std::fs::create_dir_all(root.join("target")).expect("mkdir");
514 std::fs::write(root.join("target").join("needle.txt"), "no\n").expect("write");
515 std::fs::write(root.join("needle.txt"), "yes\n").expect("write");
516
517 let ctx = ToolContext::new(root.to_path_buf());
518 let tool = FileSearchTool;
519 let result = tool
520 .execute(json!({"query": "needle"}), &ctx)
521 .await
522 .expect("execute");
523
524 assert!(result.success);
525 let matches: Value = serde_json::from_str(&result.content).expect("search json");
526 assert!(
527 matches
528 .as_array()
529 .expect("matches")
530 .iter()
531 .any(|item| item.get("path").and_then(Value::as_str) == Some("needle.txt"))
532 );
533 assert!(!result.content.contains("target/needle.txt"));
534 }
535
536 #[tokio::test]
537 async fn test_file_search_respects_cancel_token() {
538 let tmp = tempdir().expect("tempdir");
539 let root = tmp.path();
540 std::fs::write(root.join("needle.txt"), "yes\n").expect("write");
541 let cancel_token = CancellationToken::new();
542 cancel_token.cancel();
543 let ctx = ToolContext::new(root.to_path_buf()).with_cancel_token(cancel_token);
544
545 let tool = FileSearchTool;
546 let err = tool
547 .execute(json!({"query": "needle"}), &ctx)
548 .await
549 .expect_err("cancelled file_search should return an error");
550
551 assert!(
552 format!("{err:?}").contains("cancelled"),
553 "unexpected error: {err:?}"
554 );
555 }
556
557 #[tokio::test]
558 async fn test_file_search_blocking_wrapper_reports_timeout() {
559 let err = run_blocking_file_search(Duration::from_millis(1), None, || {
560 std::thread::sleep(Duration::from_millis(50));
561 Ok(Vec::new())
562 })
563 .await
564 .expect_err("slow file_search worker should time out");
565
566 assert!(
567 matches!(err, ToolError::Timeout { seconds: 1 }),
568 "unexpected error: {err:?}"
569 );
570 }
571
572 #[tokio::test]
573 #[cfg(unix)]
574 async fn test_file_search_does_not_follow_symlinked_files() {
575 let tmp = tempdir().expect("tempdir");
576 let root = tmp.path().join("workspace");
577 let outside = tmp.path().join("outside");
578 std::fs::create_dir_all(&root).expect("mkdir workspace");
579 std::fs::create_dir_all(&outside).expect("mkdir outside");
580 let outside_file = outside.join("secret.txt");
581 std::fs::write(&outside_file, "outside\n").expect("write outside");
582 std::os::unix::fs::symlink(&outside_file, root.join("secret.txt")).expect("symlink");
583
584 let ctx = ToolContext::new(root);
585 let tool = FileSearchTool;
586 let result = tool
587 .execute(json!({"query": "secret"}), &ctx)
588 .await
589 .expect("execute");
590
591 assert!(result.success);
592 assert!(!result.content.contains("secret.txt"));
593 }
594 }
595
595 lines RUST