agent_engine/tools/
find.rs1use super::util::path_arg_never_option;
2use super::{resolve_path_in, Tool, ToolContext};
3use crate::{Result, RuntimeError};
4use serde_json::{json, Value};
5use std::time::Duration;
6use tokio::process::Command;
7
8pub struct FindTool;
9
10#[async_trait::async_trait]
11impl Tool for FindTool {
12 fn effect(&self) -> crate::tools::catalog::ToolEffect {
13 crate::tools::catalog::ToolEffect::ReadOnly
14 }
15
16 fn origin(&self) -> crate::tools::ToolOrigin {
17 crate::tools::ToolOrigin::Builtin
18 }
19
20 fn name(&self) -> &str {
21 "find"
22 }
23
24 fn description(&self) -> &str {
25 "Find files by name using glob patterns. Searches recursively from the given path. Excludes .git directories."
26 }
27
28 fn parameters(&self) -> Value {
29 json!({
30 "type": "object",
31 "properties": {
32 "pattern": {
33 "type": "string",
34 "description": "Glob pattern to match file names (e.g. \"*.rs\", \"Cargo.*\")"
35 },
36 "path": {
37 "type": "string",
38 "description": "Directory to search in (default: current directory)"
39 },
40 "type": {
41 "type": "string",
42 "description": "Filter by type: \"f\" for files, \"d\" for directories, \"l\" for symlinks"
43 }
44 },
45 "required": ["pattern"]
46 })
47 }
48
49 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
50 let mut cmd = build_command(¶ms, &ctx)?;
51 let output = tokio::time::timeout(Duration::from_secs(10), cmd.output())
52 .await
53 .map_err(|_| RuntimeError::Tool("Find timed out after 10s".to_string()))?
54 .map_err(|e| RuntimeError::Tool(format!("Failed to execute find: {}", e)))?;
55
56 let stdout = String::from_utf8_lossy(&output.stdout);
57
58 if stdout.is_empty() {
59 Ok("No files found.".to_string())
60 } else {
61 Ok(stdout.trim().to_string())
62 }
63 }
64}
65
66fn build_command(params: &Value, ctx: &ToolContext) -> Result<Command> {
69 let pattern = params["pattern"]
70 .as_str()
71 .ok_or_else(|| RuntimeError::Tool("Missing pattern parameter".to_string()))?;
72 let path = path_arg_never_option(resolve_path_in(
73 params["path"].as_str().unwrap_or("."),
74 ctx.capabilities.cwd.as_deref(),
75 ));
76 let file_type = params["type"].as_str();
77 if let Some(t) = file_type {
78 if !matches!(t, "f" | "d" | "l") {
79 return Err(RuntimeError::Tool(format!(
80 "Invalid type {t:?}: use \"f\", \"d\" or \"l\""
81 )));
82 }
83 }
84
85 let mut cmd = Command::new("find");
86 if let Some(env) = &ctx.capabilities.env {
87 cmd.env_clear().envs(env.iter().map(|(k, v)| (k, v)));
88 }
89 cmd.arg(&path);
90
91 cmd.args(["-not", "-path", "*/.git/*"]);
92 cmd.args(["-not", "-path", "*/node_modules/*"]);
93 cmd.args(["-not", "-path", "*/target/*"]);
94
95 if let Some(t) = file_type {
96 cmd.arg("-type").arg(t);
97 }
98
99 cmd.arg("-name").arg(pattern);
100
101 Ok(cmd)
102}
103
104#[cfg(test)]
105mod tests {
106 use super::super::test_helpers::create_tool_context;
107 use super::*;
108 use crate::tools::Tool;
109 use serde_json::json;
110
111 #[test]
112 fn test_find_tool_schema() {
113 let tool = FindTool;
114 assert_eq!(tool.name(), "find");
115 assert!(!tool.description().is_empty());
116
117 let params = tool.parameters();
118 assert_eq!(params["type"], "object");
119 assert!(params["properties"].is_object());
120 assert!(params["required"].is_array());
121 }
122
123 #[tokio::test]
124 async fn test_find_tool_execution() {
125 let temp_dir = std::env::temp_dir().join("test_find_tool_execution");
126 std::fs::create_dir_all(&temp_dir).unwrap();
127
128 let test_file = temp_dir.join("test_find_me.txt");
129 std::fs::write(&test_file, "test content").unwrap();
130
131 let tool = FindTool;
132 let ctx = create_tool_context();
133
134 let params = json!({
135 "pattern": "test_find_me*",
136 "path": temp_dir.to_string_lossy()
137 });
138
139 let result = tool.execute(params, ctx).await.unwrap();
140
141 assert!(result.contains("test_find_me.txt"));
143
144 let _ = std::fs::remove_dir_all(&temp_dir);
146 }
147
148 fn first_arg(cmd: &Command) -> String {
149 cmd.as_std()
150 .get_args()
151 .next()
152 .unwrap()
153 .to_string_lossy()
154 .into_owned()
155 }
156
157 #[test]
160 fn leading_dash_path_is_never_an_expression() {
161 let ctx = create_tool_context();
162 assert!(ctx.capabilities.cwd.is_none());
163 for p in ["-delete", "-exec", "--help", "-fprint"] {
164 let cmd = build_command(&json!({"pattern": "*", "path": p}), &ctx).unwrap();
165 let first = first_arg(&cmd);
166 assert!(!first.starts_with('-'), "{p} became argv[1] {first:?}");
167 assert_eq!(first, format!("./{p}"));
168 }
169 let cmd = build_command(&json!({"pattern": "*", "path": "src"}), &ctx).unwrap();
171 assert_eq!(first_arg(&cmd), "src");
172 }
173
174 #[test]
178 fn start_point_terminators_are_never_expressions() {
179 let ctx = create_tool_context();
180 for p in ["!", "(", ")", ",", "!foo", "(x"] {
181 let cmd = build_command(&json!({"pattern": "*", "path": p}), &ctx).unwrap();
182 assert_eq!(first_arg(&cmd), format!("./{p}"), "path {p:?}");
183 }
184 }
185
186 #[tokio::test]
188 async fn path_bang_does_not_expose_git_dir() {
189 let dir = tempfile::tempdir().unwrap();
190 std::fs::create_dir_all(dir.path().join(".git")).unwrap();
191 std::fs::write(dir.path().join(".git/secret_obj"), "").unwrap();
192 let ctx = create_tool_context();
193 let mut cmd = build_command(&json!({"pattern": "*", "path": "!"}), &ctx).unwrap();
194 cmd.current_dir(dir.path());
195 let out = cmd.output().await.unwrap();
196 let stdout = String::from_utf8_lossy(&out.stdout);
197 assert!(!stdout.contains("secret_obj"), "{stdout}");
198 }
199
200 #[tokio::test]
203 async fn path_dash_delete_deletes_nothing() {
204 let dir = tempfile::tempdir().unwrap();
205 let victim = dir.path().join("victim.txt");
206 std::fs::write(&victim, "keep me").unwrap();
207 let ctx = create_tool_context();
208 let mut cmd = build_command(&json!({"pattern": "*", "path": "-delete"}), &ctx).unwrap();
209 cmd.current_dir(dir.path());
210 let _ = cmd.output().await.unwrap();
211 assert!(victim.exists(), "find path:-delete removed files");
212 }
213
214 #[test]
215 fn type_is_restricted_to_f_d_l() {
216 let ctx = create_tool_context();
217 for t in ["f", "d", "l"] {
218 assert!(build_command(&json!({"pattern": "*", "type": t}), &ctx).is_ok());
219 }
220 for t in ["-delete", "x", "fd", ""] {
221 assert!(
222 build_command(&json!({"pattern": "*", "type": t}), &ctx).is_err(),
223 "type {t:?} must be rejected"
224 );
225 }
226 }
227}