1use crate::executor::{
2 CommandCategory, CommandExecutor, CommandInvocation, CommandOutput, ShellKind,
3};
4use crate::policy::CommandPolicy;
5use anyhow::{Context, Result, anyhow, bail};
6use path_clean::PathClean;
7use shell_escape::escape;
8use std::fs;
9use std::path::{Path, PathBuf};
10use vtcode_commons::WorkspacePaths;
11
12pub struct BashRunner<E, P> {
13 executor: E,
14 policy: P,
15 workspace_root: PathBuf,
16 working_dir: PathBuf,
17 shell_kind: ShellKind,
18}
19
20impl<E, P> BashRunner<E, P>
21where
22 E: CommandExecutor,
23 P: CommandPolicy,
24{
25 pub fn new(workspace_root: PathBuf, executor: E, policy: P) -> Result<Self> {
26 if !workspace_root.exists() {
27 bail!(
28 "workspace root `{}` does not exist",
29 workspace_root.display()
30 );
31 }
32
33 let canonical_root = workspace_root
34 .canonicalize()
35 .with_context(|| format!("failed to canonicalize `{}`", workspace_root.display()))?;
36
37 Ok(Self {
38 executor,
39 policy,
40 workspace_root: canonical_root.clone(),
41 working_dir: canonical_root,
42 shell_kind: default_shell_kind(),
43 })
44 }
45
46 pub fn from_workspace_paths<W>(paths: &W, executor: E, policy: P) -> Result<Self>
47 where
48 W: WorkspacePaths,
49 {
50 Self::new(paths.workspace_root().to_path_buf(), executor, policy)
51 }
52
53 pub fn workspace_root(&self) -> &Path {
54 &self.workspace_root
55 }
56
57 pub fn working_dir(&self) -> &Path {
58 &self.working_dir
59 }
60
61 pub fn shell_kind(&self) -> ShellKind {
62 self.shell_kind
63 }
64
65 pub fn cd(&mut self, path: &str) -> Result<()> {
66 let candidate = self.resolve_path(path);
67 if !candidate.exists() {
68 bail!("directory `{}` does not exist", candidate.display());
69 }
70 if !candidate.is_dir() {
71 bail!("path `{}` is not a directory", candidate.display());
72 }
73
74 let canonical = candidate
75 .canonicalize()
76 .with_context(|| format!("failed to canonicalize `{}`", candidate.display()))?;
77
78 self.ensure_within_workspace(&canonical)?;
79
80 let invocation = CommandInvocation::new(
81 self.shell_kind,
82 format!("cd {}", format_path(self.shell_kind, &canonical)),
83 CommandCategory::ChangeDirectory,
84 canonical.clone(),
85 )
86 .with_paths(vec![canonical.clone()]);
87
88 self.policy.check(&invocation)?;
89 self.working_dir = canonical;
90 Ok(())
91 }
92
93 pub fn ls(&self, path: Option<&str>, show_hidden: bool) -> Result<String> {
94 let target = path
95 .map(|p| self.resolve_existing_path(p))
96 .transpose()?
97 .unwrap_or_else(|| self.working_dir.clone());
98
99 let command = match self.shell_kind {
100 ShellKind::Unix => {
101 let flag = if show_hidden { "-la" } else { "-l" };
102 format!("ls {} {}", flag, format_path(self.shell_kind, &target))
103 }
104 ShellKind::Windows => {
105 let mut parts = vec!["Get-ChildItem".to_string()];
106 if show_hidden {
107 parts.push("-Force".to_string());
108 }
109 parts.push(format!("-Path {}", format_path(self.shell_kind, &target)));
110 join_command(parts)
111 }
112 };
113
114 let invocation = CommandInvocation::new(
115 self.shell_kind,
116 command,
117 CommandCategory::ListDirectory,
118 self.working_dir.clone(),
119 )
120 .with_paths(vec![target]);
121
122 let output = self.expect_success(invocation)?;
123 Ok(output.stdout)
124 }
125
126 pub fn pwd(&self) -> Result<String> {
127 let invocation = CommandInvocation::new(
128 self.shell_kind,
129 match self.shell_kind {
130 ShellKind::Unix => "pwd".to_string(),
131 ShellKind::Windows => "Get-Location".to_string(),
132 },
133 CommandCategory::PrintDirectory,
134 self.working_dir.clone(),
135 );
136 self.policy.check(&invocation)?;
137 Ok(self.working_dir.to_string_lossy().to_string())
138 }
139
140 pub fn mkdir(&self, path: &str, parents: bool) -> Result<()> {
141 let target = self.resolve_path(path);
142 self.ensure_mutation_target_within_workspace(&target)?;
143
144 let command = match self.shell_kind {
145 ShellKind::Unix => {
146 let mut parts = vec!["mkdir".to_string()];
147 if parents {
148 parts.push("-p".to_string());
149 }
150 parts.push(format_path(self.shell_kind, &target));
151 join_command(parts)
152 }
153 ShellKind::Windows => {
154 let mut parts = vec!["New-Item".to_string(), "-ItemType Directory".to_string()];
155 if parents {
156 parts.push("-Force".to_string());
157 }
158 parts.push(format!("-Path {}", format_path(self.shell_kind, &target)));
159 join_command(parts)
160 }
161 };
162
163 let invocation = CommandInvocation::new(
164 self.shell_kind,
165 command,
166 CommandCategory::CreateDirectory,
167 self.working_dir.clone(),
168 )
169 .with_paths(vec![target]);
170
171 self.expect_success(invocation).map(|_| ())
172 }
173
174 pub fn rm(&self, path: &str, recursive: bool, force: bool) -> Result<()> {
175 let target = self.resolve_path(path);
176 self.ensure_mutation_target_within_workspace(&target)?;
177
178 let command = match self.shell_kind {
179 ShellKind::Unix => {
180 let mut parts = vec!["rm".to_string()];
181 if recursive {
182 parts.push("-r".to_string());
183 }
184 if force {
185 parts.push("-f".to_string());
186 }
187 parts.push(format_path(self.shell_kind, &target));
188 join_command(parts)
189 }
190 ShellKind::Windows => {
191 let mut parts = vec!["Remove-Item".to_string()];
192 if recursive {
193 parts.push("-Recurse".to_string());
194 }
195 if force {
196 parts.push("-Force".to_string());
197 }
198 parts.push(format!("-Path {}", format_path(self.shell_kind, &target)));
199 join_command(parts)
200 }
201 };
202
203 let invocation = CommandInvocation::new(
204 self.shell_kind,
205 command,
206 CommandCategory::Remove,
207 self.working_dir.clone(),
208 )
209 .with_paths(vec![target]);
210
211 self.expect_success(invocation).map(|_| ())
212 }
213
214 pub fn cp(&self, source: &str, dest: &str, recursive: bool) -> Result<()> {
215 let source_path = self.resolve_existing_path(source)?;
216 let dest_path = self.resolve_path(dest);
217 self.ensure_mutation_target_within_workspace(&dest_path)?;
218
219 let command = match self.shell_kind {
220 ShellKind::Unix => {
221 let mut parts = vec!["cp".to_string()];
222 if recursive {
223 parts.push("-r".to_string());
224 }
225 parts.push(format_path(self.shell_kind, &source_path));
226 parts.push(format_path(self.shell_kind, &dest_path));
227 join_command(parts)
228 }
229 ShellKind::Windows => {
230 let mut parts = vec![
231 "Copy-Item".to_string(),
232 format!("-Path {}", format_path(self.shell_kind, &source_path)),
233 format!("-Destination {}", format_path(self.shell_kind, &dest_path)),
234 ];
235 if recursive {
236 parts.push("-Recurse".to_string());
237 }
238 join_command(parts)
239 }
240 };
241
242 let invocation = CommandInvocation::new(
243 self.shell_kind,
244 command,
245 CommandCategory::Copy,
246 self.working_dir.clone(),
247 )
248 .with_paths(vec![source_path, dest_path]);
249
250 self.expect_success(invocation).map(|_| ())
251 }
252
253 pub fn mv(&self, source: &str, dest: &str) -> Result<()> {
254 let source_path = self.resolve_existing_path(source)?;
255 let dest_path = self.resolve_path(dest);
256 self.ensure_mutation_target_within_workspace(&dest_path)?;
257
258 let command = match self.shell_kind {
259 ShellKind::Unix => format!(
260 "mv {} {}",
261 format_path(self.shell_kind, &source_path),
262 format_path(self.shell_kind, &dest_path)
263 ),
264 ShellKind::Windows => join_command(vec![
265 "Move-Item".to_string(),
266 format!("-Path {}", format_path(self.shell_kind, &source_path)),
267 format!("-Destination {}", format_path(self.shell_kind, &dest_path)),
268 ]),
269 };
270
271 let invocation = CommandInvocation::new(
272 self.shell_kind,
273 command,
274 CommandCategory::Move,
275 self.working_dir.clone(),
276 )
277 .with_paths(vec![source_path, dest_path]);
278
279 self.expect_success(invocation).map(|_| ())
280 }
281
282 pub fn grep(&self, pattern: &str, path: Option<&str>, recursive: bool) -> Result<String> {
283 let target = path
284 .map(|p| self.resolve_existing_path(p))
285 .transpose()?
286 .unwrap_or_else(|| self.working_dir.clone());
287
288 let command = match self.shell_kind {
289 ShellKind::Unix => {
290 let mut parts = vec!["grep".to_string(), "-n".to_string()];
291 if recursive {
292 parts.push("-r".to_string());
293 }
294 parts.push(format_pattern(self.shell_kind, pattern));
295 parts.push(format_path(self.shell_kind, &target));
296 join_command(parts)
297 }
298 ShellKind::Windows => {
299 let mut parts = vec![
300 "Select-String".to_string(),
301 format!("-Pattern {}", format_pattern(self.shell_kind, pattern)),
302 format!("-Path {}", format_path(self.shell_kind, &target)),
303 "-SimpleMatch".to_string(),
304 ];
305 if recursive {
306 parts.push("-Recurse".to_string());
307 }
308 join_command(parts)
309 }
310 };
311
312 let invocation = CommandInvocation::new(
313 self.shell_kind,
314 command,
315 CommandCategory::Search,
316 self.working_dir.clone(),
317 )
318 .with_paths(vec![target]);
319
320 let output = self.execute_invocation(invocation)?;
321 if output.status.success() {
322 return Ok(output.stdout);
323 }
324
325 if output.stdout.trim().is_empty() && output.stderr.trim().is_empty() {
326 Ok(String::new())
327 } else {
328 Err(anyhow!(
329 "search command failed: {}",
330 if output.stderr.trim().is_empty() {
331 output.stdout
332 } else {
333 output.stderr
334 }
335 ))
336 }
337 }
338
339 fn execute_invocation(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
340 self.policy.check(&invocation)?;
341 self.executor.execute(&invocation)
342 }
343
344 fn expect_success(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
345 let output = self.execute_invocation(invocation.clone())?;
346 if output.status.success() {
347 Ok(output)
348 } else {
349 Err(anyhow!(
350 "command `{}` failed: {}",
351 invocation.command,
352 if output.stderr.trim().is_empty() {
353 output.stdout
354 } else {
355 output.stderr
356 }
357 ))
358 }
359 }
360
361 fn resolve_existing_path(&self, raw: &str) -> Result<PathBuf> {
362 let path = self.resolve_path(raw);
363 if !path.exists() {
364 bail!("path `{}` does not exist", path.display());
365 }
366
367 let canonical = path
368 .canonicalize()
369 .with_context(|| format!("failed to canonicalize `{}`", path.display()))?;
370
371 self.ensure_within_workspace(&canonical)?;
372 Ok(canonical)
373 }
374
375 fn resolve_path(&self, raw: &str) -> PathBuf {
376 let candidate = Path::new(raw);
377 let joined = if candidate.is_absolute() {
378 candidate.to_path_buf()
379 } else {
380 self.working_dir.join(candidate)
381 };
382 joined.clean()
383 }
384
385 fn ensure_mutation_target_within_workspace(&self, candidate: &Path) -> Result<()> {
386 if let Ok(metadata) = fs::symlink_metadata(candidate) {
387 if metadata.file_type().is_symlink() {
388 let canonical = candidate
389 .canonicalize()
390 .with_context(|| format!("failed to canonicalize `{}`", candidate.display()))?;
391 return self.ensure_within_workspace(&canonical);
392 }
393 }
394
395 if candidate.exists() {
396 let canonical = candidate
397 .canonicalize()
398 .with_context(|| format!("failed to canonicalize `{}`", candidate.display()))?;
399 self.ensure_within_workspace(&canonical)
400 } else {
401 let parent = self.canonicalize_existing_parent(candidate)?;
402 self.ensure_within_workspace(&parent)
403 }
404 }
405
406 fn canonicalize_existing_parent(&self, candidate: &Path) -> Result<PathBuf> {
407 let mut current = candidate.parent();
408 while let Some(path) = current {
409 if path.exists() {
410 return path
411 .canonicalize()
412 .with_context(|| format!("failed to canonicalize `{}`", path.display()));
413 }
414 current = path.parent();
415 }
416
417 Ok(self.working_dir.clone())
418 }
419
420 fn ensure_within_workspace(&self, candidate: &Path) -> Result<()> {
421 if !candidate.starts_with(&self.workspace_root) {
422 bail!(
423 "path `{}` escapes workspace root `{}`",
424 candidate.display(),
425 self.workspace_root.display()
426 );
427 }
428 Ok(())
429 }
430}
431
432fn default_shell_kind() -> ShellKind {
433 if cfg!(windows) {
434 ShellKind::Windows
435 } else {
436 ShellKind::Unix
437 }
438}
439
440fn join_command(parts: Vec<String>) -> String {
441 parts
442 .into_iter()
443 .filter(|part| !part.is_empty())
444 .collect::<Vec<_>>()
445 .join(" ")
446}
447
448fn format_path(shell: ShellKind, path: &Path) -> String {
449 match shell {
450 ShellKind::Unix => escape(path.to_string_lossy()).to_string(),
451 ShellKind::Windows => format!("'{}'", path.to_string_lossy().replace('\'', "''")),
452 }
453}
454
455fn format_pattern(shell: ShellKind, pattern: &str) -> String {
456 match shell {
457 ShellKind::Unix => escape(pattern.into()).to_string(),
458 ShellKind::Windows => format!("'{}'", pattern.replace('\'', "''")),
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use crate::executor::{CommandInvocation, CommandOutput, CommandStatus};
466 use crate::policy::AllowAllPolicy;
467 use assert_fs::TempDir;
468 use std::sync::{Arc, Mutex};
469
470 #[derive(Clone, Default)]
471 struct RecordingExecutor {
472 invocations: Arc<Mutex<Vec<CommandInvocation>>>,
473 }
474
475 impl CommandExecutor for RecordingExecutor {
476 fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
477 self.invocations.lock().unwrap().push(invocation.clone());
478 Ok(CommandOutput {
479 status: CommandStatus::new(true, Some(0)),
480 stdout: String::new(),
481 stderr: String::new(),
482 })
483 }
484 }
485
486 #[test]
487 fn cd_updates_working_directory() {
488 let dir = TempDir::new().unwrap();
489 let nested = dir.path().join("nested");
490 std::fs::create_dir(&nested).unwrap();
491 let runner = BashRunner::new(
492 dir.path().to_path_buf(),
493 RecordingExecutor::default(),
494 AllowAllPolicy,
495 );
496 let mut runner = runner.unwrap();
497 runner.cd("nested").unwrap();
498 assert_eq!(runner.working_dir(), nested);
499 }
500
501 #[test]
502 fn mkdir_records_invocation() {
503 let dir = TempDir::new().unwrap();
504 let executor = RecordingExecutor::default();
505 let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy);
506 runner.unwrap().mkdir("new_dir", true).unwrap();
507 let invocations = executor.invocations.lock().unwrap();
508 assert_eq!(invocations.len(), 1);
509 assert_eq!(invocations[0].category, CommandCategory::CreateDirectory);
510 }
511}