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