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#[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#[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#[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#[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#[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
146pub trait CommandExecutor: Send + Sync {
148 fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput>;
149}
150
151#[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}