Skip to main content

agent_engine/tools/
find.rs

1//! The `find` tool: the in-process walk grep uses (`tools::search::files`),
2//! listing paths whose file name matches a glob, one per line, as `find PATH
3//! -name GLOB` printed them. Nothing is spawned.
4
5use 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
11/// How long one search may run before it returns what it has.
12const 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(&params, &ctx)?;
61        run_search(request, ctx.capabilities.launch_cancel.clone(), TIMEOUT).await
62    }
63}
64
65/// The call's parameters, resolved against the session.
66fn 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
90/// Runs the walk on the blocking pool and renders the result. `turn`
91/// cancelled (or this future dropped) stops it with partial results.
92async 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    /// `root` joined with a `/`-separated relative path (native separators).
115    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    /// Runs find with the session cwd at `dir`.
130    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    /// A tool error's own message (Display adds "Tool execution failed: ").
137    fn tool_msg(e: RuntimeError) -> String {
138        match e {
139            RuntimeError::Tool(msg) => msg,
140            other => other.to_string(),
141        }
142    }
143
144    /// The result's paths relative to `dir`, `/`-separated (`<cwd>/./x` is
145    /// `x`); the search path itself is `.`.
146    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    /// The default path is the session cwd joined with `.`, and paths keep
197    /// that form (`<cwd>/./src/a.rs`), as `find <cwd>/.` printed them; no
198    /// trailing newline.
199    #[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        // `.` as the path is the same.
215        let same = find_in(dir.path(), json!({"pattern": "*.rs", "path": "."}))
216            .await
217            .unwrap();
218        assert_eq!(same, out);
219    }
220
221    /// A file as the path lists that file when its name matches, as the
222    /// path was given (`src/a.rs` joined onto the cwd).
223    #[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    /// A file the ignore rules exclude (a `.env`, typically) isn't listed, the
246    /// miss says how to include it, and `no_ignore` lists it.
247    #[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    /// A missing path is an error, not "No files found.".
266    #[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    /// A pattern with a separator can't match a name: an error pointing at
280    /// `path`. An invalid glob is an error with the parser's message.
281    #[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    /// `path:"!"` (once a GNU find expression that negated the `.git`
358    /// exclusion) is just a path: missing here, so an error; `.git` contents
359    /// are never listed, whatever the path.
360    #[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    /// `path:"-delete"` (once a GNU find expression that emptied the cwd) is
382    /// just a path: nothing is deleted, and a directory of that name is
383    /// listed like any other.
384    #[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    /// Run from a folder whose own names match the pattern: the pattern is
404    /// matched in-process, never expanded by a shell or MSYS (Git for
405    /// Windows' find turned `-name *.txt` into file names), and `.git` stays
406    /// out, even in a nested repo.
407    #[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    /// Row 25: `.git`, `node_modules` and `target` are neither listed nor
417    /// walked; the worktree form of `.git` (a file) is never listed either.
418    /// `no_ignore` lists node_modules and target, never `.git`.
419    #[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    /// A search rooted under a `target` directory finds its files; a
449    /// `target` below the path is still pruned.
450    #[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    /// An unreadable directory is reported beside the results.
460    #[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(&note), "{out}");
482        assert!(out.ends_with("Permission denied (os error 13)]"), "{out}");
483    }
484
485    /// Output past `max_tool_buffer` stops with grep's truncation footer, on
486    /// a whole path.
487    #[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    /// A cancelled turn stops the walk: nothing found yet is an error.
505    #[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    /// The deadline reaches the blocking walk: with no time at all the call
522    /// is a timeout error, not "No files found.".
523    #[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}