vtcode_bash_runner/
runner.rs

1use crate::executor::{
2    CommandCategory, CommandExecutor, CommandInvocation, CommandOutput, ShellKind,
3};
4use crate::policy::CommandPolicy;
5use anyhow::{Context, Result, anyhow, bail};
6use path_clean::PathClean;
7use shell_escape::escape;
8use std::fs;
9use std::path::{Path, PathBuf};
10use vtcode_commons::WorkspacePaths;
11
12pub struct BashRunner<E, P> {
13    executor: E,
14    policy: P,
15    workspace_root: PathBuf,
16    working_dir: PathBuf,
17    shell_kind: ShellKind,
18}
19
20impl<E, P> BashRunner<E, P>
21where
22    E: CommandExecutor,
23    P: CommandPolicy,
24{
25    pub fn new(workspace_root: PathBuf, executor: E, policy: P) -> Result<Self> {
26        if !workspace_root.exists() {
27            bail!(
28                "workspace root `{}` does not exist",
29                workspace_root.display()
30            );
31        }
32
33        let canonical_root = workspace_root
34            .canonicalize()
35            .with_context(|| format!("failed to canonicalize `{}`", workspace_root.display()))?;
36
37        Ok(Self {
38            executor,
39            policy,
40            workspace_root: canonical_root.clone(),
41            working_dir: canonical_root,
42            shell_kind: default_shell_kind(),
43        })
44    }
45
46    pub fn from_workspace_paths<W>(paths: &W, executor: E, policy: P) -> Result<Self>
47    where
48        W: WorkspacePaths,
49    {
50        Self::new(paths.workspace_root().to_path_buf(), executor, policy)
51    }
52
53    pub fn workspace_root(&self) -> &Path {
54        &self.workspace_root
55    }
56
57    pub fn working_dir(&self) -> &Path {
58        &self.working_dir
59    }
60
61    pub fn shell_kind(&self) -> ShellKind {
62        self.shell_kind
63    }
64
65    pub fn cd(&mut self, path: &str) -> Result<()> {
66        let candidate = self.resolve_path(path);
67        if !candidate.exists() {
68            bail!("directory `{}` does not exist", candidate.display());
69        }
70        if !candidate.is_dir() {
71            bail!("path `{}` is not a directory", candidate.display());
72        }
73
74        let canonical = candidate
75            .canonicalize()
76            .with_context(|| format!("failed to canonicalize `{}`", candidate.display()))?;
77
78        self.ensure_within_workspace(&canonical)?;
79
80        let invocation = CommandInvocation::new(
81            self.shell_kind,
82            format!("cd {}", format_path(self.shell_kind, &canonical)),
83            CommandCategory::ChangeDirectory,
84            canonical.clone(),
85        )
86        .with_paths(vec![canonical.clone()]);
87
88        self.policy.check(&invocation)?;
89        self.working_dir = canonical;
90        Ok(())
91    }
92
93    pub fn ls(&self, path: Option<&str>, show_hidden: bool) -> Result<String> {
94        let target = path
95            .map(|p| self.resolve_existing_path(p))
96            .transpose()?
97            .unwrap_or_else(|| self.working_dir.clone());
98
99        let command = match self.shell_kind {
100            ShellKind::Unix => {
101                let flag = if show_hidden { "-la" } else { "-l" };
102                format!("ls {} {}", flag, format_path(self.shell_kind, &target))
103            }
104            ShellKind::Windows => {
105                let mut parts = vec!["Get-ChildItem".to_string()];
106                if show_hidden {
107                    parts.push("-Force".to_string());
108                }
109                parts.push(format!("-Path {}", format_path(self.shell_kind, &target)));
110                join_command(parts)
111            }
112        };
113
114        let invocation = CommandInvocation::new(
115            self.shell_kind,
116            command,
117            CommandCategory::ListDirectory,
118            self.working_dir.clone(),
119        )
120        .with_paths(vec![target]);
121
122        let output = self.expect_success(invocation)?;
123        Ok(output.stdout)
124    }
125
126    pub fn pwd(&self) -> Result<String> {
127        let invocation = CommandInvocation::new(
128            self.shell_kind,
129            match self.shell_kind {
130                ShellKind::Unix => "pwd".to_string(),
131                ShellKind::Windows => "Get-Location".to_string(),
132            },
133            CommandCategory::PrintDirectory,
134            self.working_dir.clone(),
135        );
136        self.policy.check(&invocation)?;
137        Ok(self.working_dir.to_string_lossy().to_string())
138    }
139
140    pub fn mkdir(&self, path: &str, parents: bool) -> Result<()> {
141        let target = self.resolve_path(path);
142        self.ensure_mutation_target_within_workspace(&target)?;
143
144        let command = match self.shell_kind {
145            ShellKind::Unix => {
146                let mut parts = vec!["mkdir".to_string()];
147                if parents {
148                    parts.push("-p".to_string());
149                }
150                parts.push(format_path(self.shell_kind, &target));
151                join_command(parts)
152            }
153            ShellKind::Windows => {
154                let mut parts = vec!["New-Item".to_string(), "-ItemType Directory".to_string()];
155                if parents {
156                    parts.push("-Force".to_string());
157                }
158                parts.push(format!("-Path {}", format_path(self.shell_kind, &target)));
159                join_command(parts)
160            }
161        };
162
163        let invocation = CommandInvocation::new(
164            self.shell_kind,
165            command,
166            CommandCategory::CreateDirectory,
167            self.working_dir.clone(),
168        )
169        .with_paths(vec![target]);
170
171        self.expect_success(invocation).map(|_| ())
172    }
173
174    pub fn rm(&self, path: &str, recursive: bool, force: bool) -> Result<()> {
175        let target = self.resolve_path(path);
176        self.ensure_mutation_target_within_workspace(&target)?;
177
178        let command = match self.shell_kind {
179            ShellKind::Unix => {
180                let mut parts = vec!["rm".to_string()];
181                if recursive {
182                    parts.push("-r".to_string());
183                }
184                if force {
185                    parts.push("-f".to_string());
186                }
187                parts.push(format_path(self.shell_kind, &target));
188                join_command(parts)
189            }
190            ShellKind::Windows => {
191                let mut parts = vec!["Remove-Item".to_string()];
192                if recursive {
193                    parts.push("-Recurse".to_string());
194                }
195                if force {
196                    parts.push("-Force".to_string());
197                }
198                parts.push(format!("-Path {}", format_path(self.shell_kind, &target)));
199                join_command(parts)
200            }
201        };
202
203        let invocation = CommandInvocation::new(
204            self.shell_kind,
205            command,
206            CommandCategory::Remove,
207            self.working_dir.clone(),
208        )
209        .with_paths(vec![target]);
210
211        self.expect_success(invocation).map(|_| ())
212    }
213
214    pub fn cp(&self, source: &str, dest: &str, recursive: bool) -> Result<()> {
215        let source_path = self.resolve_existing_path(source)?;
216        let dest_path = self.resolve_path(dest);
217        self.ensure_mutation_target_within_workspace(&dest_path)?;
218
219        let command = match self.shell_kind {
220            ShellKind::Unix => {
221                let mut parts = vec!["cp".to_string()];
222                if recursive {
223                    parts.push("-r".to_string());
224                }
225                parts.push(format_path(self.shell_kind, &source_path));
226                parts.push(format_path(self.shell_kind, &dest_path));
227                join_command(parts)
228            }
229            ShellKind::Windows => {
230                let mut parts = vec![
231                    "Copy-Item".to_string(),
232                    format!("-Path {}", format_path(self.shell_kind, &source_path)),
233                    format!("-Destination {}", format_path(self.shell_kind, &dest_path)),
234                ];
235                if recursive {
236                    parts.push("-Recurse".to_string());
237                }
238                join_command(parts)
239            }
240        };
241
242        let invocation = CommandInvocation::new(
243            self.shell_kind,
244            command,
245            CommandCategory::Copy,
246            self.working_dir.clone(),
247        )
248        .with_paths(vec![source_path, dest_path]);
249
250        self.expect_success(invocation).map(|_| ())
251    }
252
253    pub fn mv(&self, source: &str, dest: &str) -> Result<()> {
254        let source_path = self.resolve_existing_path(source)?;
255        let dest_path = self.resolve_path(dest);
256        self.ensure_mutation_target_within_workspace(&dest_path)?;
257
258        let command = match self.shell_kind {
259            ShellKind::Unix => format!(
260                "mv {} {}",
261                format_path(self.shell_kind, &source_path),
262                format_path(self.shell_kind, &dest_path)
263            ),
264            ShellKind::Windows => join_command(vec![
265                "Move-Item".to_string(),
266                format!("-Path {}", format_path(self.shell_kind, &source_path)),
267                format!("-Destination {}", format_path(self.shell_kind, &dest_path)),
268            ]),
269        };
270
271        let invocation = CommandInvocation::new(
272            self.shell_kind,
273            command,
274            CommandCategory::Move,
275            self.working_dir.clone(),
276        )
277        .with_paths(vec![source_path, dest_path]);
278
279        self.expect_success(invocation).map(|_| ())
280    }
281
282    pub fn grep(&self, pattern: &str, path: Option<&str>, recursive: bool) -> Result<String> {
283        let target = path
284            .map(|p| self.resolve_existing_path(p))
285            .transpose()?
286            .unwrap_or_else(|| self.working_dir.clone());
287
288        let command = match self.shell_kind {
289            ShellKind::Unix => {
290                let mut parts = vec!["grep".to_string(), "-n".to_string()];
291                if recursive {
292                    parts.push("-r".to_string());
293                }
294                parts.push(format_pattern(self.shell_kind, pattern));
295                parts.push(format_path(self.shell_kind, &target));
296                join_command(parts)
297            }
298            ShellKind::Windows => {
299                let mut parts = vec![
300                    "Select-String".to_string(),
301                    format!("-Pattern {}", format_pattern(self.shell_kind, pattern)),
302                    format!("-Path {}", format_path(self.shell_kind, &target)),
303                    "-SimpleMatch".to_string(),
304                ];
305                if recursive {
306                    parts.push("-Recurse".to_string());
307                }
308                join_command(parts)
309            }
310        };
311
312        let invocation = CommandInvocation::new(
313            self.shell_kind,
314            command,
315            CommandCategory::Search,
316            self.working_dir.clone(),
317        )
318        .with_paths(vec![target]);
319
320        let output = self.execute_invocation(invocation)?;
321        if output.status.success() {
322            return Ok(output.stdout);
323        }
324
325        if output.stdout.trim().is_empty() && output.stderr.trim().is_empty() {
326            Ok(String::new())
327        } else {
328            Err(anyhow!(
329                "search command failed: {}",
330                if output.stderr.trim().is_empty() {
331                    output.stdout
332                } else {
333                    output.stderr
334                }
335            ))
336        }
337    }
338
339    fn execute_invocation(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
340        self.policy.check(&invocation)?;
341        self.executor.execute(&invocation)
342    }
343
344    fn expect_success(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
345        let output = self.execute_invocation(invocation.clone())?;
346        if output.status.success() {
347            Ok(output)
348        } else {
349            Err(anyhow!(
350                "command `{}` failed: {}",
351                invocation.command,
352                if output.stderr.trim().is_empty() {
353                    output.stdout
354                } else {
355                    output.stderr
356                }
357            ))
358        }
359    }
360
361    fn resolve_existing_path(&self, raw: &str) -> Result<PathBuf> {
362        let path = self.resolve_path(raw);
363        if !path.exists() {
364            bail!("path `{}` does not exist", path.display());
365        }
366
367        let canonical = path
368            .canonicalize()
369            .with_context(|| format!("failed to canonicalize `{}`", path.display()))?;
370
371        self.ensure_within_workspace(&canonical)?;
372        Ok(canonical)
373    }
374
375    fn resolve_path(&self, raw: &str) -> PathBuf {
376        let candidate = Path::new(raw);
377        let joined = if candidate.is_absolute() {
378            candidate.to_path_buf()
379        } else {
380            self.working_dir.join(candidate)
381        };
382        joined.clean()
383    }
384
385    fn ensure_mutation_target_within_workspace(&self, candidate: &Path) -> Result<()> {
386        if let Ok(metadata) = fs::symlink_metadata(candidate) {
387            if metadata.file_type().is_symlink() {
388                let canonical = candidate
389                    .canonicalize()
390                    .with_context(|| format!("failed to canonicalize `{}`", candidate.display()))?;
391                return self.ensure_within_workspace(&canonical);
392            }
393        }
394
395        if candidate.exists() {
396            let canonical = candidate
397                .canonicalize()
398                .with_context(|| format!("failed to canonicalize `{}`", candidate.display()))?;
399            self.ensure_within_workspace(&canonical)
400        } else {
401            let parent = self.canonicalize_existing_parent(candidate)?;
402            self.ensure_within_workspace(&parent)
403        }
404    }
405
406    fn canonicalize_existing_parent(&self, candidate: &Path) -> Result<PathBuf> {
407        let mut current = candidate.parent();
408        while let Some(path) = current {
409            if path.exists() {
410                return path
411                    .canonicalize()
412                    .with_context(|| format!("failed to canonicalize `{}`", path.display()));
413            }
414            current = path.parent();
415        }
416
417        Ok(self.working_dir.clone())
418    }
419
420    fn ensure_within_workspace(&self, candidate: &Path) -> Result<()> {
421        if !candidate.starts_with(&self.workspace_root) {
422            bail!(
423                "path `{}` escapes workspace root `{}`",
424                candidate.display(),
425                self.workspace_root.display()
426            );
427        }
428        Ok(())
429    }
430}
431
432fn default_shell_kind() -> ShellKind {
433    if cfg!(windows) {
434        ShellKind::Windows
435    } else {
436        ShellKind::Unix
437    }
438}
439
440fn join_command(parts: Vec<String>) -> String {
441    parts
442        .into_iter()
443        .filter(|part| !part.is_empty())
444        .collect::<Vec<_>>()
445        .join(" ")
446}
447
448fn format_path(shell: ShellKind, path: &Path) -> String {
449    match shell {
450        ShellKind::Unix => escape(path.to_string_lossy()).to_string(),
451        ShellKind::Windows => format!("'{}'", path.to_string_lossy().replace('\'', "''")),
452    }
453}
454
455fn format_pattern(shell: ShellKind, pattern: &str) -> String {
456    match shell {
457        ShellKind::Unix => escape(pattern.into()).to_string(),
458        ShellKind::Windows => format!("'{}'", pattern.replace('\'', "''")),
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use crate::executor::{CommandInvocation, CommandOutput, CommandStatus};
466    use crate::policy::AllowAllPolicy;
467    use assert_fs::TempDir;
468    use std::sync::{Arc, Mutex};
469
470    #[derive(Clone, Default)]
471    struct RecordingExecutor {
472        invocations: Arc<Mutex<Vec<CommandInvocation>>>,
473    }
474
475    impl CommandExecutor for RecordingExecutor {
476        fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
477            self.invocations.lock().unwrap().push(invocation.clone());
478            Ok(CommandOutput {
479                status: CommandStatus::new(true, Some(0)),
480                stdout: String::new(),
481                stderr: String::new(),
482            })
483        }
484    }
485
486    #[test]
487    fn cd_updates_working_directory() {
488        let dir = TempDir::new().unwrap();
489        let nested = dir.path().join("nested");
490        std::fs::create_dir(&nested).unwrap();
491        let runner = BashRunner::new(
492            dir.path().to_path_buf(),
493            RecordingExecutor::default(),
494            AllowAllPolicy,
495        );
496        let mut runner = runner.unwrap();
497        runner.cd("nested").unwrap();
498        assert_eq!(runner.working_dir(), nested);
499    }
500
501    #[test]
502    fn mkdir_records_invocation() {
503        let dir = TempDir::new().unwrap();
504        let executor = RecordingExecutor::default();
505        let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy);
506        runner.unwrap().mkdir("new_dir", true).unwrap();
507        let invocations = executor.invocations.lock().unwrap();
508        assert_eq!(invocations.len(), 1);
509        assert_eq!(invocations[0].category, CommandCategory::CreateDirectory);
510    }
511}