Skip to main content

vtcode_bash_runner/
runner.rs

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