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