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