Skip to main content

vtcode_bash_runner/
executor.rs

1use anyhow::{Context, Result};
2#[cfg(any(not(feature = "powershell-process"), feature = "pure-rust"))]
3use anyhow::{anyhow, bail};
4#[cfg(feature = "pure-rust")]
5use std::path::Path;
6use std::path::PathBuf;
7
8#[cfg(feature = "exec-events")]
9use parking_lot::Mutex;
10#[cfg(feature = "serde-errors")]
11use serde::{Deserialize, Serialize};
12#[cfg(feature = "pure-rust")]
13use std::fs;
14#[cfg(feature = "exec-events")]
15use std::sync::atomic::{AtomicU64, Ordering};
16#[cfg(feature = "dry-run")]
17use std::sync::{Arc, Mutex as DryRunMutex};
18
19#[cfg(feature = "exec-events")]
20use vtcode_exec_events::{
21    CommandExecutionItem, CommandExecutionStatus, EventEmitter, ItemCompletedEvent, ItemStartedEvent, ThreadEvent,
22    ThreadItem, ThreadItemDetails,
23};
24
25/// Logical grouping for commands issued by the [`BashRunner`][crate::BashRunner].
26#[cfg_attr(feature = "serde-errors", derive(Serialize, Deserialize))]
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum CommandCategory {
29    ChangeDirectory,
30    ListDirectory,
31    PrintDirectory,
32    CreateDirectory,
33    Remove,
34    Copy,
35    Move,
36    Search,
37}
38
39/// Shell family used to execute commands.
40#[cfg_attr(feature = "serde-errors", derive(Serialize, Deserialize))]
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42pub enum ShellKind {
43    Unix,
44    Windows,
45}
46
47/// Describes a command that will be executed by a [`CommandExecutor`].
48#[cfg_attr(feature = "serde-errors", derive(Serialize, Deserialize))]
49#[derive(Debug, Clone)]
50pub struct CommandInvocation {
51    shell: ShellKind,
52    pub command: String,
53    pub(crate) category: CommandCategory,
54    pub(crate) working_dir: PathBuf,
55    pub(crate) touched_paths: Vec<PathBuf>,
56}
57
58impl CommandInvocation {
59    pub(crate) fn new(shell: ShellKind, command: String, category: CommandCategory, working_dir: PathBuf) -> Self {
60        Self {
61            shell,
62            command,
63            category,
64            working_dir,
65            touched_paths: Vec::new(),
66        }
67    }
68
69    pub(crate) fn with_paths(mut self, paths: Vec<PathBuf>) -> Self {
70        self.touched_paths = paths;
71        self
72    }
73}
74
75/// Describes the exit status of a command execution.
76#[cfg_attr(feature = "serde-errors", derive(Serialize, Deserialize))]
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct CommandStatus {
79    success: bool,
80    code: Option<i32>,
81}
82
83impl CommandStatus {
84    pub(crate) fn new(success: bool, code: Option<i32>) -> Self {
85        Self { success, code }
86    }
87
88    pub(crate) fn success(&self) -> bool {
89        self.success
90    }
91
92    fn code(&self) -> Option<i32> {
93        self.code
94    }
95
96    #[cold]
97    pub fn failure(code: Option<i32>) -> Self {
98        Self { success: false, code }
99    }
100}
101
102impl From<std::process::ExitStatus> for CommandStatus {
103    fn from(status: std::process::ExitStatus) -> Self {
104        let code = status.code();
105        Self { success: status.success(), code }
106    }
107}
108
109/// Output produced by the executor for a command invocation.
110#[cfg_attr(feature = "serde-errors", derive(Serialize, Deserialize))]
111#[derive(Debug, Clone)]
112pub struct CommandOutput {
113    pub(crate) status: CommandStatus,
114    pub(crate) stdout: String,
115    pub(crate) stderr: String,
116}
117
118impl CommandOutput {
119    fn success(stdout: impl Into<String>) -> Self {
120        Self {
121            status: CommandStatus::new(true, Some(0)),
122            stdout: stdout.into(),
123            stderr: String::new(),
124        }
125    }
126
127    pub fn failure(code: Option<i32>, stdout: impl Into<String>, stderr: impl Into<String>) -> Self {
128        Self {
129            status: CommandStatus::failure(code),
130            stdout: stdout.into(),
131            stderr: stderr.into(),
132        }
133    }
134}
135
136/// Trait implemented by concrete command execution strategies.
137pub trait CommandExecutor: Send + Sync {
138    fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput>;
139}
140
141/// Executes commands by delegating to the system shell via [`std::process::Command`].
142#[cfg(feature = "std-process")]
143pub struct ProcessCommandExecutor;
144
145#[cfg(feature = "std-process")]
146impl ProcessCommandExecutor {
147    fn new() -> Self {
148        Self
149    }
150}
151
152#[cfg(feature = "std-process")]
153impl Default for ProcessCommandExecutor {
154    fn default() -> Self {
155        Self::new()
156    }
157}
158
159#[cfg(feature = "std-process")]
160impl CommandExecutor for ProcessCommandExecutor {
161    fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
162        use std::process::Command;
163
164        let mut cmd = match invocation.shell {
165            ShellKind::Unix => {
166                let mut command = Command::new("sh");
167                command.arg("-c").arg(&invocation.command);
168                command
169            }
170            ShellKind::Windows => {
171                #[cfg(not(feature = "powershell-process"))]
172                {
173                    bail!("powershell-process feature disabled; enable it to execute Windows commands");
174                }
175                #[cfg(feature = "powershell-process")]
176                let mut command = Command::new("powershell");
177                command
178                    .arg("-NoProfile")
179                    .arg("-NonInteractive")
180                    .arg("-Command")
181                    .arg(&invocation.command);
182                #[cfg(feature = "powershell-process")]
183                {
184                    command
185                }
186            }
187        };
188
189        cmd.current_dir(&invocation.working_dir);
190        let output = cmd
191            .output()
192            .with_context(|| format!("failed to execute command: {}", invocation.command))?;
193
194        Ok(CommandOutput {
195            status: CommandStatus::from(output.status),
196            stdout: String::from_utf8(output.stdout).unwrap_or_else(|e| e.to_string()),
197            stderr: String::from_utf8(output.stderr).unwrap_or_else(|e| e.to_string()),
198        })
199    }
200}
201
202#[cfg(feature = "dry-run")]
203#[derive(Clone, Default)]
204pub struct DryRunCommandExecutor {
205    log: Arc<DryRunMutex<Vec<CommandInvocation>>>,
206}
207
208#[cfg(feature = "dry-run")]
209impl DryRunCommandExecutor {
210    pub fn new() -> Self {
211        Self::default()
212    }
213
214    pub fn logged_invocations(&self) -> Vec<CommandInvocation> {
215        match self.log.lock() {
216            Ok(guard) => guard.clone(),
217            Err(poisoned) => poisoned.into_inner().clone(),
218        }
219    }
220}
221
222#[cfg(feature = "dry-run")]
223impl CommandExecutor for DryRunCommandExecutor {
224    fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
225        let mut guard = match self.log.lock() {
226            Ok(guard) => guard,
227            Err(poisoned) => poisoned.into_inner(),
228        };
229        guard.push(invocation.clone());
230        Ok(match invocation.category {
231            CommandCategory::ListDirectory => CommandOutput::success("(dry-run listing)"),
232            _ => CommandOutput::success(String::new()),
233        })
234    }
235}
236
237#[cfg(feature = "pure-rust")]
238#[derive(Debug, Default, Clone, Copy)]
239pub struct PureRustCommandExecutor;
240
241#[cfg(feature = "pure-rust")]
242impl PureRustCommandExecutor {
243    fn resolve_primary_path(invocation: &CommandInvocation) -> Result<&PathBuf> {
244        invocation
245            .touched_paths
246            .first()
247            .ok_or_else(|| anyhow!("invocation missing target path"))
248    }
249
250    fn should_include_hidden(command: &str) -> bool {
251        command.contains("-a") || command.contains("-Force")
252    }
253
254    fn mkdir(path: &Path, command: &str) -> Result<()> {
255        if command.contains("-p") || command.contains("-Force") {
256            fs::create_dir_all(path).with_context(|| format!("failed to create directory `{}`", path.display()))?
257        } else {
258            fs::create_dir(path).with_context(|| format!("failed to create directory `{}`", path.display()))?
259        }
260        Ok(())
261    }
262
263    fn rm(path: &Path, command: &str) -> Result<()> {
264        if path.is_dir() {
265            if command.contains("-r") || command.contains("-Recurse") {
266                fs::remove_dir_all(path).with_context(|| format!("failed to remove directory `{}`", path.display()))?
267            } else {
268                fs::remove_dir(path).with_context(|| format!("failed to remove directory `{}`", path.display()))?
269            }
270        } else if path.exists() {
271            fs::remove_file(path).with_context(|| format!("failed to remove file `{}`", path.display()))?
272        }
273        Ok(())
274    }
275
276    fn copy_recursive(source: &Path, dest: &Path, recursive: bool) -> Result<()> {
277        if source.is_dir() {
278            if !recursive {
279                bail!("copying directory `{}` requires recursive flag", source.display());
280            }
281            fs::create_dir_all(dest).with_context(|| format!("failed to create directory `{}`", dest.display()))?;
282            for entry in
283                fs::read_dir(source).with_context(|| format!("failed to read directory `{}`", source.display()))?
284            {
285                let entry = entry?;
286                let entry_path = entry.path();
287                let dest_path = dest.join(entry.file_name());
288                if entry_path.is_dir() {
289                    Self::copy_recursive(&entry_path, &dest_path, true)?;
290                } else {
291                    Self::copy_file(&entry_path, &dest_path)?;
292                }
293            }
294        } else {
295            Self::copy_file(source, dest)?;
296        }
297        Ok(())
298    }
299
300    fn copy_file(source: &Path, dest: &Path) -> Result<()> {
301        if let Some(parent) = dest.parent() {
302            fs::create_dir_all(parent)
303                .with_context(|| format!("failed to prepare destination directory `{}`", parent.display()))?;
304        }
305        fs::copy(source, dest)
306            .with_context(|| format!("failed to copy `{}` to `{}`", source.display(), dest.display()))?;
307        Ok(())
308    }
309
310    fn move_path(source: &Path, dest: &Path) -> Result<()> {
311        if let Some(parent) = dest.parent() {
312            fs::create_dir_all(parent)
313                .with_context(|| format!("failed to prepare destination directory `{}`", parent.display()))?;
314        }
315
316        if let Err(rename_err) = fs::rename(source, dest) {
317            Self::copy_recursive(source, dest, true)
318                .and_then(|_| Self::rm(source, "-r -f"))
319                .with_context(|| {
320                    format!("failed to move `{}` to `{}` via rename: {rename_err}", source.display(), dest.display())
321                })?;
322        }
323        Ok(())
324    }
325}
326
327#[cfg(feature = "pure-rust")]
328impl CommandExecutor for PureRustCommandExecutor {
329    fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
330        match invocation.category {
331            CommandCategory::ListDirectory => {
332                let path = Self::resolve_primary_path(invocation)?;
333                let mut entries = Vec::new();
334                for entry in
335                    fs::read_dir(path).with_context(|| format!("failed to read directory `{}`", path.display()))?
336                {
337                    let entry = entry?;
338                    let name = entry.file_name();
339                    let name = name.to_string_lossy();
340                    if !Self::should_include_hidden(&invocation.command) && name.starts_with('.') {
341                        continue;
342                    }
343                    entries.push(name.to_string());
344                }
345                entries.sort();
346                Ok(CommandOutput::success(entries.join("\n")))
347            }
348            CommandCategory::CreateDirectory => {
349                let path = Self::resolve_primary_path(invocation)?;
350                Self::mkdir(path, &invocation.command)?;
351                Ok(CommandOutput::success(String::new()))
352            }
353            CommandCategory::Remove => {
354                let path = Self::resolve_primary_path(invocation)?;
355                Self::rm(path, &invocation.command)?;
356                Ok(CommandOutput::success(String::new()))
357            }
358            CommandCategory::Copy => {
359                let source = invocation
360                    .touched_paths
361                    .first()
362                    .ok_or_else(|| anyhow!("copy missing source path"))?;
363                let dest = invocation
364                    .touched_paths
365                    .get(1)
366                    .ok_or_else(|| anyhow!("copy missing destination path"))?;
367                let recursive = invocation.command.contains("-r") || invocation.command.contains("-Recurse");
368                Self::copy_recursive(source.as_path(), dest.as_path(), recursive)?;
369                Ok(CommandOutput::success(String::new()))
370            }
371            CommandCategory::Move => {
372                let source = invocation
373                    .touched_paths
374                    .first()
375                    .ok_or_else(|| anyhow!("move missing source path"))?;
376                let dest = invocation
377                    .touched_paths
378                    .get(1)
379                    .ok_or_else(|| anyhow!("move missing destination path"))?;
380                Self::move_path(source.as_path(), dest.as_path())?;
381                Ok(CommandOutput::success(String::new()))
382            }
383            CommandCategory::Search => {
384                bail!("pure-rust executor does not implement search; enable std-process or provide a custom executor")
385            }
386            CommandCategory::ChangeDirectory | CommandCategory::PrintDirectory => {
387                Ok(CommandOutput::success(String::new()))
388            }
389        }
390    }
391}
392
393#[cfg(feature = "exec-events")]
394#[derive(Debug)]
395pub struct EventfulExecutor<E, T> {
396    inner: E,
397    emitter: Mutex<T>,
398    counter: AtomicU64,
399    id_prefix: String,
400}
401
402#[cfg(feature = "exec-events")]
403impl<E, T> EventfulExecutor<E, T>
404where
405    T: EventEmitter,
406{
407    pub fn new(inner: E, emitter: T) -> Self {
408        Self {
409            inner,
410            emitter: Mutex::new(emitter),
411            counter: AtomicU64::new(0),
412            id_prefix: "cmd-".to_string(),
413        }
414    }
415
416    pub fn with_id_prefix(inner: E, emitter: T, prefix: impl Into<String>) -> Self {
417        let mut executor = Self::new(inner, emitter);
418        executor.id_prefix = prefix.into();
419        executor
420    }
421
422    fn next_id(&self) -> String {
423        let value = self.counter.fetch_add(1, Ordering::Relaxed) + 1;
424        let mut id = String::with_capacity(self.id_prefix.len() + 10);
425        id.push_str(&self.id_prefix);
426        use std::fmt::Write;
427        let _ = write!(id, "{value}");
428        id
429    }
430
431    fn emit_event(&self, event: ThreadEvent) {
432        let mut emitter = self.emitter.lock();
433        EventEmitter::emit(&mut *emitter, &event);
434    }
435
436    fn command_details(
437        &self,
438        invocation: &CommandInvocation,
439        status: CommandExecutionStatus,
440        output: Option<&CommandOutput>,
441        error: Option<&anyhow::Error>,
442    ) -> CommandExecutionItem {
443        let aggregated_output = if let Some(output) = output {
444            aggregate_output(output)
445        } else if let Some(err) = error {
446            err.to_string()
447        } else {
448            String::new()
449        };
450
451        CommandExecutionItem {
452            command: invocation.command.clone(),
453            arguments: None,
454            aggregated_output,
455            exit_code: output.and_then(|out| out.status.code()),
456            status,
457        }
458    }
459}
460
461#[cfg(feature = "exec-events")]
462impl<E, T> CommandExecutor for EventfulExecutor<E, T>
463where
464    E: CommandExecutor,
465    T: EventEmitter + Send,
466{
467    fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
468        let item_id = self.next_id();
469        let starting_item = ThreadItem {
470            id: item_id.clone(),
471            details: ThreadItemDetails::CommandExecution(Box::new(self.command_details(
472                invocation,
473                CommandExecutionStatus::InProgress,
474                None,
475                None,
476            ))),
477        };
478        self.emit_event(ThreadEvent::ItemStarted(ItemStartedEvent { item: starting_item }));
479
480        match self.inner.execute(invocation) {
481            Ok(output) => {
482                let status = if output.status.success() {
483                    CommandExecutionStatus::Completed
484                } else {
485                    CommandExecutionStatus::Failed
486                };
487
488                let completed_item = ThreadItem {
489                    id: item_id,
490                    details: ThreadItemDetails::CommandExecution(Box::new(self.command_details(
491                        invocation,
492                        status,
493                        Some(&output),
494                        None,
495                    ))),
496                };
497                self.emit_event(ThreadEvent::ItemCompleted(ItemCompletedEvent { item: completed_item }));
498                Ok(output)
499            }
500            Err(err) => {
501                let failure = ThreadItem {
502                    id: item_id,
503                    details: ThreadItemDetails::CommandExecution(Box::new(self.command_details(
504                        invocation,
505                        CommandExecutionStatus::Failed,
506                        None,
507                        Some(&err),
508                    ))),
509                };
510                self.emit_event(ThreadEvent::ItemCompleted(ItemCompletedEvent { item: failure }));
511                Err(err)
512            }
513        }
514    }
515}
516
517#[cfg(feature = "exec-events")]
518fn aggregate_output(output: &CommandOutput) -> String {
519    let mut combined = String::new();
520    if !output.stdout.trim().is_empty() {
521        combined.push_str(output.stdout.trim());
522    }
523    if !output.stderr.trim().is_empty() {
524        if !combined.is_empty() {
525            combined.push('\n');
526        }
527        combined.push_str(output.stderr.trim());
528    }
529    combined
530}