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