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