vtcode_core/tools/
simple_search.rs

1//! Simple bash-like search tool
2//!
3//! This tool provides direct search capabilities similar to common
4//! bash commands such as grep, find, ls, and cat.
5
6use 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/// Simple bash-like search tool
16#[derive(Clone)]
17pub struct SimpleSearchTool {
18    indexer: SimpleIndexer,
19}
20
21impl SimpleSearchTool {
22    /// Create a new simple search tool
23    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    /// Execute command and capture its stdout
33    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    /// Validate command for security
75    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        // For SimpleSearchTool, we only allow safe read-only commands
83        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        // Additional validation for specific commands
97        let full_command = command_parts.join(" ");
98
99        // Prevent access to sensitive directories
100        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        // Prevent dangerous grep/find patterns
114        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    /// Execute grep-like search
130    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        // Build grep command
143        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()); // recursive
149        cmd_args.push("-n".to_string()); // line numbers
150        cmd_args.push(".".to_string()); // current directory
151
152        let output = self
153            .execute_pty_command("grep", cmd_args, Some(30))
154            .await
155            .context("Failed to execute grep")?;
156
157        // Parse and limit results
158        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    /// Execute find-like file search
172    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        // Build find command
179        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    /// Execute ls-like directory listing
205    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        // Build ls command
214        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    /// Execute cat-like file content reading
241    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            // Use sed to extract line range
259            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    /// Execute head-like file preview
294    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    /// Execute tail-like file preview
320    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}