| 1 | //! File search tool with fuzzy matching and scoring. |
| 2 | |
| 3 | use std::cmp::Ordering; |
| 4 | use std::path::Path; |
| 5 | |
| 6 | use async_trait::async_trait; |
| 7 | use ignore::WalkBuilder; |
| 8 | use serde::Serialize; |
| 9 | use serde_json::{Value, json}; |
| 10 | |
| 11 | use super::spec::{ |
| 12 | ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, |
| 13 | optional_str, optional_u64, required_str, |
| 14 | }; |
| 15 | |
| 16 | #[derive(Debug, Clone, Serialize)] |
| 17 | struct FileSearchMatch { |
| 18 | path: String, |
| 19 | name: String, |
| 20 | score: f64, |
| 21 | } |
| 22 | |
| 23 | pub struct FileSearchTool; |
| 24 | |
| 25 | #[async_trait] |
| 26 | impl ToolSpec for FileSearchTool { |
| 27 | fn name(&self) -> &'static str { |
| 28 | "file_search" |
| 29 | } |
| 30 | |
| 31 | fn description(&self) -> &'static str { |
| 32 | "Search for files using fuzzy matching with score-based ranking." |
| 33 | } |
| 34 | |
| 35 | fn input_schema(&self) -> Value { |
| 36 | json!({ |
| 37 | "type": "object", |
| 38 | "properties": { |
| 39 | "query": { |
| 40 | "type": "string", |
| 41 | "description": "Search query (file name or path fragment)." |
| 42 | }, |
| 43 | "path": { |
| 44 | "type": "string", |
| 45 | "description": "Optional base path to search (relative to workspace)." |
| 46 | }, |
| 47 | "limit": { |
| 48 | "type": "integer", |
| 49 | "description": "Maximum number of results to return (default: 20)." |
| 50 | }, |
| 51 | "extensions": { |
| 52 | "type": "array", |
| 53 | "items": { "type": "string" }, |
| 54 | "description": "Optional list of file extensions to include (e.g. [\"rs\", \"md\"])." |
| 55 | } |
| 56 | }, |
| 57 | "required": ["query"] |
| 58 | }) |
| 59 | } |
| 60 | |
| 61 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 62 | vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable] |
| 63 | } |
| 64 | |
| 65 | fn approval_requirement(&self) -> ApprovalRequirement { |
| 66 | ApprovalRequirement::Auto |
| 67 | } |
| 68 | |
| 69 | async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> { |
| 70 | let query = required_str(&input, "query")?.trim(); |
| 71 | if query.is_empty() { |
| 72 | return Err(ToolError::invalid_input("query cannot be empty")); |
| 73 | } |
| 74 | |
| 75 | let limit = optional_u64(&input, "limit", 20).clamp(1, 200) as usize; |
| 76 | let base_path = match optional_str(&input, "path") { |
| 77 | Some(path) if !path.trim().is_empty() => context.resolve_path(path)?, |
| 78 | _ => context.workspace.clone(), |
| 79 | }; |
| 80 | |
| 81 | let extensions = parse_extensions(&input); |
| 82 | let matches = search_files(query, &base_path, extensions, limit)?; |
| 83 | ToolResult::json(&matches).map_err(|e| ToolError::execution_failed(e.to_string())) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | fn parse_extensions(input: &Value) -> Vec<String> { |
| 88 | let mut out = Vec::new(); |
| 89 | if let Some(values) = input.get("extensions").and_then(|v| v.as_array()) { |
| 90 | for value in values { |
| 91 | if let Some(ext) = value.as_str() { |
| 92 | let ext = ext.trim().trim_start_matches('.').to_ascii_lowercase(); |
| 93 | if !ext.is_empty() { |
| 94 | out.push(ext); |
| 95 | } |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | if out.is_empty() |
| 100 | && let Some(value) = input.get("extension").and_then(|v| v.as_str()) |
| 101 | { |
| 102 | let ext = value.trim().trim_start_matches('.').to_ascii_lowercase(); |
| 103 | if !ext.is_empty() { |
| 104 | out.push(ext); |
| 105 | } |
| 106 | } |
| 107 | out |
| 108 | } |
| 109 | |
| 110 | fn search_files( |
| 111 | query: &str, |
| 112 | base_path: &Path, |
| 113 | extensions: Vec<String>, |
| 114 | limit: usize, |
| 115 | ) -> Result<Vec<FileSearchMatch>, ToolError> { |
| 116 | if !base_path.exists() { |
| 117 | return Err(ToolError::invalid_input(format!( |
| 118 | "Base path does not exist: {}", |
| 119 | base_path.display() |
| 120 | ))); |
| 121 | } |
| 122 | |
| 123 | let query_norm = query.to_ascii_lowercase(); |
| 124 | let mut results: Vec<FileSearchMatch> = Vec::new(); |
| 125 | |
| 126 | let mut builder = WalkBuilder::new(base_path); |
| 127 | builder.hidden(false).follow_links(true).require_git(false); |
| 128 | let walker = builder.build(); |
| 129 | |
| 130 | for entry in walker { |
| 131 | let entry = match entry { |
| 132 | Ok(entry) => entry, |
| 133 | Err(_) => continue, |
| 134 | }; |
| 135 | if !entry.file_type().is_some_and(|ft| ft.is_file()) { |
| 136 | continue; |
| 137 | } |
| 138 | |
| 139 | let path = entry.path(); |
| 140 | if !extensions.is_empty() && !extension_matches(path, &extensions) { |
| 141 | continue; |
| 142 | } |
| 143 | |
| 144 | let rel_path = path |
| 145 | .strip_prefix(base_path) |
| 146 | .unwrap_or(path) |
| 147 | .to_string_lossy() |
| 148 | .to_string(); |
| 149 | let name = file_name(path); |
| 150 | |
| 151 | let score = match score_match(&query_norm, &rel_path, &name) { |
| 152 | Some(score) => score, |
| 153 | None => continue, |
| 154 | }; |
| 155 | |
| 156 | results.push(FileSearchMatch { |
| 157 | path: rel_path, |
| 158 | name, |
| 159 | score, |
| 160 | }); |
| 161 | } |
| 162 | |
| 163 | results.sort_by(compare_match); |
| 164 | if results.len() > limit { |
| 165 | results.truncate(limit); |
| 166 | } |
| 167 | Ok(results) |
| 168 | } |
| 169 | |
| 170 | fn extension_matches(path: &Path, extensions: &[String]) -> bool { |
| 171 | let Some(ext) = path.extension().and_then(|e| e.to_str()) else { |
| 172 | return false; |
| 173 | }; |
| 174 | let ext = ext.to_ascii_lowercase(); |
| 175 | extensions.iter().any(|wanted| wanted == &ext) |
| 176 | } |
| 177 | |
| 178 | fn file_name(path: &Path) -> String { |
| 179 | path.file_name() |
| 180 | .map(|name| name.to_string_lossy().into_owned()) |
| 181 | .unwrap_or_else(|| path.to_string_lossy().to_string()) |
| 182 | } |
| 183 | |
| 184 | fn score_match(query: &str, rel_path: &str, name: &str) -> Option<f64> { |
| 185 | let path_norm = rel_path.to_ascii_lowercase(); |
| 186 | let name_norm = name.to_ascii_lowercase(); |
| 187 | |
| 188 | if name_norm == query { |
| 189 | return Some(1.0); |
| 190 | } |
| 191 | if path_norm == query { |
| 192 | return Some(0.98); |
| 193 | } |
| 194 | |
| 195 | if name_norm.starts_with(query) { |
| 196 | return Some(0.9 + length_bonus(query, &name_norm)); |
| 197 | } |
| 198 | if path_norm.starts_with(query) { |
| 199 | return Some(0.85 + length_bonus(query, &path_norm)); |
| 200 | } |
| 201 | |
| 202 | if name_norm.contains(query) { |
| 203 | return Some(0.75 + length_bonus(query, &name_norm)); |
| 204 | } |
| 205 | if path_norm.contains(query) { |
| 206 | return Some(0.7 + length_bonus(query, &path_norm)); |
| 207 | } |
| 208 | |
| 209 | if let Some(score) = fuzzy_score(query, &name_norm) { |
| 210 | return Some(0.6 + 0.4 * score); |
| 211 | } |
| 212 | if let Some(score) = fuzzy_score(query, &path_norm) { |
| 213 | return Some(0.55 + 0.4 * score); |
| 214 | } |
| 215 | |
| 216 | None |
| 217 | } |
| 218 | |
| 219 | fn length_bonus(query: &str, target: &str) -> f64 { |
| 220 | let q_len = query.chars().count().max(1) as f64; |
| 221 | let t_len = target.chars().count().max(1) as f64; |
| 222 | (q_len / t_len).min(1.0) * 0.08 |
| 223 | } |
| 224 | |
| 225 | fn fuzzy_score(query: &str, target: &str) -> Option<f64> { |
| 226 | let mut positions = Vec::new(); |
| 227 | let mut query_chars = query.chars(); |
| 228 | let mut current = query_chars.next()?; |
| 229 | |
| 230 | for (idx, ch) in target.chars().enumerate() { |
| 231 | if ch == current { |
| 232 | positions.push(idx); |
| 233 | if let Some(next) = query_chars.next() { |
| 234 | current = next; |
| 235 | } else { |
| 236 | break; |
| 237 | } |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | if positions.len() != query.chars().count() { |
| 242 | return None; |
| 243 | } |
| 244 | |
| 245 | let first = *positions.first().unwrap_or(&0) as f64; |
| 246 | let last = *positions.last().unwrap_or(&0) as f64; |
| 247 | let span = (last - first + 1.0).max(1.0); |
| 248 | let query_len = query.chars().count().max(1) as f64; |
| 249 | let target_len = target.chars().count().max(1) as f64; |
| 250 | |
| 251 | let density = (query_len / span).min(1.0); |
| 252 | let coverage = (query_len / target_len).min(1.0); |
| 253 | Some((density * 0.7 + coverage * 0.3).min(1.0)) |
| 254 | } |
| 255 | |
| 256 | fn compare_match(a: &FileSearchMatch, b: &FileSearchMatch) -> Ordering { |
| 257 | b.score |
| 258 | .partial_cmp(&a.score) |
| 259 | .unwrap_or(Ordering::Equal) |
| 260 | .then_with(|| a.path.cmp(&b.path)) |
| 261 | } |
| 262 | |
| 263 | #[cfg(test)] |
| 264 | mod tests { |
| 265 | use super::*; |
| 266 | use tempfile::tempdir; |
| 267 | |
| 268 | #[tokio::test] |
| 269 | async fn test_file_search_basic() { |
| 270 | let tmp = tempdir().expect("tempdir"); |
| 271 | let root = tmp.path(); |
| 272 | std::fs::create_dir_all(root.join("src")).expect("mkdir"); |
| 273 | std::fs::write(root.join("src").join("main.rs"), "fn main() {}\n").expect("write"); |
| 274 | std::fs::write(root.join("README.md"), "docs\n").expect("write"); |
| 275 | |
| 276 | let ctx = ToolContext::new(root.to_path_buf()); |
| 277 | let tool = FileSearchTool; |
| 278 | let result = tool |
| 279 | .execute(json!({"query": "main", "limit": 5}), &ctx) |
| 280 | .await |
| 281 | .expect("execute"); |
| 282 | |
| 283 | assert!(result.success); |
| 284 | assert!(result.content.contains("main.rs")); |
| 285 | } |
| 286 | |
| 287 | #[tokio::test] |
| 288 | async fn test_file_search_respects_gitignore() { |
| 289 | let tmp = tempdir().expect("tempdir"); |
| 290 | let root = tmp.path(); |
| 291 | std::fs::write(root.join(".gitignore"), "ignored.txt\n").expect("write"); |
| 292 | std::fs::write(root.join("ignored.txt"), "nope\n").expect("write"); |
| 293 | std::fs::write(root.join("keep.txt"), "ok\n").expect("write"); |
| 294 | |
| 295 | let ctx = ToolContext::new(root.to_path_buf()); |
| 296 | let tool = FileSearchTool; |
| 297 | let result = tool |
| 298 | .execute(json!({"query": "txt"}), &ctx) |
| 299 | .await |
| 300 | .expect("execute"); |
| 301 | |
| 302 | assert!(result.success); |
| 303 | assert!(!result.content.contains("ignored.txt")); |
| 304 | assert!(result.content.contains("keep.txt")); |
| 305 | } |
| 306 | |
| 307 | #[tokio::test] |
| 308 | async fn test_file_search_extension_filter() { |
| 309 | let tmp = tempdir().expect("tempdir"); |
| 310 | let root = tmp.path(); |
| 311 | std::fs::write(root.join("main.rs"), "fn main() {}\n").expect("write"); |
| 312 | std::fs::write(root.join("notes.md"), "docs\n").expect("write"); |
| 313 | |
| 314 | let ctx = ToolContext::new(root.to_path_buf()); |
| 315 | let tool = FileSearchTool; |
| 316 | let result = tool |
| 317 | .execute(json!({"query": "m", "extensions": ["rs"]}), &ctx) |
| 318 | .await |
| 319 | .expect("execute"); |
| 320 | |
| 321 | assert!(result.success); |
| 322 | assert!(result.content.contains("main.rs")); |
| 323 | assert!(!result.content.contains("notes.md")); |
| 324 | } |
| 325 | } |
| 326 |