返回 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 true
40 }
41
42 fn description(&self) -> &'static str {
43 "Find workspace files by name using fuzzy matching with score-based ranking. 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 (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 // S1: enumerating names inside a denied tree is a read of that tree —
98 // Seatbelt's `deny file-read*` blocks readdir of denied dirs, so a
99 // denied search root is refused rather than walked. The raw spelling is
100 // checked before `resolve_path` (F2) so the refusal names the caller's
101 // path, never a symlink target it might resolve to.
102 let base_path = match optional_str(&input, "path")? {
103 Some(path) if !path.trim().is_empty() => {
104 super::file::enforce_read_denylist(Path::new(path), "file_search")?;
105 context.resolve_path(path)?
106 }
107 _ => context.workspace.clone(),
108 };
109 super::file::enforce_read_denylist(&base_path, "file_search")?;
110
111 let extensions = parse_extensions(&input);
112 let exclude_patterns = parse_exclude_patterns(&input);
113 let matches = search_files_async(
114 query.to_string(),
115 base_path,
116 extensions,
117 exclude_patterns,
118 limit,
119 context.cancel_token.clone(),
120 FILE_SEARCH_TIMEOUT,
121 context.follow_symlinks,
122 )
123 .await?;
124 ToolResult::json(&matches).map_err(|e| ToolError::execution_failed(e.to_string()))
125 }
126 }
127
128 #[allow(clippy::too_many_arguments)]
129 async fn search_files_async(
130 query: String,
131 base_path: PathBuf,
132 extensions: Vec<String>,
133 exclude_patterns: Vec<String>,
134 limit: usize,
135 cancel_token: Option<CancellationToken>,
136 timeout: Duration,
137 follow_symlinks: bool,
138 ) -> Result<Vec<FileSearchMatch>, ToolError> {
139 let worker_cancel_token = cancel_token.clone();
140 run_blocking_file_search(timeout, cancel_token, move || {
141 search_files(
142 &query,
143 &base_path,
144 extensions,
145 exclude_patterns,
146 limit,
147 worker_cancel_token.as_ref(),
148 follow_symlinks,
149 )
150 })
151 .await
152 }
153
154 async fn run_blocking_file_search<F>(
155 timeout: Duration,
156 cancel_token: Option<CancellationToken>,
157 search: F,
158 ) -> Result<Vec<FileSearchMatch>, ToolError>
159 where
160 F: FnOnce() -> Result<Vec<FileSearchMatch>, ToolError> + Send + 'static,
161 {
162 if cancel_token
163 .as_ref()
164 .is_some_and(CancellationToken::is_cancelled)
165 {
166 return Err(file_search_cancelled());
167 }
168
169 let task = tokio::task::spawn_blocking(search);
170 let result = match cancel_token {
171 Some(token) => {
172 tokio::select! {
173 biased;
174 () = token.cancelled() => return Err(file_search_cancelled()),
175 result = tokio::time::timeout(timeout, task) => result,
176 }
177 }
178 None => tokio::time::timeout(timeout, task).await,
179 };
180
181 let joined = result.map_err(|_| file_search_timeout(timeout))?;
182 joined.map_err(|err| {
183 ToolError::execution_failed(format!(
184 "file_search worker failed before completion: {err}"
185 ))
186 })?
187 }
188
189 fn file_search_cancelled() -> ToolError {
190 ToolError::cancelled("file_search cancelled before completion")
191 }
192
193 fn file_search_timeout(timeout: Duration) -> ToolError {
194 ToolError::Timeout {
195 seconds: timeout.as_secs().max(1),
196 }
197 }
198
199 fn parse_extensions(input: &Value) -> Vec<String> {
200 let mut out = Vec::new();
201 if let Some(values) = input.get("extensions").and_then(|v| v.as_array()) {
202 for value in values {
203 if let Some(ext) = value.as_str() {
204 let ext = ext.trim().trim_start_matches('.').to_ascii_lowercase();
205 if !ext.is_empty() {
206 out.push(ext);
207 }
208 }
209 }
210 }
211 if out.is_empty()
212 && let Some(value) = input.get("extension").and_then(|v| v.as_str())
213 {
214 let ext = value.trim().trim_start_matches('.').to_ascii_lowercase();
215 if !ext.is_empty() {
216 out.push(ext);
217 }
218 }
219 out
220 }
221
222 fn parse_exclude_patterns(input: &Value) -> Vec<String> {
223 if let Some(values) = input.get("exclude").and_then(Value::as_array) {
224 return values
225 .iter()
226 .filter_map(Value::as_str)
227 .map(str::trim)
228 .filter(|pattern| !pattern.is_empty())
229 .map(ToOwned::to_owned)
230 .collect();
231 }
232
233 [
234 "target/**",
235 "node_modules/**",
236 ".git/**",
237 "DerivedData/**",
238 "dist/**",
239 "build/**",
240 "*.lock",
241 "*.plist",
242 ]
243 .into_iter()
244 .map(ToOwned::to_owned)
245 .collect()
246 }
247
248 fn search_files(
249 query: &str,
250 base_path: &Path,
251 extensions: Vec<String>,
252 exclude_patterns: Vec<String>,
253 limit: usize,
254 cancel_token: Option<&CancellationToken>,
255 follow_symlinks: bool,
256 ) -> Result<Vec<FileSearchMatch>, ToolError> {
257 check_cancelled(cancel_token)?;
258
259 if !base_path.exists() {
260 return Err(ToolError::invalid_input(format!(
261 "Base path does not exist: {}",
262 base_path.display()
263 )));
264 }
265
266 let query_norm = query.to_ascii_lowercase();
267 let mut results: Vec<FileSearchMatch> = Vec::new();
268
269 let mut builder = WalkBuilder::new(base_path);
270 builder
271 .hidden(false)
272 .follow_links(follow_symlinks)
273 .require_git(false);
274 let walker = builder.build();
275
276 for entry in walker {
277 check_cancelled(cancel_token)?;
278
279 let entry = match entry {
280 Ok(entry) => entry,
281 Err(_) => continue,
282 };
283 // Sandbox read deny-list (S1). A walk rooted above a denied tree —
284 // e.g. `path = "~"` — must not enumerate the names inside it
285 // (`~/.ssh/id_rsa` …), exactly as `search` skips denied files during
286 // its walk. Skipped silently rather than failing the whole search: a
287 // name search is not a directed read, and the explicit refusal for one
288 // lives at the root guard above.
289 if let Err(denial) = crate::sandbox::read_guard::active().check(entry.path()) {
290 tracing::debug!(
291 target: "codewhale::sandbox::read_guard",
292 requested = %denial.requested.display(),
293 "sandbox read deny-list skipped an entry during file_search"
294 );
295 continue;
296 }
297 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
298 continue;
299 }
300
301 let path = entry.path();
302 let rel_path = path
303 .strip_prefix(base_path)
304 .unwrap_or(path)
305 .to_string_lossy()
306 .replace('\\', "/");
307 if should_exclude(&rel_path, &exclude_patterns) {
308 continue;
309 }
310
311 if !extensions.is_empty() && !extension_matches(path, &extensions) {
312 continue;
313 }
314
315 let name = file_name(path);
316
317 let score = match score_match(&query_norm, &rel_path, &name) {
318 Some(score) => score,
319 None => continue,
320 };
321
322 results.push(FileSearchMatch {
323 path: rel_path,
324 name,
325 score,
326 });
327 }
328
329 results.sort_by(compare_match);
330 if results.len() > limit {
331 results.truncate(limit);
332 }
333 Ok(results)
334 }
335
336 fn check_cancelled(cancel_token: Option<&CancellationToken>) -> Result<(), ToolError> {
337 if cancel_token.is_some_and(CancellationToken::is_cancelled) {
338 return Err(file_search_cancelled());
339 }
340 Ok(())
341 }
342
343 fn should_exclude(rel_path: &str, exclude_patterns: &[String]) -> bool {
344 exclude_patterns
345 .iter()
346 .any(|pattern| matches_glob(rel_path, pattern))
347 }
348
349 fn extension_matches(path: &Path, extensions: &[String]) -> bool {
350 let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
351 return false;
352 };
353 let ext = ext.to_ascii_lowercase();
354 extensions.iter().any(|wanted| wanted == &ext)
355 }
356
357 fn file_name(path: &Path) -> String {
358 path.file_name()
359 .map(|name| name.to_string_lossy().into_owned())
360 .unwrap_or_else(|| path.to_string_lossy().to_string())
361 }
362
363 fn score_match(query: &str, rel_path: &str, name: &str) -> Option<f64> {
364 let path_norm = rel_path.to_ascii_lowercase();
365 let name_norm = name.to_ascii_lowercase();
366
367 if name_norm == query {
368 return Some(1.0);
369 }
370 if path_norm == query {
371 return Some(0.98);
372 }
373
374 if name_norm.starts_with(query) {
375 return Some(0.9 + length_bonus(query, &name_norm));
376 }
377 if path_norm.starts_with(query) {
378 return Some(0.85 + length_bonus(query, &path_norm));
379 }
380
381 if name_norm.contains(query) {
382 return Some(0.75 + length_bonus(query, &name_norm));
383 }
384 if path_norm.contains(query) {
385 return Some(0.7 + length_bonus(query, &path_norm));
386 }
387
388 if let Some(score) = fuzzy_score(query, &name_norm) {
389 return Some(0.6 + 0.4 * score);
390 }
391 if let Some(score) = fuzzy_score(query, &path_norm) {
392 return Some(0.55 + 0.4 * score);
393 }
394
395 None
396 }
397
398 fn length_bonus(query: &str, target: &str) -> f64 {
399 let q_len = query.chars().count().max(1) as f64;
400 let t_len = target.chars().count().max(1) as f64;
401 (q_len / t_len).min(1.0) * 0.08
402 }
403
404 fn fuzzy_score(query: &str, target: &str) -> Option<f64> {
405 let mut positions = Vec::new();
406 let mut query_chars = query.chars();
407 let mut current = query_chars.next()?;
408
409 for (idx, ch) in target.chars().enumerate() {
410 if ch == current {
411 positions.push(idx);
412 if let Some(next) = query_chars.next() {
413 current = next;
414 } else {
415 break;
416 }
417 }
418 }
419
420 if positions.len() != query.chars().count() {
421 return None;
422 }
423
424 let first = *positions.first().unwrap_or(&0) as f64;
425 let last = *positions.last().unwrap_or(&0) as f64;
426 let span = (last - first + 1.0).max(1.0);
427 let query_len = query.chars().count().max(1) as f64;
428 let target_len = target.chars().count().max(1) as f64;
429
430 let density = (query_len / span).min(1.0);
431 let coverage = (query_len / target_len).min(1.0);
432 Some((density * 0.7 + coverage * 0.3).min(1.0))
433 }
434
435 fn compare_match(a: &FileSearchMatch, b: &FileSearchMatch) -> Ordering {
436 b.score
437 .partial_cmp(&a.score)
438 .unwrap_or(Ordering::Equal)
439 .then_with(|| a.path.cmp(&b.path))
440 }
441
442 #[cfg(test)]
443 mod tests {
444 use super::*;
445 use tempfile::tempdir;
446
447 #[tokio::test]
448 async fn test_file_search_basic() {
449 let tmp = tempdir().expect("tempdir");
450 let root = tmp.path();
451 std::fs::create_dir_all(root.join("src")).expect("mkdir");
452 std::fs::write(root.join("src").join("main.rs"), "fn main() {}\n").expect("write");
453 std::fs::write(root.join("README.md"), "docs\n").expect("write");
454
455 let ctx = ToolContext::new(root.to_path_buf());
456 let tool = FileSearchTool;
457 let result = tool
458 .execute(json!({"query": "main", "limit": 5}), &ctx)
459 .await
460 .expect("execute");
461
462 assert!(result.success);
463 assert!(result.content.contains("main.rs"));
464 }
465
466 #[tokio::test]
467 async fn test_file_search_respects_gitignore() {
468 let tmp = tempdir().expect("tempdir");
469 let root = tmp.path();
470 std::fs::write(root.join(".gitignore"), "ignored.txt\n").expect("write");
471 std::fs::write(root.join("ignored.txt"), "nope\n").expect("write");
472 std::fs::write(root.join("keep.txt"), "ok\n").expect("write");
473
474 let ctx = ToolContext::new(root.to_path_buf());
475 let tool = FileSearchTool;
476 let result = tool
477 .execute(json!({"query": "txt"}), &ctx)
478 .await
479 .expect("execute");
480
481 assert!(result.success);
482 assert!(!result.content.contains("ignored.txt"));
483 assert!(result.content.contains("keep.txt"));
484 }
485
486 #[tokio::test]
487 async fn test_file_search_extension_filter() {
488 let tmp = tempdir().expect("tempdir");
489 let root = tmp.path();
490 std::fs::write(root.join("main.rs"), "fn main() {}\n").expect("write");
491 std::fs::write(root.join("notes.md"), "docs\n").expect("write");
492
493 let ctx = ToolContext::new(root.to_path_buf());
494 let tool = FileSearchTool;
495 let result = tool
496 .execute(json!({"query": "m", "extensions": ["rs"]}), &ctx)
497 .await
498 .expect("execute");
499
500 assert!(result.success);
501 assert!(result.content.contains("main.rs"));
502 assert!(!result.content.contains("notes.md"));
503 }
504
505 #[tokio::test]
506 async fn test_file_search_exclude_filter() {
507 let tmp = tempdir().expect("tempdir");
508 let root = tmp.path();
509 std::fs::create_dir_all(root.join("fixtures")).expect("mkdir");
510 std::fs::write(root.join("fixtures").join("needle.txt"), "no\n").expect("write");
511 std::fs::write(root.join("needle.txt"), "yes\n").expect("write");
512
513 let ctx = ToolContext::new(root.to_path_buf());
514 let tool = FileSearchTool;
515 let result = tool
516 .execute(json!({"query": "needle", "exclude": ["fixtures/**"]}), &ctx)
517 .await
518 .expect("execute");
519
520 assert!(result.success);
521 let matches: Value = serde_json::from_str(&result.content).expect("search json");
522 assert!(
523 matches
524 .as_array()
525 .expect("matches")
526 .iter()
527 .any(|item| item.get("path").and_then(Value::as_str) == Some("needle.txt"))
528 );
529 assert!(!result.content.contains("fixtures/needle.txt"));
530 }
531
532 #[tokio::test]
533 async fn test_file_search_default_excludes_build_artifacts() {
534 let tmp = tempdir().expect("tempdir");
535 let root = tmp.path();
536 std::fs::create_dir_all(root.join("target")).expect("mkdir");
537 std::fs::write(root.join("target").join("needle.txt"), "no\n").expect("write");
538 std::fs::write(root.join("needle.txt"), "yes\n").expect("write");
539
540 let ctx = ToolContext::new(root.to_path_buf());
541 let tool = FileSearchTool;
542 let result = tool
543 .execute(json!({"query": "needle"}), &ctx)
544 .await
545 .expect("execute");
546
547 assert!(result.success);
548 let matches: Value = serde_json::from_str(&result.content).expect("search json");
549 assert!(
550 matches
551 .as_array()
552 .expect("matches")
553 .iter()
554 .any(|item| item.get("path").and_then(Value::as_str) == Some("needle.txt"))
555 );
556 assert!(!result.content.contains("target/needle.txt"));
557 }
558
559 #[tokio::test]
560 async fn test_file_search_respects_cancel_token() {
561 let tmp = tempdir().expect("tempdir");
562 let root = tmp.path();
563 std::fs::write(root.join("needle.txt"), "yes\n").expect("write");
564 let cancel_token = CancellationToken::new();
565 cancel_token.cancel();
566 let ctx = ToolContext::new(root.to_path_buf()).with_cancel_token(cancel_token);
567
568 let tool = FileSearchTool;
569 let err = tool
570 .execute(json!({"query": "needle"}), &ctx)
571 .await
572 .expect_err("cancelled file_search should return an error");
573
574 assert!(
575 format!("{err:?}").contains("cancelled"),
576 "unexpected error: {err:?}"
577 );
578 }
579
580 #[tokio::test]
581 async fn test_file_search_blocking_wrapper_reports_timeout() {
582 let err = run_blocking_file_search(Duration::from_millis(1), None, || {
583 std::thread::sleep(Duration::from_millis(50));
584 Ok(Vec::new())
585 })
586 .await
587 .expect_err("slow file_search worker should time out");
588
589 assert!(
590 matches!(err, ToolError::Timeout { seconds: 1 }),
591 "unexpected error: {err:?}"
592 );
593 }
594
595 #[tokio::test]
596 #[cfg(unix)]
597 async fn test_file_search_does_not_follow_symlinked_files() {
598 let tmp = tempdir().expect("tempdir");
599 let root = tmp.path().join("workspace");
600 let outside = tmp.path().join("outside");
601 std::fs::create_dir_all(&root).expect("mkdir workspace");
602 std::fs::create_dir_all(&outside).expect("mkdir outside");
603 let outside_file = outside.join("secret.txt");
604 std::fs::write(&outside_file, "outside\n").expect("write outside");
605 std::os::unix::fs::symlink(&outside_file, root.join("secret.txt")).expect("symlink");
606
607 let ctx = ToolContext::new(root);
608 let tool = FileSearchTool;
609 let result = tool
610 .execute(json!({"query": "secret"}), &ctx)
611 .await
612 .expect("execute");
613
614 assert!(result.success);
615 assert!(!result.content.contains("secret.txt"));
616 }
617
618 /// F1: searching a denied directory is enumeration of it — `file_search`
619 /// with its root inside a denied tree must refuse, matching the OS layer
620 /// (Seatbelt `deny file-read*` blocks readdir of denied dirs).
621 #[tokio::test]
622 async fn test_file_search_refuses_a_denied_root() {
623 let holder = tempdir().expect("tempdir");
624 let project = holder.path().join("project");
625 // Deterministic anchor: the `.env` filename rule denies any path whose
626 // file name is `.env`, directory or not.
627 std::fs::create_dir_all(project.join(".env")).expect("mkdir");
628 std::fs::write(project.join(".env").join("token"), "x\n").expect("write");
629
630 let ctx = ToolContext::new(project.clone());
631 let tool = FileSearchTool;
632 let error = tool
633 .execute(
634 json!({"query": "token", "path": project.join(".env")}),
635 &ctx,
636 )
637 .await
638 .expect_err("a denied search root must be refused, not walked");
639
640 assert!(
641 matches!(error, ToolError::PermissionDenied { .. }),
642 "expected a permission refusal, got: {error:?}"
643 );
644 }
645
646 /// F1: a walk rooted *above* a denied path must not enumerate names inside
647 /// it — `path = "~"` must not surface `~/.ssh/id_rsa`, and a project root
648 /// must not surface its `.env` (denied by name under the default list).
649 #[tokio::test]
650 async fn test_file_search_skips_denied_entries_during_the_walk() {
651 let tmp = tempdir().expect("tempdir");
652 let root = tmp.path();
653 std::fs::write(root.join("needle.txt"), "yes\n").expect("write");
654 std::fs::write(root.join(".env"), "SECRET=1\n").expect("write env");
655 std::fs::write(root.join(".env.local"), "SECRET=2\n").expect("write env local");
656
657 let ctx = ToolContext::new(root.to_path_buf());
658 let tool = FileSearchTool;
659 let result = tool
660 .execute(json!({"query": "env"}), &ctx)
661 .await
662 .expect("execute");
663
664 assert!(result.success);
665 assert!(
666 !result.content.contains(".env"),
667 "denied entries must not be enumerated by a name search: {}",
668 result.content
669 );
670
671 // The innocent sibling is still found — the deny-list filters, it does
672 // not blank the search.
673 let result = tool
674 .execute(json!({"query": "needle"}), &ctx)
675 .await
676 .expect("execute");
677 assert!(result.success);
678 assert!(result.content.contains("needle.txt"));
679 }
680 }
681
681 lines RUST