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            && (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    /// Execute grep-like search
129    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        // Build grep command
142        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()); // recursive
148        cmd_args.push("-n".to_string()); // line numbers
149        cmd_args.push(".".to_string()); // current directory
150
151        let output = self
152            .execute_pty_command("grep", cmd_args, Some(30))
153            .await
154            .context("Failed to execute grep")?;
155
156        // Parse and limit results
157        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    /// Execute find-like file search
171    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        // Build find command
178        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    /// Execute ls-like directory listing
204    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        // Build ls command
213        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    /// Execute cat-like file content reading
240    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            // Use sed to extract line range
258            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    /// Execute head-like file preview
293    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    /// Execute tail-like file preview
319    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}