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    form: CommandForm,
54    pub(crate) category: CommandCategory,
55    pub(crate) working_dir: PathBuf,
56    pub(crate) touched_paths: Vec<PathBuf>,
57}
58
59#[cfg_attr(feature = "serde-errors", derive(Serialize, Deserialize))]
60#[derive(Debug, Clone)]
61enum CommandForm {
62    DirectArgv(Vec<String>),
63    ValidatedShellScript(String),
64    Invalid(String),
65}
66
67impl CommandInvocation {
68    pub(crate) fn new(shell: ShellKind, command: String, category: CommandCategory, working_dir: PathBuf) -> Self {
69        let form = match shell {
70            ShellKind::Unix => match shell_words::split(&command) {
71                Ok(argv) if !argv.is_empty() => CommandForm::DirectArgv(argv),
72                Ok(_) => CommandForm::Invalid("direct command is empty".to_owned()),
73                Err(error) => CommandForm::Invalid(format!("direct command is not valid argv: {error}")),
74            },
75            ShellKind::Windows => CommandForm::ValidatedShellScript(command.clone()),
76        };
77        Self {
78            shell,
79            command,
80            form,
81            category,
82            working_dir,
83            touched_paths: Vec::new(),
84        }
85    }
86
87    pub(crate) fn with_paths(mut self, paths: Vec<PathBuf>) -> Self {
88        self.touched_paths = paths;
89        self
90    }
91}
92
93/// Describes the exit status of a command execution.
94#[cfg_attr(feature = "serde-errors", derive(Serialize, Deserialize))]
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub struct CommandStatus {
97    success: bool,
98    code: Option<i32>,
99}
100
101impl CommandStatus {
102    pub(crate) fn new(success: bool, code: Option<i32>) -> Self {
103        Self { success, code }
104    }
105
106    pub(crate) fn success(&self) -> bool {
107        self.success
108    }
109
110    fn code(&self) -> Option<i32> {
111        self.code
112    }
113
114    #[cold]
115    pub fn failure(code: Option<i32>) -> Self {
116        Self { success: false, code }
117    }
118}
119
120impl From<std::process::ExitStatus> for CommandStatus {
121    fn from(status: std::process::ExitStatus) -> Self {
122        let code = status.code();
123        Self { success: status.success(), code }
124    }
125}
126
127/// Output produced by the executor for a command invocation.
128#[cfg_attr(feature = "serde-errors", derive(Serialize, Deserialize))]
129#[derive(Debug, Clone)]
130pub struct CommandOutput {
131    pub(crate) status: CommandStatus,
132    pub(crate) stdout: String,
133    pub(crate) stderr: String,
134}
135
136impl CommandOutput {
137    fn success(stdout: impl Into<String>) -> Self {
138        Self {
139            status: CommandStatus::new(true, Some(0)),
140            stdout: stdout.into(),
141            stderr: String::new(),
142        }
143    }
144
145    pub fn failure(code: Option<i32>, stdout: impl Into<String>, stderr: impl Into<String>) -> Self {
146        Self {
147            status: CommandStatus::failure(code),
148            stdout: stdout.into(),
149            stderr: stderr.into(),
150        }
151    }
152}
153
154/// Trait implemented by concrete command execution strategies.
155pub trait CommandExecutor: Send + Sync {
156    fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput>;
157}
158
159/// Executes commands by delegating to the system shell via [`std::process::Command`].
160#[cfg(feature = "std-process")]
161pub struct ProcessCommandExecutor;
162
163#[cfg(feature = "std-process")]
164impl ProcessCommandExecutor {
165    fn new() -> Self {
166        Self
167    }
168}
169
170#[cfg(feature = "std-process")]
171impl Default for ProcessCommandExecutor {
172    fn default() -> Self {
173        Self::new()
174    }
175}
176
177#[cfg(feature = "std-process")]
178impl CommandExecutor for ProcessCommandExecutor {
179    fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
180        use std::process::Command;
181
182        let mut cmd = match &invocation.form {
183            CommandForm::DirectArgv(argv) => {
184                let (program, args) = argv.split_first().context("direct command is missing executable")?;
185                let mut command = Command::new(program);
186                command.args(args);
187                command
188            }
189            CommandForm::ValidatedShellScript(script) if invocation.shell == ShellKind::Unix => {
190                let mut command = Command::new("sh");
191                command.arg("-c").arg(script);
192                command
193            }
194            CommandForm::ValidatedShellScript(script) => {
195                #[cfg(not(feature = "powershell-process"))]
196                {
197                    bail!("powershell-process feature disabled; enable it to execute Windows commands");
198                }
199                #[cfg(feature = "powershell-process")]
200                let mut command = Command::new("powershell");
201                command.arg("-NoProfile").arg("-NonInteractive").arg("-Command").arg(script);
202                #[cfg(feature = "powershell-process")]
203                {
204                    command
205                }
206            }
207            CommandForm::Invalid(message) => return Err(anyhow::Error::msg(message.clone())),
208        };
209
210        #[cfg(unix)]
211        {
212            let directory = vtcode_commons::fs::bound_file::open_directory_handle(&invocation.working_dir)
213                .with_context(|| format!("bind command working directory {}", invocation.working_dir.display()))?;
214            vtcode_commons::fs::bound_file::set_command_working_directory(&mut cmd, &directory)
215                .context("confine command working directory")?;
216            cmd.current_dir(&invocation.working_dir);
217        }
218        #[cfg(not(unix))]
219        cmd.current_dir(&invocation.working_dir);
220        let output = cmd
221            .output()
222            .with_context(|| format!("failed to execute command: {}", invocation.command))?;
223
224        Ok(CommandOutput {
225            status: CommandStatus::from(output.status),
226            stdout: String::from_utf8(output.stdout).unwrap_or_else(|e| e.to_string()),
227            stderr: String::from_utf8(output.stderr).unwrap_or_else(|e| e.to_string()),
228        })
229    }
230}
231
232#[cfg(feature = "dry-run")]
233#[derive(Clone, Default)]
234pub struct DryRunCommandExecutor {
235    log: Arc<DryRunMutex<Vec<CommandInvocation>>>,
236}
237
238#[cfg(feature = "dry-run")]
239impl DryRunCommandExecutor {
240    pub fn new() -> Self {
241        Self::default()
242    }
243
244    pub fn logged_invocations(&self) -> Vec<CommandInvocation> {
245        match self.log.lock() {
246            Ok(guard) => guard.clone(),
247            Err(poisoned) => poisoned.into_inner().clone(),
248        }
249    }
250}
251
252#[cfg(feature = "dry-run")]
253impl CommandExecutor for DryRunCommandExecutor {
254    fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
255        let mut guard = match self.log.lock() {
256            Ok(guard) => guard,
257            Err(poisoned) => poisoned.into_inner(),
258        };
259        guard.push(invocation.clone());
260        Ok(match invocation.category {
261            CommandCategory::ListDirectory => CommandOutput::success("(dry-run listing)"),
262            _ => CommandOutput::success(String::new()),
263        })
264    }
265}
266
267#[cfg(feature = "pure-rust")]
268#[derive(Debug, Default, Clone, Copy)]
269pub struct PureRustCommandExecutor;
270
271#[cfg(feature = "pure-rust")]
272impl PureRustCommandExecutor {
273    fn resolve_primary_path(invocation: &CommandInvocation) -> Result<&PathBuf> {
274        invocation
275            .touched_paths
276            .first()
277            .ok_or_else(|| anyhow!("invocation missing target path"))
278    }
279
280    fn should_include_hidden(command: &str) -> bool {
281        command.contains("-a") || command.contains("-Force")
282    }
283
284    fn mkdir(path: &Path, command: &str) -> Result<()> {
285        if command.contains("-p") || command.contains("-Force") {
286            fs::create_dir_all(path).with_context(|| format!("failed to create directory `{}`", path.display()))?
287        } else {
288            fs::create_dir(path).with_context(|| format!("failed to create directory `{}`", path.display()))?
289        }
290        Ok(())
291    }
292
293    fn rm(path: &Path, command: &str) -> Result<()> {
294        if path.is_dir() {
295            if command.contains("-r") || command.contains("-Recurse") {
296                fs::remove_dir_all(path).with_context(|| format!("failed to remove directory `{}`", path.display()))?
297            } else {
298                fs::remove_dir(path).with_context(|| format!("failed to remove directory `{}`", path.display()))?
299            }
300        } else if path.exists() {
301            fs::remove_file(path).with_context(|| format!("failed to remove file `{}`", path.display()))?
302        }
303        Ok(())
304    }
305
306    fn copy_recursive(source: &Path, dest: &Path, recursive: bool) -> Result<()> {
307        if source.is_dir() {
308            if !recursive {
309                bail!("copying directory `{}` requires recursive flag", source.display());
310            }
311            fs::create_dir_all(dest).with_context(|| format!("failed to create directory `{}`", dest.display()))?;
312            for entry in
313                fs::read_dir(source).with_context(|| format!("failed to read directory `{}`", source.display()))?
314            {
315                let entry = entry?;
316                let entry_path = entry.path();
317                let dest_path = dest.join(entry.file_name());
318                if entry_path.is_dir() {
319                    Self::copy_recursive(&entry_path, &dest_path, true)?;
320                } else {
321                    Self::copy_file(&entry_path, &dest_path)?;
322                }
323            }
324        } else {
325            Self::copy_file(source, dest)?;
326        }
327        Ok(())
328    }
329
330    fn copy_file(source: &Path, dest: &Path) -> Result<()> {
331        if let Some(parent) = dest.parent() {
332            fs::create_dir_all(parent)
333                .with_context(|| format!("failed to prepare destination directory `{}`", parent.display()))?;
334        }
335        fs::copy(source, dest)
336            .with_context(|| format!("failed to copy `{}` to `{}`", source.display(), dest.display()))?;
337        Ok(())
338    }
339
340    fn move_path(source: &Path, dest: &Path) -> Result<()> {
341        if let Some(parent) = dest.parent() {
342            fs::create_dir_all(parent)
343                .with_context(|| format!("failed to prepare destination directory `{}`", parent.display()))?;
344        }
345
346        if let Err(rename_err) = fs::rename(source, dest) {
347            Self::copy_recursive(source, dest, true)
348                .and_then(|_| Self::rm(source, "-r -f"))
349                .with_context(|| {
350                    format!("failed to move `{}` to `{}` via rename: {rename_err}", source.display(), dest.display())
351                })?;
352        }
353        Ok(())
354    }
355}
356
357#[cfg(feature = "pure-rust")]
358impl CommandExecutor for PureRustCommandExecutor {
359    fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
360        match invocation.category {
361            CommandCategory::ListDirectory => {
362                let path = Self::resolve_primary_path(invocation)?;
363                let mut entries = Vec::new();
364                for entry in
365                    fs::read_dir(path).with_context(|| format!("failed to read directory `{}`", path.display()))?
366                {
367                    let entry = entry?;
368                    let name = entry.file_name();
369                    let name = name.to_string_lossy();
370                    if !Self::should_include_hidden(&invocation.command) && name.starts_with('.') {
371                        continue;
372                    }
373                    entries.push(name.to_string());
374                }
375                entries.sort();
376                Ok(CommandOutput::success(entries.join("\n")))
377            }
378            CommandCategory::CreateDirectory => {
379                let path = Self::resolve_primary_path(invocation)?;
380                Self::mkdir(path, &invocation.command)?;
381                Ok(CommandOutput::success(String::new()))
382            }
383            CommandCategory::Remove => {
384                let path = Self::resolve_primary_path(invocation)?;
385                Self::rm(path, &invocation.command)?;
386                Ok(CommandOutput::success(String::new()))
387            }
388            CommandCategory::Copy => {
389                let source = invocation
390                    .touched_paths
391                    .first()
392                    .ok_or_else(|| anyhow!("copy missing source path"))?;
393                let dest = invocation
394                    .touched_paths
395                    .get(1)
396                    .ok_or_else(|| anyhow!("copy missing destination path"))?;
397                let recursive = invocation.command.contains("-r") || invocation.command.contains("-Recurse");
398                Self::copy_recursive(source.as_path(), dest.as_path(), recursive)?;
399                Ok(CommandOutput::success(String::new()))
400            }
401            CommandCategory::Move => {
402                let source = invocation
403                    .touched_paths
404                    .first()
405                    .ok_or_else(|| anyhow!("move missing source path"))?;
406                let dest = invocation
407                    .touched_paths
408                    .get(1)
409                    .ok_or_else(|| anyhow!("move missing destination path"))?;
410                Self::move_path(source.as_path(), dest.as_path())?;
411                Ok(CommandOutput::success(String::new()))
412            }
413            CommandCategory::Search => {
414                bail!("pure-rust executor does not implement search; enable std-process or provide a custom executor")
415            }
416            CommandCategory::ChangeDirectory | CommandCategory::PrintDirectory => {
417                Ok(CommandOutput::success(String::new()))
418            }
419        }
420    }
421}
422
423#[cfg(feature = "exec-events")]
424#[derive(Debug)]
425pub struct EventfulExecutor<E, T> {
426    inner: E,
427    emitter: Mutex<T>,
428    counter: AtomicU64,
429    id_prefix: String,
430}
431
432#[cfg(feature = "exec-events")]
433impl<E, T> EventfulExecutor<E, T>
434where
435    T: EventEmitter,
436{
437    pub fn new(inner: E, emitter: T) -> Self {
438        Self {
439            inner,
440            emitter: Mutex::new(emitter),
441            counter: AtomicU64::new(0),
442            id_prefix: "cmd-".to_string(),
443        }
444    }
445
446    pub fn with_id_prefix(inner: E, emitter: T, prefix: impl Into<String>) -> Self {
447        let mut executor = Self::new(inner, emitter);
448        executor.id_prefix = prefix.into();
449        executor
450    }
451
452    fn next_id(&self) -> String {
453        let value = self.counter.fetch_add(1, Ordering::Relaxed) + 1;
454        let mut id = String::with_capacity(self.id_prefix.len() + 10);
455        id.push_str(&self.id_prefix);
456        use std::fmt::Write;
457        let _ = write!(id, "{value}");
458        id
459    }
460
461    fn emit_event(&self, event: ThreadEvent) {
462        let mut emitter = self.emitter.lock();
463        EventEmitter::emit(&mut *emitter, &event);
464    }
465
466    fn command_details(
467        &self,
468        invocation: &CommandInvocation,
469        status: CommandExecutionStatus,
470        output: Option<&CommandOutput>,
471        error: Option<&anyhow::Error>,
472    ) -> CommandExecutionItem {
473        let aggregated_output = if let Some(output) = output {
474            aggregate_output(output)
475        } else if let Some(err) = error {
476            err.to_string()
477        } else {
478            String::new()
479        };
480
481        CommandExecutionItem {
482            command: invocation.command.clone(),
483            arguments: None,
484            aggregated_output,
485            exit_code: output.and_then(|out| out.status.code()),
486            status,
487        }
488    }
489}
490
491#[cfg(feature = "exec-events")]
492impl<E, T> CommandExecutor for EventfulExecutor<E, T>
493where
494    E: CommandExecutor,
495    T: EventEmitter + Send,
496{
497    fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
498        let item_id = self.next_id();
499        let starting_item = ThreadItem {
500            id: item_id.clone(),
501            details: ThreadItemDetails::CommandExecution(Box::new(self.command_details(
502                invocation,
503                CommandExecutionStatus::InProgress,
504                None,
505                None,
506            ))),
507        };
508        self.emit_event(ThreadEvent::ItemStarted(ItemStartedEvent { item: starting_item }));
509
510        match self.inner.execute(invocation) {
511            Ok(output) => {
512                let status = if output.status.success() {
513                    CommandExecutionStatus::Completed
514                } else {
515                    CommandExecutionStatus::Failed
516                };
517
518                let completed_item = ThreadItem {
519                    id: item_id,
520                    details: ThreadItemDetails::CommandExecution(Box::new(self.command_details(
521                        invocation,
522                        status,
523                        Some(&output),
524                        None,
525                    ))),
526                };
527                self.emit_event(ThreadEvent::ItemCompleted(ItemCompletedEvent { item: completed_item }));
528                Ok(output)
529            }
530            Err(err) => {
531                let failure = ThreadItem {
532                    id: item_id,
533                    details: ThreadItemDetails::CommandExecution(Box::new(self.command_details(
534                        invocation,
535                        CommandExecutionStatus::Failed,
536                        None,
537                        Some(&err),
538                    ))),
539                };
540                self.emit_event(ThreadEvent::ItemCompleted(ItemCompletedEvent { item: failure }));
541                Err(err)
542            }
543        }
544    }
545}
546
547#[cfg(feature = "exec-events")]
548fn aggregate_output(output: &CommandOutput) -> String {
549    let mut combined = String::new();
550    if !output.stdout.trim().is_empty() {
551        combined.push_str(output.stdout.trim());
552    }
553    if !output.stderr.trim().is_empty() {
554        if !combined.is_empty() {
555            combined.push('\n');
556        }
557        combined.push_str(output.stderr.trim());
558    }
559    combined
560}
561
562#[cfg(all(test, feature = "serde-errors"))]
563mod serde_tests {
564    use super::{CommandForm, CommandInvocation, CommandOutput, CommandStatus};
565    use serde::Serialize;
566    use serde::de::DeserializeOwned;
567
568    /// Compile-time assertion that a type satisfies the `serde-errors`
569    /// contract; the oracle is that this module fails to compile when a derive
570    /// is missing, which is exactly how `--all-features` broke previously.
571    fn assert_serde<T: Serialize + DeserializeOwned>() {}
572
573    /// Every type reachable from a serialized payload must derive the same
574    /// features. `CommandInvocation` derives under `serde-errors`, so its
575    /// private `CommandForm` (and the output/status types) must as well, or
576    /// `cargo check --all-features` fails.
577    #[test]
578    fn serde_error_types_satisfy_serialize_and_deserialize() {
579        assert_serde::<CommandInvocation>();
580        assert_serde::<CommandForm>();
581        assert_serde::<CommandOutput>();
582        assert_serde::<CommandStatus>();
583    }
584}