vtcode_bash_runner/
executor.rs

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