1use super::traits::Tool;
7use crate::config::constants::tools;
8use crate::simple_indexer::SimpleIndexer;
9use anyhow::{Context, Result};
10use async_trait::async_trait;
11use serde_json::{Value, json};
12use std::{path::PathBuf, process::Stdio, time::Duration};
13use tokio::{process::Command, time::timeout};
14
15#[derive(Clone)]
17pub struct SimpleSearchTool {
18 indexer: SimpleIndexer,
19}
20
21impl SimpleSearchTool {
22 pub fn new(workspace_root: PathBuf) -> Self {
24 let indexer = SimpleIndexer::new(workspace_root.clone());
25 indexer.init().unwrap_or_else(|e| {
26 eprintln!("Warning: Failed to initialize indexer: {}", e);
27 });
28
29 Self { indexer }
30 }
31
32 async fn execute_pty_command(
34 &self,
35 command: &str,
36 args: Vec<String>,
37 timeout_secs: Option<u64>,
38 ) -> Result<String> {
39 let full_command_parts = std::iter::once(command.to_string())
40 .chain(args.clone())
41 .collect::<Vec<String>>();
42 self.validate_command(&full_command_parts)?;
43
44 let full_command = if args.is_empty() {
45 command.to_string()
46 } else {
47 format!("{} {}", command, args.join(" "))
48 };
49
50 let work_dir = self.indexer.workspace_root().to_path_buf();
51 let mut cmd = Command::new(command);
52 if !args.is_empty() {
53 cmd.args(&args);
54 }
55 cmd.current_dir(&work_dir);
56 cmd.stdout(Stdio::piped());
57 cmd.stderr(Stdio::piped());
58
59 let duration = Duration::from_secs(timeout_secs.unwrap_or(30));
60 let output = timeout(duration, cmd.output())
61 .await
62 .with_context(|| {
63 format!(
64 "command '{}' timed out after {}s",
65 full_command,
66 duration.as_secs()
67 )
68 })?
69 .with_context(|| format!("Failed to execute command: {}", full_command))?;
70
71 Ok(String::from_utf8_lossy(&output.stdout).to_string())
72 }
73
74 fn validate_command(&self, command_parts: &[String]) -> Result<()> {
76 if command_parts.is_empty() {
77 return Err(anyhow::anyhow!("Command cannot be empty"));
78 }
79
80 let program = &command_parts[0];
81
82 let allowed_commands = [
84 "grep", "find", "ls", "cat", "head", "tail", "wc", "sort", "uniq", "cut", "tr", "fold",
85 ];
86
87 if !allowed_commands.contains(&program.as_str()) {
88 return Err(anyhow::anyhow!(
89 "Command '{}' is not allowed in SimpleSearchTool. \
90 Only safe read-only commands are permitted: {}",
91 program,
92 allowed_commands.join(", ")
93 ));
94 }
95
96 let full_command = command_parts.join(" ");
98
99 let sensitive_paths = [
101 "/etc/", "/usr/", "/var/", "/root/", "/boot/", "/sys/", "/proc/", "/home/",
102 ];
103 for path in &sensitive_paths {
104 if full_command.contains(path) {
105 return Err(anyhow::anyhow!(
106 "Access to system directory '{}' is not allowed. \
107 Work within your project workspace only.",
108 path.trim_end_matches('/')
109 ));
110 }
111 }
112
113 if program == "grep" || program == "find" {
115 if full_command.contains(" -exec")
116 || full_command.contains(" -delete")
117 || full_command.contains(" -execdir")
118 {
119 return Err(anyhow::anyhow!(
120 "Dangerous execution patterns in {} command are not allowed.",
121 program
122 ));
123 }
124 }
125
126 Ok(())
127 }
128
129 async fn grep(&self, args: Value) -> Result<Value> {
131 let pattern = args
132 .get("pattern")
133 .and_then(|v| v.as_str())
134 .context("pattern is required for grep")?;
135
136 let file_pattern = args.get("file_pattern").and_then(|v| v.as_str());
137 let max_results = args
138 .get("max_results")
139 .and_then(|v| v.as_u64())
140 .unwrap_or(50) as usize;
141
142 let mut cmd_args = vec![pattern.to_string()];
144 if let Some(file_pat) = file_pattern {
145 cmd_args.push("--include".to_string());
146 cmd_args.push(format!("*{}*", file_pat));
147 }
148 cmd_args.push("-r".to_string()); cmd_args.push("-n".to_string()); cmd_args.push(".".to_string()); let output = self
153 .execute_pty_command("grep", cmd_args, Some(30))
154 .await
155 .context("Failed to execute grep")?;
156
157 let lines: Vec<&str> = output.lines().collect();
159 let limited_lines: Vec<&str> = lines.into_iter().take(max_results).collect();
160
161 Ok(json!({
162 "command": "grep",
163 "pattern": pattern,
164 "results": limited_lines,
165 "count": limited_lines.len(),
166 "mode": "pty",
167 "pty_enabled": true
168 }))
169 }
170
171 async fn find(&self, args: Value) -> Result<Value> {
173 let pattern = args
174 .get("pattern")
175 .and_then(|v| v.as_str())
176 .context("pattern is required for find")?;
177
178 let cmd_args = vec![
180 ".".to_string(),
181 "-name".to_string(),
182 format!("*{}*", pattern),
183 "-type".to_string(),
184 "f".to_string(),
185 ];
186
187 let output = self
188 .execute_pty_command("find", cmd_args, Some(30))
189 .await
190 .context("Failed to execute find")?;
191
192 let files: Vec<&str> = output.lines().collect();
193
194 Ok(json!({
195 "command": "find",
196 "pattern": pattern,
197 "files": files,
198 "count": files.len(),
199 "mode": "pty",
200 "pty_enabled": true
201 }))
202 }
203
204 async fn ls(&self, args: Value) -> Result<Value> {
206 let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
207
208 let show_hidden = args
209 .get("show_hidden")
210 .and_then(|v| v.as_bool())
211 .unwrap_or(false);
212
213 let mut cmd_args = vec![];
215 if show_hidden {
216 cmd_args.push("-la".to_string());
217 } else {
218 cmd_args.push("-l".to_string());
219 }
220 cmd_args.push(path.to_string());
221
222 let output = self
223 .execute_pty_command("ls", cmd_args, Some(10))
224 .await
225 .context("Failed to execute ls")?;
226
227 let files: Vec<&str> = output.lines().collect();
228
229 Ok(json!({
230 "command": "ls",
231 "path": path,
232 "files": files,
233 "count": files.len(),
234 "show_hidden": show_hidden,
235 "mode": "pty",
236 "pty_enabled": true
237 }))
238 }
239
240 async fn cat(&self, args: Value) -> Result<Value> {
242 let file_path = args
243 .get("file_path")
244 .and_then(|v| v.as_str())
245 .context("file_path is required for cat")?;
246
247 let start_line = args
248 .get("start_line")
249 .and_then(|v| v.as_u64())
250 .map(|v| v as usize);
251 let end_line = args
252 .get("end_line")
253 .and_then(|v| v.as_u64())
254 .map(|v| v as usize);
255
256 let mut cmd_args = vec![];
257 if let (Some(start), Some(end)) = (start_line, end_line) {
258 let sed_cmd = format!("sed -n '{}','{}'p {}", start, end, file_path);
260 cmd_args = vec!["-c".to_string(), sed_cmd];
261 let output = self
262 .execute_pty_command("sh", cmd_args, Some(10))
263 .await
264 .context("Failed to execute sed")?;
265 return Ok(json!({
266 "command": "cat",
267 "file_path": file_path,
268 "content": output,
269 "start_line": start,
270 "end_line": end,
271 "mode": "pty",
272 "pty_enabled": true
273 }));
274 }
275
276 cmd_args.push(file_path.to_string());
277 let output = self
278 .execute_pty_command("cat", cmd_args, Some(10))
279 .await
280 .context("Failed to execute cat")?;
281
282 Ok(json!({
283 "command": "cat",
284 "file_path": file_path,
285 "content": output,
286 "start_line": start_line,
287 "end_line": end_line,
288 "mode": "pty",
289 "pty_enabled": true
290 }))
291 }
292
293 async fn head(&self, args: Value) -> Result<Value> {
295 let file_path = args
296 .get("file_path")
297 .and_then(|v| v.as_str())
298 .context("file_path is required for head")?;
299
300 let lines = args.get("lines").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
301
302 let cmd_args = vec!["-n".to_string(), lines.to_string(), file_path.to_string()];
303
304 let output = self
305 .execute_pty_command("head", cmd_args, Some(10))
306 .await
307 .context("Failed to execute head")?;
308
309 Ok(json!({
310 "command": "head",
311 "file_path": file_path,
312 "content": output,
313 "lines": lines,
314 "mode": "pty",
315 "pty_enabled": true
316 }))
317 }
318
319 async fn tail(&self, args: Value) -> Result<Value> {
321 let file_path = args
322 .get("file_path")
323 .and_then(|v| v.as_str())
324 .context("file_path is required for tail")?;
325
326 let lines = args.get("lines").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
327
328 let cmd_args = vec!["-n".to_string(), lines.to_string(), file_path.to_string()];
329
330 let output = self
331 .execute_pty_command("tail", cmd_args, Some(10))
332 .await
333 .context("Failed to execute tail")?;
334
335 Ok(json!({
336 "command": "tail",
337 "file_path": file_path,
338 "content": output,
339 "lines": lines,
340 "mode": "pty",
341 "pty_enabled": true
342 }))
343 }
344}
345
346#[async_trait]
347impl Tool for SimpleSearchTool {
348 async fn execute(&self, args: Value) -> Result<Value> {
349 let command = args
350 .get("command")
351 .and_then(|v| v.as_str())
352 .unwrap_or("grep");
353
354 match command {
355 "grep" => self.grep(args).await,
356 "find" => self.find(args).await,
357 "ls" => self.ls(args).await,
358 "cat" => self.cat(args).await,
359 "head" => self.head(args).await,
360 "tail" => self.tail(args).await,
361 _ => Err(anyhow::anyhow!("Unknown command: {}", command)),
362 }
363 }
364
365 fn name(&self) -> &'static str {
366 tools::SIMPLE_SEARCH
367 }
368
369 fn description(&self) -> &'static str {
370 "Simple bash-like search and file operations with security validation: grep, find, ls, cat, head, tail, index. \
371 Only safe read-only operations are allowed - no file modifications or dangerous commands."
372 }
373}