vtcode_core/
bash_runner.rs

1//! Simple bash-like command runner
2//!
3//! This module provides simple, direct command execution that acts like
4//! a human using bash commands. No complex abstractions, just direct
5//! execution of common shell commands.
6
7use anyhow::{Context, Result};
8use std::path::PathBuf;
9use std::process::Command;
10
11/// Simple bash-like command runner
12#[derive(Clone)]
13pub struct BashRunner {
14    /// Working directory
15    working_dir: PathBuf,
16}
17
18impl BashRunner {
19    /// Create a new bash runner
20    pub fn new(working_dir: PathBuf) -> Self {
21        Self { working_dir }
22    }
23
24    /// Change directory (like cd)
25    pub fn cd(&mut self, path: &str) -> Result<()> {
26        let new_path = if path.starts_with('/') {
27            PathBuf::from(path)
28        } else {
29            self.working_dir.join(path)
30        };
31
32        if !new_path.exists() {
33            return Err(anyhow::anyhow!("Directory does not exist: {}", path));
34        }
35
36        if !new_path.is_dir() {
37            return Err(anyhow::anyhow!("Path is not a directory: {}", path));
38        }
39
40        self.working_dir = new_path.canonicalize()?;
41        Ok(())
42    }
43
44    /// List directory contents (like ls)
45    pub fn ls(&self, path: Option<&str>, show_hidden: bool) -> Result<String> {
46        let target_path = path
47            .map(|p| self.resolve_path(p))
48            .unwrap_or_else(|| self.working_dir.clone());
49
50        let mut cmd = Command::new("ls");
51        if show_hidden {
52            cmd.arg("-la");
53        } else {
54            cmd.arg("-l");
55        }
56        cmd.arg(&target_path);
57
58        let output = cmd
59            .output()
60            .with_context(|| "Failed to execute ls command".to_string())?;
61
62        if output.status.success() {
63            Ok(String::from_utf8_lossy(&output.stdout).to_string())
64        } else {
65            Err(anyhow::anyhow!(
66                "ls failed: {}",
67                String::from_utf8_lossy(&output.stderr)
68            ))
69        }
70    }
71
72    /// Print working directory (like pwd)
73    pub fn pwd(&self) -> String {
74        self.working_dir.to_string_lossy().to_string()
75    }
76
77    /// Create directory (like mkdir)
78    pub fn mkdir(&self, path: &str, parents: bool) -> Result<()> {
79        let target_path = self.resolve_path(path);
80
81        let mut cmd = Command::new("mkdir");
82        if parents {
83            cmd.arg("-p");
84        }
85        cmd.arg(&target_path);
86
87        let output = cmd
88            .output()
89            .with_context(|| "Failed to execute mkdir command".to_string())?;
90
91        if output.status.success() {
92            Ok(())
93        } else {
94            Err(anyhow::anyhow!(
95                "mkdir failed: {}",
96                String::from_utf8_lossy(&output.stderr)
97            ))
98        }
99    }
100
101    /// Remove files/directories (like rm)
102    pub fn rm(&self, path: &str, recursive: bool, force: bool) -> Result<()> {
103        let target_path = self.resolve_path(path);
104
105        let mut cmd = Command::new("rm");
106        if recursive {
107            cmd.arg("-r");
108        }
109        if force {
110            cmd.arg("-f");
111        }
112        cmd.arg(&target_path);
113
114        let output = cmd
115            .output()
116            .with_context(|| "Failed to execute rm command".to_string())?;
117
118        if output.status.success() {
119            Ok(())
120        } else {
121            Err(anyhow::anyhow!(
122                "rm failed: {}",
123                String::from_utf8_lossy(&output.stderr)
124            ))
125        }
126    }
127
128    /// Copy files/directories (like cp)
129    pub fn cp(&self, source: &str, dest: &str, recursive: bool) -> Result<()> {
130        let source_path = self.resolve_path(source);
131        let dest_path = self.resolve_path(dest);
132
133        let mut cmd = Command::new("cp");
134        if recursive {
135            cmd.arg("-r");
136        }
137        cmd.arg(&source_path).arg(&dest_path);
138
139        let output = cmd
140            .output()
141            .with_context(|| "Failed to execute cp command".to_string())?;
142
143        if output.status.success() {
144            Ok(())
145        } else {
146            Err(anyhow::anyhow!(
147                "cp failed: {}",
148                String::from_utf8_lossy(&output.stderr)
149            ))
150        }
151    }
152
153    /// Move/rename files/directories (like mv)
154    pub fn mv(&self, source: &str, dest: &str) -> Result<()> {
155        let source_path = self.resolve_path(source);
156        let dest_path = self.resolve_path(dest);
157
158        let output = Command::new("mv")
159            .arg(&source_path)
160            .arg(&dest_path)
161            .output()
162            .with_context(|| "Failed to execute mv command".to_string())?;
163
164        if output.status.success() {
165            Ok(())
166        } else {
167            Err(anyhow::anyhow!(
168                "mv failed: {}",
169                String::from_utf8_lossy(&output.stderr)
170            ))
171        }
172    }
173
174    /// Search for text in files (like grep)
175    pub fn grep(&self, pattern: &str, path: Option<&str>, recursive: bool) -> Result<String> {
176        let target_path = path
177            .map(|p| self.resolve_path(p))
178            .unwrap_or_else(|| self.working_dir.clone());
179
180        let mut cmd = Command::new("grep");
181        cmd.arg("-n"); // Show line numbers
182        if recursive {
183            cmd.arg("-r");
184        }
185        cmd.arg(pattern).arg(&target_path);
186
187        let output = cmd
188            .output()
189            .with_context(|| "Failed to execute grep command".to_string())?;
190
191        if output.status.success() {
192            Ok(String::from_utf8_lossy(&output.stdout).to_string())
193        } else {
194            // grep returns non-zero when no matches found, which is not an error for us
195            let stderr = String::from_utf8_lossy(&output.stderr);
196            if stderr.is_empty() {
197                Ok(String::new()) // No matches found
198            } else {
199                Err(anyhow::anyhow!("grep failed: {}", stderr))
200            }
201        }
202    }
203
204    /// Find files (like find)
205    pub fn find(
206        &self,
207        path: Option<&str>,
208        name_pattern: Option<&str>,
209        type_filter: Option<&str>,
210    ) -> Result<String> {
211        let target_path = path
212            .map(|p| self.resolve_path(p))
213            .unwrap_or_else(|| self.working_dir.clone());
214
215        let mut cmd = Command::new("find");
216        cmd.arg(&target_path);
217
218        if let Some(pattern) = name_pattern {
219            cmd.arg("-name").arg(pattern);
220        }
221
222        if let Some(type_filter) = type_filter {
223            cmd.arg("-type").arg(type_filter);
224        }
225
226        let output = cmd
227            .output()
228            .with_context(|| "Failed to execute find command".to_string())?;
229
230        if output.status.success() {
231            Ok(String::from_utf8_lossy(&output.stdout).to_string())
232        } else {
233            Err(anyhow::anyhow!(
234                "find failed: {}",
235                String::from_utf8_lossy(&output.stderr)
236            ))
237        }
238    }
239
240    /// Show file contents (like cat)
241    pub fn cat(
242        &self,
243        path: &str,
244        start_line: Option<usize>,
245        end_line: Option<usize>,
246    ) -> Result<String> {
247        let file_path = self.resolve_path(path);
248
249        if let (Some(start), Some(end)) = (start_line, end_line) {
250            // Use sed to extract specific lines
251            let range = format!("{}q;{}q", start, end);
252            let output = Command::new("sed")
253                .arg("-n")
254                .arg(&range)
255                .arg(&file_path)
256                .output()
257                .with_context(|| "Failed to execute sed command".to_string())?;
258
259            if output.status.success() {
260                Ok(String::from_utf8_lossy(&output.stdout).to_string())
261            } else {
262                Err(anyhow::anyhow!(
263                    "sed failed: {}",
264                    String::from_utf8_lossy(&output.stderr)
265                ))
266            }
267        } else {
268            // Simple cat
269            let output = Command::new("cat")
270                .arg(&file_path)
271                .output()
272                .with_context(|| "Failed to execute cat command".to_string())?;
273
274            if output.status.success() {
275                Ok(String::from_utf8_lossy(&output.stdout).to_string())
276            } else {
277                Err(anyhow::anyhow!(
278                    "cat failed: {}",
279                    String::from_utf8_lossy(&output.stderr)
280                ))
281            }
282        }
283    }
284
285    /// Show first/last lines of file (like head/tail)
286    pub fn head(&self, path: &str, lines: usize) -> Result<String> {
287        let file_path = self.resolve_path(path);
288
289        let output = Command::new("head")
290            .arg("-n")
291            .arg(lines.to_string())
292            .arg(&file_path)
293            .output()
294            .with_context(|| "Failed to execute head command".to_string())?;
295
296        if output.status.success() {
297            Ok(String::from_utf8_lossy(&output.stdout).to_string())
298        } else {
299            Err(anyhow::anyhow!(
300                "head failed: {}",
301                String::from_utf8_lossy(&output.stderr)
302            ))
303        }
304    }
305
306    pub fn tail(&self, path: &str, lines: usize) -> Result<String> {
307        let file_path = self.resolve_path(path);
308
309        let output = Command::new("tail")
310            .arg("-n")
311            .arg(lines.to_string())
312            .arg(&file_path)
313            .output()
314            .with_context(|| "Failed to execute tail command".to_string())?;
315
316        if output.status.success() {
317            Ok(String::from_utf8_lossy(&output.stdout).to_string())
318        } else {
319            Err(anyhow::anyhow!(
320                "tail failed: {}",
321                String::from_utf8_lossy(&output.stderr)
322            ))
323        }
324    }
325
326    /// Get file info (like ls -la but for single file)
327    pub fn stat(&self, path: &str) -> Result<String> {
328        let file_path = self.resolve_path(path);
329
330        let output = Command::new("ls")
331            .arg("-la")
332            .arg(&file_path)
333            .output()
334            .with_context(|| "Failed to execute ls command".to_string())?;
335
336        if output.status.success() {
337            Ok(String::from_utf8_lossy(&output.stdout).to_string())
338        } else {
339            Err(anyhow::anyhow!(
340                "stat failed: {}",
341                String::from_utf8_lossy(&output.stderr)
342            ))
343        }
344    }
345
346    /// Execute arbitrary command
347    pub fn run(&self, command: &str, args: &[&str]) -> Result<String> {
348        let output = Command::new(command)
349            .args(args)
350            .current_dir(&self.working_dir)
351            .output()
352            .with_context(|| format!("Failed to execute command: {}", command))?;
353
354        if output.status.success() {
355            Ok(String::from_utf8_lossy(&output.stdout).to_string())
356        } else {
357            let stderr = String::from_utf8_lossy(&output.stderr);
358            if stderr.is_empty() {
359                Ok(String::new())
360            } else {
361                Err(anyhow::anyhow!("Command failed: {}", stderr))
362            }
363        }
364    }
365
366    // Helper method
367    fn resolve_path(&self, path: &str) -> PathBuf {
368        if path.starts_with('/') {
369            PathBuf::from(path)
370        } else {
371            self.working_dir.join(path)
372        }
373    }
374}