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 && (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 Ok(())
126 }
127
128 async fn grep(&self, args: Value) -> Result<Value> {
130 let pattern = args
131 .get("pattern")
132 .and_then(|v| v.as_str())
133 .context("pattern is required for grep")?;
134
135 let file_pattern = args.get("file_pattern").and_then(|v| v.as_str());
136 let max_results = args
137 .get("max_results")
138 .and_then(|v| v.as_u64())
139 .unwrap_or(50) as usize;
140
141 let mut cmd_args = vec![pattern.to_string()];
143 if let Some(file_pat) = file_pattern {
144 cmd_args.push("--include".to_string());
145 cmd_args.push(format!("*{}*", file_pat));
146 }
147 cmd_args.push("-r".to_string()); cmd_args.push("-n".to_string()); cmd_args.push(".".to_string()); let output = self
152 .execute_pty_command("grep", cmd_args, Some(30))
153 .await
154 .context("Failed to execute grep")?;
155
156 let lines: Vec<&str> = output.lines().collect();
158 let limited_lines: Vec<&str> = lines.into_iter().take(max_results).collect();
159
160 Ok(json!({
161 "command": "grep",
162 "pattern": pattern,
163 "results": limited_lines,
164 "count": limited_lines.len(),
165 "mode": "pty",
166 "pty_enabled": true
167 }))
168 }
169
170 async fn find(&self, args: Value) -> Result<Value> {
172 let pattern = args
173 .get("pattern")
174 .and_then(|v| v.as_str())
175 .context("pattern is required for find")?;
176
177 let cmd_args = vec![
179 ".".to_string(),
180 "-name".to_string(),
181 format!("*{}*", pattern),
182 "-type".to_string(),
183 "f".to_string(),
184 ];
185
186 let output = self
187 .execute_pty_command("find", cmd_args, Some(30))
188 .await
189 .context("Failed to execute find")?;
190
191 let files: Vec<&str> = output.lines().collect();
192
193 Ok(json!({
194 "command": "find",
195 "pattern": pattern,
196 "files": files,
197 "count": files.len(),
198 "mode": "pty",
199 "pty_enabled": true
200 }))
201 }
202
203 async fn ls(&self, args: Value) -> Result<Value> {
205 let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
206
207 let show_hidden = args
208 .get("show_hidden")
209 .and_then(|v| v.as_bool())
210 .unwrap_or(false);
211
212 let mut cmd_args = vec![];
214 if show_hidden {
215 cmd_args.push("-la".to_string());
216 } else {
217 cmd_args.push("-l".to_string());
218 }
219 cmd_args.push(path.to_string());
220
221 let output = self
222 .execute_pty_command("ls", cmd_args, Some(10))
223 .await
224 .context("Failed to execute ls")?;
225
226 let files: Vec<&str> = output.lines().collect();
227
228 Ok(json!({
229 "command": "ls",
230 "path": path,
231 "files": files,
232 "count": files.len(),
233 "show_hidden": show_hidden,
234 "mode": "pty",
235 "pty_enabled": true
236 }))
237 }
238
239 async fn cat(&self, args: Value) -> Result<Value> {
241 let file_path = args
242 .get("file_path")
243 .and_then(|v| v.as_str())
244 .context("file_path is required for cat")?;
245
246 let start_line = args
247 .get("start_line")
248 .and_then(|v| v.as_u64())
249 .map(|v| v as usize);
250 let end_line = args
251 .get("end_line")
252 .and_then(|v| v.as_u64())
253 .map(|v| v as usize);
254
255 let mut cmd_args = vec![];
256 if let (Some(start), Some(end)) = (start_line, end_line) {
257 let sed_cmd = format!("sed -n '{}','{}'p {}", start, end, file_path);
259 cmd_args = vec!["-c".to_string(), sed_cmd];
260 let output = self
261 .execute_pty_command("sh", cmd_args, Some(10))
262 .await
263 .context("Failed to execute sed")?;
264 return Ok(json!({
265 "command": "cat",
266 "file_path": file_path,
267 "content": output,
268 "start_line": start,
269 "end_line": end,
270 "mode": "pty",
271 "pty_enabled": true
272 }));
273 }
274
275 cmd_args.push(file_path.to_string());
276 let output = self
277 .execute_pty_command("cat", cmd_args, Some(10))
278 .await
279 .context("Failed to execute cat")?;
280
281 Ok(json!({
282 "command": "cat",
283 "file_path": file_path,
284 "content": output,
285 "start_line": start_line,
286 "end_line": end_line,
287 "mode": "pty",
288 "pty_enabled": true
289 }))
290 }
291
292 async fn head(&self, args: Value) -> Result<Value> {
294 let file_path = args
295 .get("file_path")
296 .and_then(|v| v.as_str())
297 .context("file_path is required for head")?;
298
299 let lines = args.get("lines").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
300
301 let cmd_args = vec!["-n".to_string(), lines.to_string(), file_path.to_string()];
302
303 let output = self
304 .execute_pty_command("head", cmd_args, Some(10))
305 .await
306 .context("Failed to execute head")?;
307
308 Ok(json!({
309 "command": "head",
310 "file_path": file_path,
311 "content": output,
312 "lines": lines,
313 "mode": "pty",
314 "pty_enabled": true
315 }))
316 }
317
318 async fn tail(&self, args: Value) -> Result<Value> {
320 let file_path = args
321 .get("file_path")
322 .and_then(|v| v.as_str())
323 .context("file_path is required for tail")?;
324
325 let lines = args.get("lines").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
326
327 let cmd_args = vec!["-n".to_string(), lines.to_string(), file_path.to_string()];
328
329 let output = self
330 .execute_pty_command("tail", cmd_args, Some(10))
331 .await
332 .context("Failed to execute tail")?;
333
334 Ok(json!({
335 "command": "tail",
336 "file_path": file_path,
337 "content": output,
338 "lines": lines,
339 "mode": "pty",
340 "pty_enabled": true
341 }))
342 }
343}
344
345#[async_trait]
346impl Tool for SimpleSearchTool {
347 async fn execute(&self, args: Value) -> Result<Value> {
348 let command = args
349 .get("command")
350 .and_then(|v| v.as_str())
351 .unwrap_or("grep");
352
353 match command {
354 "grep" => self.grep(args).await,
355 "find" => self.find(args).await,
356 "ls" => self.ls(args).await,
357 "cat" => self.cat(args).await,
358 "head" => self.head(args).await,
359 "tail" => self.tail(args).await,
360 _ => Err(anyhow::anyhow!("Unknown command: {}", command)),
361 }
362 }
363
364 fn name(&self) -> &'static str {
365 tools::SIMPLE_SEARCH
366 }
367
368 fn description(&self) -> &'static str {
369 "Simple bash-like search and file operations with security validation: grep, find, ls, cat, head, tail, index. \
370 Only safe read-only operations are allowed - no file modifications or dangerous commands."
371 }
372}