返回 DeepSeek-TUI-2026
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;
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
326 lines RUST