| 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 |