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