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