1use super::search::{self, files, pattern};
6use super::{resolve_path_in, Tool, ToolContext};
7use crate::{Result, RuntimeError};
8use serde_json::{json, Value};
9use std::time::Duration;
10
11const TIMEOUT: Duration = Duration::from_secs(10);
13
14pub struct FindTool;
15
16#[async_trait::async_trait]
17impl Tool for FindTool {
18 fn effect(&self) -> crate::tools::catalog::ToolEffect {
19 crate::tools::catalog::ToolEffect::ReadOnly
20 }
21
22 fn origin(&self) -> crate::tools::ToolOrigin {
23 crate::tools::ToolOrigin::Builtin
24 }
25
26 fn name(&self) -> &str {
27 "find"
28 }
29
30 fn description(&self) -> &str {
31 "Find files by name: a glob matched against file names, searched recursively from the given path. Respects .gitignore (use no_ignore to include ignored files, node_modules and target); hidden files are listed, VCS dirs (.git) never."
32 }
33
34 fn parameters(&self) -> Value {
35 json!({
36 "type": "object",
37 "properties": {
38 "pattern": {
39 "type": "string",
40 "description": "Glob pattern to match file names (e.g. \"*.rs\", \"Cargo.*\")"
41 },
42 "path": {
43 "type": "string",
44 "description": "Directory to search in (default: current directory)"
45 },
46 "type": {
47 "type": "string",
48 "description": "Filter by type: \"f\" for files, \"d\" for directories, \"l\" for symlinks"
49 },
50 "no_ignore": {
51 "type": "boolean",
52 "description": "Also list files skipped by .gitignore/.ignore, node_modules and target (default false)"
53 }
54 },
55 "required": ["pattern"]
56 })
57 }
58
59 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
60 let request = request(¶ms, &ctx)?;
61 run_search(request, ctx.capabilities.launch_cancel.clone(), TIMEOUT).await
62 }
63}
64
65fn request(params: &Value, ctx: &ToolContext) -> Result<files::FilesRequest> {
67 let glob = params["pattern"]
68 .as_str()
69 .ok_or_else(|| RuntimeError::Tool("Missing pattern parameter".to_string()))?;
70 let root = resolve_path_in(
71 params["path"].as_str().unwrap_or("."),
72 ctx.capabilities.cwd.as_deref(),
73 );
74 let entry_type = params["type"]
75 .as_str()
76 .map(files::EntryType::parse)
77 .transpose()
78 .map_err(RuntimeError::Tool)?;
79 let name = pattern::name_glob(glob).map_err(RuntimeError::Tool)?;
80 Ok(files::FilesRequest {
81 root,
82 name,
83 entry_type,
84 no_ignore: params["no_ignore"].as_bool().unwrap_or(false),
85 cwd: ctx.capabilities.cwd.clone(),
86 cap: ctx.limits.max_tool_buffer,
87 })
88}
89
90async fn run_search(
93 request: files::FilesRequest,
94 turn: Option<crate::CancellationToken>,
95 timeout: Duration,
96) -> Result<String> {
97 let cap = request.cap;
98 let outcome = search::run_blocking(timeout, turn, move |control| {
99 files::search(&request, control)
100 })
101 .await?
102 .map_err(RuntimeError::Tool)?;
103 files::render(outcome, cap, timeout)
104}
105
106#[cfg(test)]
107mod tests {
108 use super::super::test_helpers::create_tool_context;
109 use super::*;
110 use crate::tools::Tool;
111 use serde_json::json;
112 use std::path::{Path, PathBuf};
113
114 fn native(root: &Path, rel: &str) -> PathBuf {
116 rel.split('/').fold(root.to_path_buf(), |p, c| p.join(c))
117 }
118
119 fn tree(paths: &[&str]) -> tempfile::TempDir {
120 let dir = tempfile::tempdir().unwrap();
121 for p in paths {
122 let p = native(dir.path(), p);
123 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
124 std::fs::write(p, "").unwrap();
125 }
126 dir
127 }
128
129 async fn find_in(dir: &Path, params: Value) -> Result<String> {
131 let mut ctx = create_tool_context();
132 ctx.capabilities.cwd = Some(dir.to_path_buf());
133 FindTool.execute(params, ctx).await
134 }
135
136 fn tool_msg(e: RuntimeError) -> String {
138 match e {
139 RuntimeError::Tool(msg) => msg,
140 other => other.to_string(),
141 }
142 }
143
144 fn rel(out: &str, dir: &Path) -> Vec<String> {
147 let dir = dir.display().to_string();
148 out.lines()
149 .map(|line| {
150 let rest = line.strip_prefix(dir.as_str()).unwrap_or(line);
151 let rest = rest.replace('\\', "/");
152 let rest = rest.trim_start_matches('/');
153 let rest = rest.strip_prefix("./").unwrap_or(rest);
154 if rest.is_empty() { "." } else { rest }.to_string()
155 })
156 .collect()
157 }
158
159 #[test]
160 fn test_find_tool_schema() {
161 let tool = FindTool;
162 assert_eq!(tool.name(), "find");
163 assert!(tool.description().contains(".gitignore"));
164 assert!(tool.description().contains("no_ignore"));
165
166 let params = tool.parameters();
167 assert_eq!(params["type"], "object");
168 assert_eq!(params["required"], json!(["pattern"]));
169 for (name, ty) in [
170 ("pattern", "string"),
171 ("path", "string"),
172 ("type", "string"),
173 ("no_ignore", "boolean"),
174 ] {
175 assert_eq!(params["properties"][name]["type"], ty, "{name}");
176 }
177 }
178
179 #[tokio::test]
180 async fn test_find_tool_execution() {
181 let dir = tree(&["test_find_me.txt", "other.txt"]);
182 let params = json!({
183 "pattern": "test_find_me*",
184 "path": dir.path().to_string_lossy()
185 });
186 let result = FindTool
187 .execute(params, create_tool_context())
188 .await
189 .unwrap();
190 assert_eq!(
191 result,
192 dir.path().join("test_find_me.txt").display().to_string()
193 );
194 }
195
196 #[tokio::test]
200 async fn default_path_is_the_session_cwd() {
201 let dir = tree(&["src/a.rs", "b.rs"]);
202 let out = find_in(dir.path(), json!({"pattern": "*.rs"}))
203 .await
204 .unwrap();
205 let dot = dir.path().join(".");
206 assert_eq!(
207 out,
208 format!(
209 "{}\n{}",
210 dot.join("b.rs").display(),
211 native(&dot, "src/a.rs").display()
212 )
213 );
214 let same = find_in(dir.path(), json!({"pattern": "*.rs", "path": "."}))
216 .await
217 .unwrap();
218 assert_eq!(same, out);
219 }
220
221 #[tokio::test]
224 async fn a_file_path_lists_the_file() {
225 let dir = tree(&["src/a.rs"]);
226 let out = find_in(dir.path(), json!({"pattern": "*.rs", "path": "src/a.rs"}))
227 .await
228 .unwrap();
229 assert_eq!(out, dir.path().join("src/a.rs").display().to_string());
230 }
231
232 #[tokio::test]
233 async fn no_match_says_no_files_found() {
234 let dir = tree(&["src/a.rs"]);
235 let out = find_in(dir.path(), json!({"pattern": "*.py"}))
236 .await
237 .unwrap();
238 assert_eq!(out, format!("No files found.\n\n{}", search::IGNORED_NOTE));
239 let out = find_in(dir.path(), json!({"pattern": "*.py", "no_ignore": true}))
240 .await
241 .unwrap();
242 assert_eq!(out, "No files found.");
243 }
244
245 #[tokio::test]
248 async fn an_ignored_file_is_missed_with_a_note_and_no_ignore_lists_it() {
249 let dir = tree(&[".env"]);
250 std::fs::write(dir.path().join(".ignore"), ".env\n").unwrap();
251 let missed = find_in(dir.path(), json!({"pattern": ".env"}))
252 .await
253 .unwrap();
254 assert!(missed.starts_with("No files found."), "{missed}");
255 assert!(missed.contains("no_ignore: true"), "{missed}");
256 let found = find_in(dir.path(), json!({"pattern": ".env", "no_ignore": true}))
257 .await
258 .unwrap();
259 assert_eq!(
260 found,
261 dir.path().join(".").join(".env").display().to_string()
262 );
263 }
264
265 #[tokio::test]
267 async fn missing_path_is_an_error() {
268 let dir = tempfile::tempdir().unwrap();
269 let err = find_in(dir.path(), json!({"pattern": "*", "path": "nope"}))
270 .await
271 .map_err(tool_msg)
272 .unwrap_err();
273 assert_eq!(
274 err,
275 format!("Path not found: {}", dir.path().join("nope").display())
276 );
277 }
278
279 #[tokio::test]
282 async fn bad_patterns_are_errors() {
283 let dir = tree(&["src/a.rs"]);
284 let err = find_in(dir.path(), json!({"pattern": "src/*.rs"}))
285 .await
286 .map_err(tool_msg)
287 .unwrap_err();
288 assert!(err.starts_with("invalid pattern \"src/*.rs\""), "{err}");
289 assert!(err.contains("`path`"), "{err}");
290 let err = find_in(dir.path(), json!({"pattern": "[a"}))
291 .await
292 .map_err(tool_msg)
293 .unwrap_err();
294 assert!(err.starts_with("invalid pattern: "), "{err}");
295 assert!(err.contains("unclosed character class"), "{err}");
296 let err = find_in(dir.path(), json!({}))
297 .await
298 .map_err(tool_msg)
299 .unwrap_err();
300 assert_eq!(err, "Missing pattern parameter");
301 }
302
303 #[tokio::test]
304 async fn type_and_no_ignore_reach_the_engine() {
305 let dir = tree(&[".git/HEAD", "node_modules/m/x.rs", "src/a.rs", ".hidden.rs"]);
306 std::fs::write(dir.path().join(".gitignore"), "*.log\n").unwrap();
307 std::fs::write(dir.path().join("x.log"), "").unwrap();
308 let out = find_in(dir.path(), json!({"pattern": "*", "type": "d"}))
309 .await
310 .unwrap();
311 assert_eq!(rel(&out, dir.path()), [".", "src"]);
312 let out = find_in(dir.path(), json!({"pattern": "*", "type": "f"}))
313 .await
314 .unwrap();
315 assert_eq!(
316 rel(&out, dir.path()),
317 [".gitignore", ".hidden.rs", "src/a.rs"]
318 );
319 let out = find_in(
320 dir.path(),
321 json!({"pattern": "*", "type": "f", "no_ignore": true}),
322 )
323 .await
324 .unwrap();
325 assert_eq!(
326 rel(&out, dir.path()),
327 [
328 ".gitignore",
329 ".hidden.rs",
330 "node_modules/m/x.rs",
331 "src/a.rs",
332 "x.log"
333 ]
334 );
335 }
336
337 #[tokio::test]
338 async fn type_is_restricted_to_f_d_l() {
339 let dir = tree(&["a.rs"]);
340 for t in ["f", "d", "l"] {
341 assert!(
342 find_in(dir.path(), json!({"pattern": "*", "type": t}))
343 .await
344 .is_ok(),
345 "type {t:?}"
346 );
347 }
348 for t in ["-delete", "x", "fd", ""] {
349 let err = find_in(dir.path(), json!({"pattern": "*", "type": t}))
350 .await
351 .map_err(tool_msg)
352 .unwrap_err();
353 assert!(err.starts_with("Invalid type"), "type {t:?}: {err}");
354 }
355 }
356
357 #[tokio::test]
361 async fn path_bang_does_not_expose_git_dir() {
362 let dir = tree(&[".git/secret_obj", "a.txt"]);
363 let err = find_in(dir.path(), json!({"pattern": "*", "path": "!"}))
364 .await
365 .map_err(tool_msg)
366 .unwrap_err();
367 assert!(err.starts_with("Path not found: "), "{err}");
368 assert!(!err.contains("secret_obj"), "{err}");
369 for path in [".", "./"] {
370 let out = find_in(dir.path(), json!({"pattern": "*", "path": path}))
371 .await
372 .unwrap();
373 assert!(out.contains("a.txt"), "{out}");
374 assert!(
375 !out.contains("secret_obj") && !out.contains(".git"),
376 "{out}"
377 );
378 }
379 }
380
381 #[tokio::test]
385 async fn path_dash_delete_deletes_nothing() {
386 let dir = tree(&["victim.txt"]);
387 let victim = dir.path().join("victim.txt");
388 let err = find_in(dir.path(), json!({"pattern": "*", "path": "-delete"}))
389 .await
390 .map_err(tool_msg)
391 .unwrap_err();
392 assert!(err.starts_with("Path not found: "), "{err}");
393 assert!(victim.exists(), "find path:-delete removed files");
394 std::fs::create_dir(dir.path().join("-delete")).unwrap();
395 std::fs::write(dir.path().join("-delete").join("in.txt"), "").unwrap();
396 let out = find_in(dir.path(), json!({"pattern": "*.txt", "path": "-delete"}))
397 .await
398 .unwrap();
399 assert_eq!(rel(&out, &dir.path().join("-delete")), ["in.txt"]);
400 assert!(victim.exists(), "find path:-delete removed files");
401 }
402
403 #[tokio::test]
408 async fn patterns_reach_find_unexpanded_and_git_stays_excluded() {
409 let dir = tree(&["a.txt", "b.txt", "sub/c.txt", "sub/.git/d.txt"]);
410 let out = find_in(dir.path(), json!({"pattern": "*.txt", "path": "."}))
411 .await
412 .unwrap();
413 assert_eq!(rel(&out, dir.path()), ["a.txt", "b.txt", "sub/c.txt"]);
414 }
415
416 #[tokio::test]
420 async fn excluded_dirs_are_absent_and_no_ignore_lists_all_but_git() {
421 let dir = tree(&[
422 "src/a.rs",
423 "node_modules/pkg/index.rs",
424 ".git/objects/x.rs",
425 "target/debug/build.rs",
426 "wt/.git",
427 "wt/b.rs",
428 ]);
429 let out = find_in(dir.path(), json!({"pattern": "*"})).await.unwrap();
430 assert_eq!(
431 rel(&out, dir.path()),
432 [".", "src", "src/a.rs", "wt", "wt/b.rs"]
433 );
434 let out = find_in(dir.path(), json!({"pattern": "*", "no_ignore": true}))
435 .await
436 .unwrap();
437 let listed = rel(&out, dir.path());
438 for want in [
439 "node_modules/pkg/index.rs",
440 "target",
441 "target/debug/build.rs",
442 ] {
443 assert!(listed.iter().any(|l| l == want), "{want} missing: {out}");
444 }
445 assert!(!out.contains(".git"), "{out}");
446 }
447
448 #[tokio::test]
451 async fn a_search_under_a_target_dir_finds_files() {
452 let dir = tree(&["target/app/src/a.rs", "target/app/target/debug/b.rs"]);
453 let out = find_in(dir.path(), json!({"pattern": "*.rs", "path": "target/app"}))
454 .await
455 .unwrap();
456 assert_eq!(rel(&out, &dir.path().join("target/app")), ["src/a.rs"]);
457 }
458
459 #[cfg(unix)]
461 #[tokio::test]
462 async fn walk_errors_are_surfaced_with_the_results() {
463 use std::os::unix::fs::PermissionsExt;
464 let dir = tree(&["src/a.rs", "src/locked/b.rs"]);
465 let locked = native(dir.path(), "src/locked");
466 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
467 if std::fs::read_dir(&locked).is_ok() {
468 eprintln!("skipped: permissions are not enforced for this user");
469 return;
470 }
471 let out = find_in(dir.path(), json!({"pattern": "*.rs"})).await;
472 let _ = std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755));
473 let out = out.unwrap();
474 let a_rs = native(&dir.path().join("."), "src/a.rs")
475 .display()
476 .to_string();
477 let note = format!(
478 "{a_rs}\n\n[1 error while searching, results may be incomplete. First: {}: ",
479 native(&dir.path().join("."), "src/locked").display()
480 );
481 assert!(out.starts_with(¬e), "{out}");
482 assert!(out.ends_with("Permission denied (os error 13)]"), "{out}");
483 }
484
485 #[tokio::test]
488 async fn output_is_capped() {
489 let names: Vec<String> = (0..400).map(|i| format!("f{i:03}.txt")).collect();
490 let dir = tree(&names.iter().map(String::as_str).collect::<Vec<_>>());
491 let mut ctx = create_tool_context();
492 ctx.capabilities.cwd = Some(dir.path().to_path_buf());
493 ctx.limits.max_tool_buffer = 2048;
494 let out = FindTool
495 .execute(json!({"pattern": "*.txt"}), ctx)
496 .await
497 .unwrap();
498 let (body, footer) = out.split_once("\n\n").unwrap();
499 assert!(body.len() <= 2048 && body.ends_with(".txt"), "{body}");
500 assert!(body.lines().all(|l| l.ends_with(".txt")), "{body}");
501 assert_eq!(footer, "... (output truncated, more than 2048 total bytes)");
502 }
503
504 #[tokio::test]
506 async fn turn_cancellation_reaches_the_walk() {
507 let dir = tree(&["a.rs"]);
508 let turn = crate::CancellationToken::new();
509 turn.cancel();
510 let mut ctx = create_tool_context();
511 ctx.capabilities.cwd = Some(dir.path().to_path_buf());
512 ctx.capabilities.launch_cancel = Some(turn);
513 let err = FindTool
514 .execute(json!({"pattern": "*.rs"}), ctx)
515 .await
516 .map_err(tool_msg)
517 .unwrap_err();
518 assert_eq!(err, "Find was cancelled before finding a match.");
519 }
520
521 #[tokio::test]
524 async fn deadline_reaches_the_walk() {
525 let dir = tree(&["a.rs"]);
526 let mut ctx = create_tool_context();
527 ctx.capabilities.cwd = Some(dir.path().to_path_buf());
528 let request = request(&json!({"pattern": "*.rs"}), &ctx).unwrap();
529 let err = run_search(request, None, Duration::ZERO)
530 .await
531 .map_err(tool_msg)
532 .unwrap_err();
533 assert!(
534 err.starts_with("Find timed out after 0s before finding a match"),
535 "{err}"
536 );
537 }
538}