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