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