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