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#[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#[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#[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#[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#[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
155pub trait CommandExecutor: Send + Sync {
157 fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput>;
158}
159
160#[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}