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