1use crate::types::*;
23use async_trait::async_trait;
24use std::time::Duration;
25use tokio::process::Command;
26
27pub struct SearchTool {
29 pub root: Option<String>,
31 pub max_results: usize,
33 pub timeout: Duration,
35}
36
37impl Default for SearchTool {
38 fn default() -> Self {
39 Self {
40 root: None,
41 max_results: 50,
42 timeout: Duration::from_secs(30),
43 }
44 }
45}
46
47impl SearchTool {
48 pub fn new() -> Self {
49 Self::default()
50 }
51
52 pub fn with_root(mut self, root: impl Into<String>) -> Self {
53 self.root = Some(root.into());
54 self
55 }
56}
57
58#[async_trait]
59impl AgentTool for SearchTool {
60 fn name(&self) -> &str {
61 "search"
62 }
63
64 fn label(&self) -> &str {
65 "Search Files"
66 }
67
68 fn description(&self) -> &str {
69 "Search for a pattern across files using grep. Returns matching lines with file paths and line numbers. Supports regex patterns."
70 }
71
72 fn parameters_schema(&self) -> serde_json::Value {
73 serde_json::json!({
74 "type": "object",
75 "properties": {
76 "pattern": {
77 "type": "string",
78 "description": "Search pattern (regex supported)"
79 },
80 "path": {
81 "type": "string",
82 "description": "Directory or file to search in (optional, defaults to working directory)"
83 },
84 "include": {
85 "type": "string",
86 "description": "File glob pattern to include, e.g. '*.rs' (optional)"
87 },
88 "case_sensitive": {
89 "type": "boolean",
90 "description": "Case sensitive search (default: false)"
91 }
92 },
93 "required": ["pattern"]
94 })
95 }
96
97 async fn execute(
98 &self,
99 params: serde_json::Value,
100 ctx: ToolContext,
101 ) -> Result<ToolResult, ToolError> {
102 let cancel = ctx.cancel;
103 let pattern = params["pattern"]
104 .as_str()
105 .ok_or_else(|| ToolError::InvalidArgs("missing 'pattern' parameter".into()))?;
106
107 let search_path = params["path"]
108 .as_str()
109 .map(|s| s.to_string())
110 .or_else(|| self.root.clone())
111 .unwrap_or_else(|| ".".into());
112
113 let include = params["include"].as_str();
114 let case_sensitive = params["case_sensitive"].as_bool().unwrap_or(false);
115
116 if cancel.is_cancelled() {
117 return Err(ToolError::Cancelled);
118 }
119
120 let (cmd_name, args) = if which_exists("rg") {
122 build_rg_args(
123 pattern,
124 &search_path,
125 include,
126 case_sensitive,
127 self.max_results,
128 )
129 } else {
130 build_grep_args(
131 pattern,
132 &search_path,
133 include,
134 case_sensitive,
135 self.max_results,
136 )
137 };
138
139 let mut cmd = Command::new(&cmd_name);
140 cmd.args(&args);
141 cmd.stdout(std::process::Stdio::piped());
142 cmd.stderr(std::process::Stdio::piped());
143
144 let timeout = self.timeout;
145
146 let result = tokio::select! {
147 _ = cancel.cancelled() => {
148 return Err(ToolError::Cancelled);
149 }
150 _ = tokio::time::sleep(timeout) => {
151 return Err(ToolError::Failed("Search timed out".into()));
152 }
153 result = cmd.output() => {
154 result.map_err(|e| ToolError::Failed(format!("Search failed: {}", e)))?
155 }
156 };
157
158 let stdout = String::from_utf8_lossy(&result.stdout).to_string();
159 let stderr = String::from_utf8_lossy(&result.stderr).to_string();
160
161 if result.status.code() == Some(2)
163 || (!stderr.is_empty() && result.status.code() != Some(1))
164 {
165 return Err(ToolError::Failed(format!("Search error: {}", stderr)));
166 }
167
168 if stdout.trim().is_empty() {
169 return Ok(ToolResult {
170 content: vec![Content::Text {
171 text: format!("No matches found for '{}'", pattern),
172 }],
173 details: serde_json::json!({ "matches": 0 }),
174 child_loop_id: None,
175 });
176 }
177
178 let match_count = stdout.lines().count();
179 let text = if match_count >= self.max_results {
180 format!(
181 "{}\n... (showing first {} matches)",
182 stdout.trim(),
183 self.max_results
184 )
185 } else {
186 format!("{}\n({} matches)", stdout.trim(), match_count)
187 };
188
189 Ok(ToolResult {
190 content: vec![Content::Text { text }],
191 details: serde_json::json!({ "matches": match_count }),
192 child_loop_id: None,
193 })
194 }
195}
196
197fn which_exists(name: &str) -> bool {
198 std::process::Command::new("which")
199 .arg(name)
200 .output()
201 .map(|o| o.status.success())
202 .unwrap_or(false)
203}
204
205fn build_rg_args(
206 pattern: &str, path: &str, include: Option<&str>, case_sensitive: bool, max_results: usize, ) -> (String, Vec<String>) {
212 let mut args = vec![
214 "--line-number".into(),
215 "--no-heading".into(),
216 format!("--max-count={}", max_results),
217 ];
218
219 if !case_sensitive {
220 args.push("--ignore-case".into());
221 }
222
223 if let Some(glob) = include {
224 args.push(format!("--glob={}", glob));
225 }
226
227 args.push(pattern.into());
228 args.push(path.into());
229
230 ("rg".into(), args)
231}
232
233fn build_grep_args(
234 pattern: &str, path: &str, include: Option<&str>, case_sensitive: bool, max_results: usize, ) -> (String, Vec<String>) {
240 let mut args = vec!["-r".into(), "-n".into(), format!("-m{}", max_results)];
242
243 if !case_sensitive {
244 args.push("-i".into());
245 }
246
247 if let Some(glob) = include {
248 args.push(format!("--include={}", glob));
249 }
250
251 args.push(pattern.into());
252 args.push(path.into());
253
254 ("grep".into(), args)
255}