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