| 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(®ex_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 |