use std::path::Path; use std::process::Stdio; use async_trait::async_trait; use serde_json::json; use tokio::process::Command; use tokio::time::timeout; use crate::tools::traits::{Tool, ToolResult}; const MAX_RESULTS: usize = 200; const MAX_OUTPUT_CHARS: usize = 50_000; const TIMEOUT_SECS: u64 = 60; pub struct FileSearchTool; impl FileSearchTool { pub fn new() -> Self { Self } fn resolve_dir(&self, dir: Option<&str>) -> String { match dir { Some(d) if !d.is_empty() => d.to_string(), _ => ".".to_string(), } } fn truncate_output(&self, lines: &[String]) -> String { let mut output = String::new(); for (i, line) in lines.iter().enumerate() { if output.len() + line.len() + 1 > MAX_OUTPUT_CHARS { let omitted = lines.len() - i; output.push_str(&format!("\n... ({} files omitted) ...", omitted)); break; } if !output.is_empty() { output.push('\n'); } output.push_str(line); } output } } impl Default for FileSearchTool { fn default() -> Self { Self::new() } } #[async_trait] impl Tool for FileSearchTool { fn name(&self) -> &str { "file_search" } fn description(&self) -> &str { "Search for files by glob pattern (e.g. '*.rs', 'test_*.rs'). Internally uses fd (fast find) for efficient searching — if fd is not available, falls back to the find command, then pure Rust glob matching." } fn parameters_schema(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "pattern": { "type": "string", "description": "File glob pattern to search for (e.g. *.rs, test_*.rs, src/**/*.py)" }, "dir": { "type": "string", "description": "Directory to search in (default: current working directory)" }, "case_sensitive": { "type": "boolean", "description": "Whether to match case-sensitively (default: true)" }, "max_results": { "type": "integer", "description": "Maximum number of results to return (default: 200)" } }, "required": ["pattern"] }) } fn read_only(&self) -> bool { true } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { let pattern = match args.get("pattern").and_then(|v| v.as_str()) { Some(p) if !p.is_empty() => p, _ => { return Ok(ToolResult { success: false, output: String::new(), error: Some("Missing required parameter: pattern".to_string()), }); } }; let dir = self.resolve_dir(args.get("dir").and_then(|v| v.as_str())); let case_sensitive = args .get("case_sensitive") .and_then(|v| v.as_bool()) .unwrap_or(true); let max_results = args .get("max_results") .and_then(|v| v.as_u64()) .unwrap_or(MAX_RESULTS as u64) as usize; let result = self .run_search(pattern, &dir, case_sensitive, max_results) .await; match result { Ok(lines) => { let count = lines.len(); let mut output = self.truncate_output(&lines); output.push_str(&format!("\n\n---\n共 {} 个文件", count)); Ok(ToolResult { success: true, output, error: None, }) } Err(e) => Ok(ToolResult { success: false, output: String::new(), error: Some(e.to_string()), }), } } } impl FileSearchTool { async fn run_search( &self, pattern: &str, dir: &str, case_sensitive: bool, max_results: usize, ) -> anyhow::Result> { let fd_cmd = if which::which("fd").is_ok() { "fd" } else if which::which("fdfind").is_ok() { "fdfind" } else { "" }; if !fd_cmd.is_empty() { match self .search_with_fd(pattern, dir, case_sensitive, max_results, fd_cmd) .await { Ok(lines) if !lines.is_empty() => return Ok(lines), Ok(_) => {} Err(e) => tracing::warn!("{} failed: {}, falling back", fd_cmd, e), } } if which::which("find").is_ok() { match self.search_with_find(pattern, dir, max_results).await { Ok(lines) if !lines.is_empty() => return Ok(lines), Ok(_) => {} Err(e) => tracing::warn!("find failed: {}, falling back", e), } } tracing::warn!("No fd/find available, using built-in file search (slower)"); self.search_with_rust(pattern, dir, case_sensitive, max_results) .await } async fn search_with_fd( &self, pattern: &str, dir: &str, case_sensitive: bool, max_results: usize, fd_cmd: &str, ) -> anyhow::Result> { let mut cmd = Command::new(fd_cmd); cmd.arg("--search-path") .arg(dir) .arg("--glob") .arg(pattern) .arg("--color") .arg("never") .arg("--max-results") .arg(max_results.to_string()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); if !case_sensitive { cmd.arg("--ignore-case"); } let output = timeout(std::time::Duration::from_secs(TIMEOUT_SECS), cmd.output()) .await .map_err(|_| anyhow::anyhow!("fd timed out after {}s", TIMEOUT_SECS))??; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(anyhow::anyhow!("fd error: {}", stderr.trim())); } let text = String::from_utf8_lossy(&output.stdout); let lines: Vec = text .lines() .filter(|l| !l.is_empty()) .map(|l| l.to_string()) .collect(); Ok(lines) } async fn search_with_find( &self, pattern: &str, dir: &str, max_results: usize, ) -> anyhow::Result> { let mut cmd = Command::new("find"); cmd.arg(dir) .arg("-name") .arg(pattern) .arg("-not") .arg("-path") .arg("*/.*") .stdout(Stdio::piped()) .stderr(Stdio::null()); let output = timeout(std::time::Duration::from_secs(TIMEOUT_SECS), cmd.output()) .await .map_err(|_| anyhow::anyhow!("find timed out after {}s", TIMEOUT_SECS))??; let text = String::from_utf8_lossy(&output.stdout); let mut lines: Vec = text .lines() .filter(|l| !l.is_empty()) .map(|l| { let p = Path::new(l); p.to_string_lossy().to_string() }) .collect(); if lines.len() > max_results { lines.truncate(max_results); } Ok(lines) } async fn search_with_rust( &self, pattern: &str, dir: &str, case_sensitive: bool, max_results: usize, ) -> anyhow::Result> { let regex_str = glob_to_regex(pattern); let re = if case_sensitive { regex::Regex::new(®ex_str) } else { regex::RegexBuilder::new(®ex_str) .case_insensitive(true) .build() } .map_err(|e| anyhow::anyhow!("Invalid glob pattern '{}': {}", pattern, e))?; let mut results = Vec::new(); walk_dir( Path::new(dir), Path::new(dir), &re, &mut results, max_results, )?; Ok(results) } } fn glob_to_regex(glob: &str) -> String { let mut regex = String::from("^"); let chars: Vec = glob.chars().collect(); let mut i = 0; while i < chars.len() { match chars[i] { '*' => { if i + 1 < chars.len() && chars[i + 1] == '*' { regex.push_str(".*"); i += 1; } else { regex.push_str("[^/]*"); } } '?' => regex.push_str("[^/]"), '.' | '+' | '(' | ')' | '[' | ']' | '{' | '}' | '^' | '$' | '|' | '\\' => { regex.push('\\'); regex.push(chars[i]); } c => regex.push(c), } i += 1; } regex.push('$'); regex } fn walk_dir( base: &Path, current: &Path, re: ®ex::Regex, results: &mut Vec, max: usize, ) -> anyhow::Result<()> { if results.len() >= max { return Ok(()); } let entries = match std::fs::read_dir(current) { Ok(e) => e, Err(_) => return Ok(()), }; for entry in entries.flatten() { let path = entry.path(); let rel = match path.strip_prefix(base) { Ok(r) => r, Err(_) => continue, }; if path.is_dir() { if let Some(name) = rel.file_name().and_then(|n| n.to_str()) && name.starts_with('.') && name.len() > 1 { continue; } walk_dir(base, &path, re, results, max)?; } else if path.is_file() { if let Some(name) = rel.file_name().and_then(|n| n.to_str()) && re.is_match(name) { results.push(rel.to_string_lossy().to_string()); } if results.len() >= max { return Ok(()); } } } Ok(()) } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::TempDir; #[cfg(unix)] #[tokio::test] async fn fd_search_path_does_not_use_incompatible_strip_cwd_prefix() { use std::os::unix::fs::PermissionsExt; let dir = TempDir::new().unwrap(); let fake_fd = dir.path().join("fd"); fs::write( &fake_fd, r#"#!/bin/sh for arg in "$@"; do if [ "$arg" = "--strip-cwd-prefix" ]; then echo "--strip-cwd-prefix conflicts with --search-path" >&2 exit 2 fi done printf '%s\n' 'example.rs' "#, ) .unwrap(); let mut permissions = fs::metadata(&fake_fd).unwrap().permissions(); permissions.set_mode(0o755); fs::set_permissions(&fake_fd, permissions).unwrap(); let tool = FileSearchTool::new(); let results = tool .search_with_fd( "*.rs", dir.path().to_str().unwrap(), true, 10, fake_fd.to_str().unwrap(), ) .await .unwrap(); assert_eq!(results, vec!["example.rs"]); } #[tokio::test] async fn test_file_search_rust_fallback() { let dir = TempDir::new().unwrap(); fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap(); fs::write(dir.path().join("lib.rs"), "pub fn foo() {}").unwrap(); fs::write(dir.path().join("test.rs"), "#[test] fn t() {}").unwrap(); fs::write(dir.path().join("README.md"), "# Readme").unwrap(); fs::create_dir(dir.path().join("src")).unwrap(); fs::write(dir.path().join("src/nested.rs"), "fn nested() {}").unwrap(); let tool = FileSearchTool::new(); let result = tool .execute(json!({ "pattern": "*.rs", "dir": dir.path().to_str().unwrap() })) .await .unwrap(); assert!(result.success); assert!(result.output.contains("main.rs")); assert!(result.output.contains("lib.rs")); assert!(result.output.contains("test.rs")); assert!(result.output.contains("nested.rs")); assert!(!result.output.contains("README.md")); assert!(result.output.contains("共 4 个文件")); } #[tokio::test] async fn test_file_search_max_results() { let dir = TempDir::new().unwrap(); for i in 0..5 { fs::write(dir.path().join(format!("file_{}.rs", i)), "").unwrap(); } let tool = FileSearchTool::new(); let result = tool .execute(json!({ "pattern": "*.rs", "dir": dir.path().to_str().unwrap(), "max_results": 3 })) .await .unwrap(); assert!(result.success); assert!(result.output.contains("共 3 个文件")); } }