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    shell: ShellKind,
53    pub command: String,
54    pub(crate) category: CommandCategory,
55    pub(crate) working_dir: PathBuf,
56    pub(crate) touched_paths: Vec<PathBuf>,
57}
58
59impl CommandInvocation {
60    pub(crate) 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(crate) 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(crate) fn new(success: bool, code: Option<i32>) -> Self {
86        Self { success, code }
87    }
88
89    pub(crate) fn success(&self) -> bool {
90        self.success
91    }
92
93    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(crate) status: CommandStatus,
115    pub(crate) stdout: String,
116    pub(crate) stderr: String,
117}
118
119impl CommandOutput {
120    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    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(output.stdout).unwrap_or_else(|e| e.to_string()),
198            stderr: String::from_utf8(output.stderr).unwrap_or_else(|e| e.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        let mut id = String::with_capacity(self.id_prefix.len() + 10);
426        id.push_str(&self.id_prefix);
427        use std::fmt::Write;
428        let _ = write!(id, "{value}");
429        id
430    }
431
432    fn emit_event(&self, event: ThreadEvent) {
433        if let Ok(mut emitter) = self.emitter.lock() {
434            EventEmitter::emit(&mut *emitter, &event);
435        }
436    }
437
438    fn command_details(
439        &self,
440        invocation: &CommandInvocation,
441        status: CommandExecutionStatus,
442        output: Option<&CommandOutput>,
443        error: Option<&anyhow::Error>,
444    ) -> CommandExecutionItem {
445        let aggregated_output = if let Some(output) = output {
446            aggregate_output(output)
447        } else if let Some(err) = error {
448            err.to_string()
449        } else {
450            String::new()
451        };
452
453        CommandExecutionItem {
454            command: invocation.command.clone(),
455            arguments: None,
456            aggregated_output,
457            exit_code: output.and_then(|out| out.status.code()),
458            status,
459        }
460    }
461}
462
463#[cfg(feature = "exec-events")]
464impl<E, T> CommandExecutor for EventfulExecutor<E, T>
465where
466    E: CommandExecutor,
467    T: EventEmitter + Send,
468{
469    fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
470        let item_id = self.next_id();
471        let starting_item = ThreadItem {
472            id: item_id.clone(),
473            details: ThreadItemDetails::CommandExecution(Box::new(self.command_details(
474                invocation,
475                CommandExecutionStatus::InProgress,
476                None,
477                None,
478            ))),
479        };
480        self.emit_event(ThreadEvent::ItemStarted(ItemStartedEvent { item: starting_item }));
481
482        match self.inner.execute(invocation) {
483            Ok(output) => {
484                let status = if output.status.success() {
485                    CommandExecutionStatus::Completed
486                } else {
487                    CommandExecutionStatus::Failed
488                };
489
490                let completed_item = ThreadItem {
491                    id: item_id,
492                    details: ThreadItemDetails::CommandExecution(Box::new(self.command_details(
493                        invocation,
494                        status,
495                        Some(&output),
496                        None,
497                    ))),
498                };
499                self.emit_event(ThreadEvent::ItemCompleted(ItemCompletedEvent { item: completed_item }));
500                Ok(output)
501            }
502            Err(err) => {
503                let failure = ThreadItem {
504                    id: item_id,
505                    details: ThreadItemDetails::CommandExecution(Box::new(self.command_details(
506                        invocation,
507                        CommandExecutionStatus::Failed,
508                        None,
509                        Some(&err),
510                    ))),
511                };
512                self.emit_event(ThreadEvent::ItemCompleted(ItemCompletedEvent { item: failure }));
513                Err(err)
514            }
515        }
516    }
517}
518
519#[cfg(feature = "exec-events")]
520fn aggregate_output(output: &CommandOutput) -> String {
521    let mut combined = String::new();
522    if !output.stdout.trim().is_empty() {
523        combined.push_str(output.stdout.trim());
524    }
525    if !output.stderr.trim().is_empty() {
526        if !combined.is_empty() {
527            combined.push('\n');
528        }
529        combined.push_str(output.stderr.trim());
530    }
531    combined
532}