Skip to main content

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