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