返回 CodeWhale
shell_dispatcher.rs
根目录 / crates / tui / src / shell_dispatcher.rs
1 //! Shell abstraction layer for Codewhale.
2 //!
3 //! Detects the user's shell at startup and provides a single entry point for
4 //! all command execution. Codewhale never calls `Command::new("cmd")` (or
5 //! `"sh"`, `"pwsh"`, ...) directly — it asks the [`ShellDispatcher`] to build
6 //! a correctly configured [`std::process::Command`].
7 //!
8 //! ## Responsibilities
9 //!
10 //! 1. **Shell detection** — find the user's actual shell (PowerShell, pwsh,
11 //! bash via WSL / Git Bash, cmd.exe fallback on Windows, /bin/sh on Unix).
12 //! On Windows, prefer PowerShell 7 (`pwsh`) over Windows PowerShell 5.1.
13 //! 2. **Quoting correctness** — each shell's argument-passing convention is
14 //! respected so quoted strings survive the spawn boundary intact.
15 //! 3. **PowerShell safety** — non-interactive flags, temporary `.ps1` files
16 //! for multiline scripts, and explicit native `$LASTEXITCODE` capture.
17 //! 4. **Terminal state** — foreground shell execution saves and restores
18 //! crossterm raw-mode so the TUI input pipeline is not broken after a
19 //! child process exits (issue #1690).
20
21 use std::fs::OpenOptions;
22 use std::io::Write;
23 #[cfg(windows)]
24 use std::os::windows::process::CommandExt;
25 use std::path::Path;
26 use std::process::Command;
27 use std::sync::Mutex;
28
29 static LOG_MUTEX: Mutex<()> = Mutex::new(());
30
31 #[cfg(test)]
32 #[allow(dead_code)] // Direct integration-harness inclusion only needs the read barrier.
33 #[path = "test_env_lock.rs"]
34 pub(crate) mod test_env_lock;
35
36 // ---------------------------------------------------------------------------
37 // Shell kind
38 // ---------------------------------------------------------------------------
39
40 /// The concrete shell that the dispatcher will use.
41 #[allow(dead_code)]
42 #[derive(Debug, Clone, PartialEq, Eq)]
43 pub enum ShellKind {
44 /// PowerShell 7+ (`pwsh.exe`).
45 Pwsh,
46 /// Windows PowerShell 5.1 (`powershell.exe`).
47 WindowsPowerShell,
48 /// Command Prompt (`cmd.exe`).
49 Cmd,
50 /// Unix `/bin/sh` fallback.
51 Sh,
52 /// Bash — detected via `$SHELL` on WSL/Git Bash, or constructed explicitly.
53 Bash,
54 /// The exact shell executable selected by Unix `$SHELL`.
55 Custom { binary: String, flag: String },
56 }
57
58 impl ShellKind {
59 /// Binary name for the shell. Appends `.exe` on Windows where needed.
60 pub fn binary(&self) -> &str {
61 match self {
62 #[cfg(windows)]
63 ShellKind::Pwsh => "pwsh.exe",
64 #[cfg(not(windows))]
65 ShellKind::Pwsh => "pwsh",
66
67 #[cfg(windows)]
68 ShellKind::WindowsPowerShell => "powershell.exe",
69 #[cfg(not(windows))]
70 ShellKind::WindowsPowerShell => "powershell",
71
72 #[cfg(windows)]
73 ShellKind::Cmd => "cmd.exe",
74 #[cfg(not(windows))]
75 ShellKind::Cmd => "cmd",
76
77 #[cfg(windows)]
78 ShellKind::Sh => "sh",
79 #[cfg(not(windows))]
80 ShellKind::Sh => "/bin/sh",
81 ShellKind::Bash => "bash",
82 ShellKind::Custom { binary, .. } => binary,
83 }
84 }
85
86 /// Flag that tells the shell to execute the following argument as a
87 /// command string.
88 pub fn command_flag(&self) -> &str {
89 match self {
90 ShellKind::Pwsh | ShellKind::WindowsPowerShell => "-NoProfile",
91 ShellKind::Cmd => "/C",
92 ShellKind::Sh | ShellKind::Bash => "-c",
93 ShellKind::Custom { flag, .. } => flag,
94 }
95 }
96
97 /// Whether this shell needs an extra `-Command` flag after the profile
98 /// flag (PowerShell-specific). Only exercised by shell-flag unit tests.
99 #[cfg(test)]
100 pub fn needs_command_flag(&self) -> bool {
101 matches!(self, ShellKind::Pwsh | ShellKind::WindowsPowerShell)
102 }
103
104 /// Returns true when this is a PowerShell-family shell.
105 pub fn is_powershell(&self) -> bool {
106 match self {
107 ShellKind::Pwsh | ShellKind::WindowsPowerShell => true,
108 ShellKind::Custom { binary, .. } => Path::new(binary)
109 .file_name()
110 .and_then(|name| name.to_str())
111 .is_some_and(|name| {
112 let name = name.to_ascii_lowercase();
113 name.contains("pwsh") || name.contains("powershell")
114 }),
115 ShellKind::Cmd | ShellKind::Sh | ShellKind::Bash => false,
116 }
117 }
118 }
119
120 /// Multiline, nested-quote, or non-ASCII PowerShell scripts are safer as a
121 /// temporary `-File` script than as a single `-Command` string.
122 fn powershell_prefers_script_file(shell_command: &str) -> bool {
123 shell_command.contains('\n')
124 || shell_command.contains('\r')
125 || !shell_command.is_ascii()
126 || shell_command.matches('"').count() >= 4
127 || shell_command.contains("'''")
128 || shell_command.contains("@'")
129 || shell_command.contains("@\"")
130 }
131
132 /// Wrap a model/user PowerShell command so native program failures surface
133 /// through `$LASTEXITCODE` without using `Invoke-Expression`.
134 fn powershell_exit_aware_command(shell_command: &str) -> String {
135 // Keep simple expressions as-is; only wrap when the payload looks like it
136 // may invoke a native executable (contains a path or known separators).
137 if shell_command.trim().is_empty() {
138 return shell_command.to_string();
139 }
140 // The exit-code check goes on its own line: a trailing unquoted `#`
141 // comment in the payload would otherwise swallow a `;`-joined check to
142 // end-of-line and silently report success for failing native commands.
143 // `-Command` accepts embedded newlines inside one argv string.
144 format!(
145 "$ErrorActionPreference = 'Continue'; {shell_command}\nif ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) {{ exit $LASTEXITCODE }}"
146 )
147 }
148
149 /// Tail appended to every temp `-File` script: capture the native exit code,
150 /// remove the script itself (PowerShell reads the whole file before running,
151 /// so self-deletion is safe), then propagate the exit code.
152 const TEMP_PS1_TAIL: &str = concat!(
153 "$__codewhaleExit = if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } else { 0 }\n",
154 "Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force ",
155 "-ErrorAction SilentlyContinue\n",
156 "if ($__codewhaleExit -ne 0) { exit $__codewhaleExit }\n",
157 );
158
159 fn write_temp_ps1(shell_command: &str) -> std::io::Result<String> {
160 use std::io::Write;
161 let dir = std::env::temp_dir();
162 sweep_stale_temp_ps1(&dir);
163 let name = format!(
164 "codewhale-shell-{}-{}.ps1",
165 std::process::id(),
166 std::time::SystemTime::now()
167 .duration_since(std::time::UNIX_EPOCH)
168 .map(|d| d.as_nanos())
169 .unwrap_or(0)
170 );
171 let path = dir.join(name);
172 let mut file = std::fs::File::create(&path)?;
173 // UTF-8 with BOM helps Windows PowerShell 5.1 decode non-ASCII scripts.
174 file.write_all(&[0xEF, 0xBB, 0xBF])?;
175 file.write_all(shell_command.as_bytes())?;
176 if !shell_command.ends_with('\n') {
177 file.write_all(b"\n")?;
178 }
179 // Native exit-code propagation plus self-cleanup for the script form.
180 file.write_all(TEMP_PS1_TAIL.as_bytes())?;
181 Ok(path.to_string_lossy().into_owned())
182 }
183
184 /// Best-effort removal of leftover `codewhale-shell-*.ps1` scripts (for
185 /// example after a killed process, which skips the in-script self-delete).
186 /// Only files older than one hour are touched so a concurrently starting
187 /// invocation is never raced.
188 fn sweep_stale_temp_ps1(dir: &std::path::Path) {
189 const STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(60 * 60);
190 let Ok(entries) = std::fs::read_dir(dir) else {
191 return;
192 };
193 for entry in entries.flatten() {
194 let name = entry.file_name();
195 let Some(name) = name.to_str() else {
196 continue;
197 };
198 if !name.starts_with("codewhale-shell-") || !name.ends_with(".ps1") {
199 continue;
200 }
201 let stale = entry
202 .metadata()
203 .and_then(|meta| meta.modified())
204 .ok()
205 .and_then(|modified| modified.elapsed().ok())
206 .is_some_and(|age| age > STALE_AFTER);
207 if stale {
208 let _ = std::fs::remove_file(entry.path());
209 }
210 }
211 }
212
213 // ---------------------------------------------------------------------------
214 // Dispatcher
215 // ---------------------------------------------------------------------------
216
217 /// Central shell abstraction. Created once at startup via
218 /// [`ShellDispatcher::detect`] and then used everywhere a command needs to
219 /// be spawned.
220 #[derive(Debug, Clone)]
221 pub struct ShellDispatcher {
222 kind: ShellKind,
223 }
224
225 #[allow(dead_code)]
226 impl ShellDispatcher {
227 /// Detect the user's shell from the environment.
228 ///
229 /// ## Detection order (Windows)
230 ///
231 /// 1. `$env:SHELL` — WSL interop or Git Bash often set this.
232 /// 2. `pwsh.exe` found on `PATH` — PowerShell 7+.
233 /// 3. `powershell.exe` found on `PATH` — Windows PowerShell 5.1.
234 /// 4. `cmd.exe` — always available, last resort.
235 ///
236 /// ## Detection order (Unix)
237 ///
238 /// 1. `$SHELL` — preserve its actual executable via `Custom`; bare names
239 /// are resolved against the current `PATH` once at detection time.
240 /// 2. `/bin/sh` fallback.
241 pub fn detect() -> Self {
242 let kind = Self::detect_shell();
243 Self::log_startup(&kind);
244 ShellDispatcher { kind }
245 }
246
247 /// Log a shell execution line when `SHELL_DISPATCHER_LOG` is set.
248 pub fn log_exec(command: &str) {
249 if let Ok(path) = std::env::var("SHELL_DISPATCHER_LOG") {
250 let _ = Self::append_log_static(&path, command);
251 }
252 }
253
254 fn log_startup(kind: &ShellKind) {
255 let _lock = LOG_MUTEX.lock();
256 if let Ok(path) = std::env::var("SHELL_DISPATCHER_LOG") {
257 let init_line = format!(
258 "--- ShellDispatcher log started pid={} ---\n",
259 std::process::id()
260 );
261 let _ = Self::append_log(&path, &init_line);
262 let detect_line = format!("[{}] detect: {kind:?}\n", now_iso());
263 let _ = Self::append_log(&path, &detect_line);
264 }
265 }
266
267 fn append_log(path: &str, line: &str) -> std::io::Result<()> {
268 let mut file = OpenOptions::new()
269 .create(true)
270 .append(true)
271 .open(Path::new(path))?;
272 file.write_all(line.as_bytes())?;
273 file.flush()
274 }
275
276 fn append_log_static(path: &str, command: &str) -> std::io::Result<()> {
277 // Resolve kind outside the lock — `global_dispatcher()` may trigger
278 // `detect()` which calls `log_startup()` which also acquires the mutex.
279 let kind = global_dispatcher().kind();
280 let _lock = LOG_MUTEX.lock();
281 let line = format!("[{}] exec via {kind:?}: {command}\n", now_iso());
282 Self::append_log(path, &line)
283 }
284
285 /// The detected shell kind.
286 pub fn kind(&self) -> &ShellKind {
287 &self.kind
288 }
289
290 // -- Public builders --------------------------------------------------
291
292 /// Build a `std::process::Command` for the given shell command string.
293 pub fn build_command(&self, shell_command: &str) -> Command {
294 let (program, args) = self.build_command_parts(shell_command);
295 let mut cmd = Command::new(program);
296 if matches!(self.kind, ShellKind::Cmd) {
297 #[cfg(windows)]
298 {
299 // Preserve quotes for `cmd /C <payload>` (issue #1691).
300 if args.len() == 2 && args[0].eq_ignore_ascii_case("/C") {
301 cmd.raw_arg(&args[0]);
302 cmd.raw_arg(&args[1]);
303 return cmd;
304 }
305 }
306 }
307 cmd.args(args);
308 cmd
309 }
310
311 /// Build the program + args tuple. Useful when the caller needs to
312 /// inspect or modify the args before passing them to `Command`.
313 pub fn build_command_parts(&self, shell_command: &str) -> (String, Vec<String>) {
314 let program = self.kind.binary().to_string();
315 if self.kind.is_powershell() {
316 let mut args = vec![
317 "-NoLogo".to_string(),
318 "-NoProfile".to_string(),
319 "-NonInteractive".to_string(),
320 ];
321 if powershell_prefers_script_file(shell_command) {
322 // Complex multiline / heavily quoted scripts: write a temp
323 // .ps1 and invoke with -File so quoting stays structured.
324 match write_temp_ps1(shell_command) {
325 Ok(path) => {
326 args.push("-File".to_string());
327 args.push(path);
328 return (program, args);
329 }
330 Err(_) => {
331 // Fall through to -Command if the temp file cannot be
332 // created; execution still proceeds.
333 }
334 }
335 }
336 args.push("-Command".to_string());
337 args.push(powershell_exit_aware_command(shell_command));
338 return (program, args);
339 }
340 let args = if matches!(self.kind, ShellKind::Cmd) {
341 vec!["/C".to_string(), shell_command.to_string()]
342 } else {
343 vec![
344 self.kind.command_flag().to_string(),
345 shell_command.to_string(),
346 ]
347 };
348 (program, args)
349 }
350
351 /// Build a `Command` from separate program + args (bypasses the shell).
352 /// Used when the caller already has a resolved executable and argument
353 /// vector — e.g. `ExecEnv` from the sandbox.
354 #[cfg(test)]
355 pub fn build_direct(&self, program: &str, args: &[String]) -> Command {
356 let mut cmd = Command::new(program);
357 cmd.args(args);
358 cmd
359 }
360
361 /// Execute a foreground command with raw-mode save/restore.
362 ///
363 /// A scope guard ensures raw mode is restored even if the command fails
364 /// to spawn or returns early (review feedback, issue #1690).
365 pub fn run_foreground(
366 &self,
367 shell_command: &str,
368 cwd: &std::path::Path,
369 ) -> Result<String, anyhow::Error> {
370 use anyhow::Context;
371
372 // Log the execution
373 {
374 let _lock = LOG_MUTEX.lock();
375 if let Ok(path) = std::env::var("SHELL_DISPATCHER_LOG") {
376 let kind = self.kind();
377 let line = format!("[{}] exec via {kind:?}: {shell_command}\n", now_iso());
378 let _ = Self::append_log(&path, &line);
379 }
380 }
381
382 // Disable raw mode; guard restores it only if it was already enabled.
383 let raw_mode_was_enabled = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
384 if raw_mode_was_enabled {
385 let _ = crossterm::terminal::disable_raw_mode();
386 }
387 struct FgRawModeGuard {
388 restore: bool,
389 }
390 impl Drop for FgRawModeGuard {
391 fn drop(&mut self) {
392 if self.restore {
393 let _ = crossterm::terminal::enable_raw_mode();
394 }
395 }
396 }
397 let _guard = FgRawModeGuard {
398 restore: raw_mode_was_enabled,
399 };
400
401 let mut cmd = self.build_command(shell_command);
402 cmd.current_dir(cwd);
403
404 let output = cmd
405 .output()
406 .with_context(|| format!("failed to execute shell command: {shell_command}"))?;
407
408 if !output.status.success() {
409 let stderr = String::from_utf8_lossy(&output.stderr);
410 anyhow::bail!(
411 "shell command failed (status={}): {}",
412 output.status,
413 stderr.trim()
414 );
415 }
416
417 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
418 Ok(stdout)
419 }
420
421 // -- Detection --------------------------------------------------------
422
423 fn detect_shell() -> ShellKind {
424 #[cfg(test)]
425 {
426 test_env_lock::with_test_env_lock(Self::detect_shell_unlocked)
427 }
428 #[cfg(not(test))]
429 {
430 Self::detect_shell_unlocked()
431 }
432 }
433
434 fn detect_shell_unlocked() -> ShellKind {
435 #[cfg(windows)]
436 {
437 // 1. $env:SHELL — WSL interop or Git Bash often set this.
438 if let Ok(shell) = std::env::var("SHELL") {
439 let lower = shell.to_lowercase();
440 if lower.contains("bash") {
441 return ShellKind::Bash;
442 }
443 if lower.contains("pwsh") {
444 return ShellKind::Pwsh;
445 }
446 if lower.contains("powershell") {
447 return ShellKind::WindowsPowerShell;
448 }
449 }
450
451 if Self::find_exe("pwsh.exe") {
452 return ShellKind::Pwsh;
453 }
454 if Self::find_exe("powershell.exe") {
455 return ShellKind::WindowsPowerShell;
456 }
457 ShellKind::Cmd
458 }
459
460 #[cfg(not(windows))]
461 {
462 if let Ok(shell) = std::env::var("SHELL")
463 && let Some(kind) = Self::unix_shell_kind(&shell)
464 {
465 return kind;
466 }
467
468 ShellKind::Sh
469 }
470 }
471
472 #[cfg(not(windows))]
473 fn unix_shell_kind(shell: &str) -> Option<ShellKind> {
474 let shell = shell.trim();
475 if shell.is_empty() {
476 return None;
477 }
478 let path = Path::new(shell);
479 let binary = if path.is_absolute() || path.components().count() > 1 {
480 shell.to_string()
481 } else {
482 std::env::var_os("PATH")
483 .and_then(|path| {
484 std::env::split_paths(&path)
485 .map(|dir| dir.join(shell))
486 .find(|candidate| candidate.is_file())
487 })
488 .map_or_else(
489 || shell.to_string(),
490 |path| path.to_string_lossy().into_owned(),
491 )
492 };
493 Some(ShellKind::Custom {
494 binary,
495 flag: "-c".to_string(),
496 })
497 }
498
499 /// Check PATH first, then fall back to well-known install directories.
500 #[cfg(windows)]
501 fn find_exe(name: &str) -> bool {
502 if Self::binary_on_path(name) {
503 return true;
504 }
505 // Well-known install locations (order by preference).
506 let known_dirs: &[&str] = &[
507 r"C:\Program Files\PowerShell\7",
508 r"C:\Windows\System32\WindowsPowerShell\v1.0",
509 ];
510 known_dirs
511 .iter()
512 .any(|dir| std::path::Path::new(dir).join(name).is_file())
513 }
514
515 #[cfg(windows)]
516 fn binary_on_path(name: &str) -> bool {
517 std::env::var_os("PATH")
518 .map(|path| {
519 std::env::split_paths(&path).any(|dir| {
520 let candidate = dir.join(name);
521 candidate.is_file()
522 })
523 })
524 .unwrap_or(false)
525 }
526 }
527
528 // -- Helpers ---------------------------------------------------------------
529
530 fn now_iso() -> String {
531 chrono::Utc::now()
532 .format("%Y-%m-%dT%H:%M:%S%.3f")
533 .to_string()
534 }
535
536 /// Global dispatcher instance, detected once at startup.
537 ///
538 /// Any code path that needs to spawn a shell command can use
539 /// `global_dispatcher()` instead of threading the dispatcher through
540 /// every function signature.
541 pub fn global_dispatcher() -> &'static ShellDispatcher {
542 use std::sync::LazyLock;
543 static DISPATCHER: LazyLock<ShellDispatcher> = LazyLock::new(ShellDispatcher::detect);
544 &DISPATCHER
545 }
546
547 // ---------------------------------------------------------------------------
548 // Tests
549 // ---------------------------------------------------------------------------
550
551 #[cfg(test)]
552 mod tests {
553 use super::*;
554
555 #[test]
556 fn shell_kind_binary_names() {
557 #[cfg(windows)]
558 {
559 assert_eq!(ShellKind::Pwsh.binary(), "pwsh.exe");
560 assert_eq!(ShellKind::WindowsPowerShell.binary(), "powershell.exe");
561 assert_eq!(ShellKind::Cmd.binary(), "cmd.exe");
562 }
563 #[cfg(not(windows))]
564 {
565 assert_eq!(ShellKind::Pwsh.binary(), "pwsh");
566 assert_eq!(ShellKind::WindowsPowerShell.binary(), "powershell");
567 assert_eq!(ShellKind::Cmd.binary(), "cmd");
568 }
569 #[cfg(windows)]
570 assert_eq!(ShellKind::Sh.binary(), "sh");
571 #[cfg(not(windows))]
572 assert_eq!(ShellKind::Sh.binary(), "/bin/sh");
573 assert_eq!(ShellKind::Bash.binary(), "bash");
574 }
575
576 #[cfg(not(windows))]
577 #[test]
578 fn unix_shell_detection_preserves_absolute_executable_paths() {
579 let bash = ShellDispatcher::unix_shell_kind("/bin/bash").expect("bash shell");
580 assert_eq!(
581 bash,
582 ShellKind::Custom {
583 binary: "/bin/bash".to_string(),
584 flag: "-c".to_string(),
585 }
586 );
587
588 let pwsh =
589 ShellDispatcher::unix_shell_kind("/opt/homebrew/bin/pwsh").expect("PowerShell path");
590 assert!(pwsh.is_powershell());
591 assert_eq!(pwsh.binary(), "/opt/homebrew/bin/pwsh");
592
593 let dispatcher = ShellDispatcher {
594 kind: ShellDispatcher::unix_shell_kind("/bin/sh").expect("POSIX shell"),
595 };
596 let mut command = dispatcher.build_command("printf path-independent");
597 command.env_clear();
598 let output = command.output().expect("absolute shell must not need PATH");
599 assert!(output.status.success(), "{output:?}");
600 assert_eq!(output.stdout, b"path-independent");
601 }
602
603 #[test]
604 fn detect_returns_some_shell() {
605 let dispatcher = global_dispatcher();
606 let _kind = dispatcher.kind();
607 }
608
609 #[test]
610 fn powershell_build_command_includes_no_profile_and_command_flags() {
611 let dispatcher = ShellDispatcher {
612 kind: ShellKind::Pwsh,
613 };
614 let cmd = dispatcher.build_command("echo hello");
615 let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
616 assert!(args.contains(&"-NoLogo"));
617 assert!(args.contains(&"-NoProfile"));
618 assert!(args.contains(&"-NonInteractive"));
619 assert!(args.contains(&"-Command"));
620 assert!(
621 args.iter().any(|a| a.contains("echo hello")),
622 "command payload missing: {args:?}"
623 );
624 assert!(
625 args.iter().any(|a| a.contains("$LASTEXITCODE")),
626 "native exit-code capture missing: {args:?}"
627 );
628 }
629
630 #[test]
631 fn powershell_multiline_uses_temp_file_invocation() {
632 let dispatcher = ShellDispatcher {
633 kind: ShellKind::Pwsh,
634 };
635 let script = "Write-Output 'line1'\nWrite-Output 'line2'";
636 let (program, args) = dispatcher.build_command_parts(script);
637 assert!(program.contains("pwsh"));
638 assert!(args.iter().any(|a| a == "-File"), "{args:?}");
639 let path = args
640 .iter()
641 .find(|a| a.ends_with(".ps1"))
642 .unwrap_or_else(|| panic!("expected temp .ps1 path: {args:?}"));
643 // The script must clean up after itself and still propagate the
644 // native exit code — self-delete before the exit line, so a nonzero
645 // exit cannot skip the removal.
646 let contents = std::fs::read_to_string(path).expect("read temp script");
647 let remove_at = contents
648 .find("Remove-Item -LiteralPath $MyInvocation.MyCommand.Path")
649 .expect("self-delete line present");
650 let exit_at = contents
651 .find("if ($__codewhaleExit -ne 0) { exit $__codewhaleExit }")
652 .expect("exit propagation present");
653 assert!(remove_at < exit_at, "self-delete must precede exit");
654 // Cleanup temp script created by the builder (the test never runs it).
655 let _ = std::fs::remove_file(path);
656 }
657
658 #[test]
659 fn powershell_trailing_comment_cannot_swallow_exit_capture() {
660 // An unquoted `#` in a single-line payload comments to end-of-line;
661 // the appended $LASTEXITCODE check must live on its own line so a
662 // failing native command can never silently report success.
663 let dispatcher = ShellDispatcher {
664 kind: ShellKind::Pwsh,
665 };
666 let (_, args) = dispatcher.build_command_parts("git log --oneline -5 # recent");
667 let payload = args.last().expect("command payload");
668 assert!(payload.contains("# recent"), "{payload}");
669 assert!(
670 payload.contains("\nif ($null -ne $LASTEXITCODE"),
671 "exit-code capture must start on a fresh line: {payload}"
672 );
673 }
674
675 #[test]
676 fn stale_temp_ps1_scripts_are_swept() {
677 let dir = std::env::temp_dir();
678 let stale = dir.join("codewhale-shell-0-stale-test.ps1");
679 std::fs::write(&stale, "Write-Output 'stale'\n").expect("write stale script");
680 // Backdate the file beyond the sweep horizon.
681 let old = std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 60 * 60);
682 let file = std::fs::File::options()
683 .append(true)
684 .open(&stale)
685 .expect("open stale script");
686 file.set_modified(old).expect("backdate stale script");
687 drop(file);
688
689 sweep_stale_temp_ps1(&dir);
690 assert!(!stale.exists(), "stale script should be removed");
691 }
692
693 #[test]
694 fn cmd_build_command_uses_c_flag() {
695 let dispatcher = ShellDispatcher {
696 kind: ShellKind::Cmd,
697 };
698 let cmd = dispatcher.build_command("echo hello");
699 let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
700 assert!(args.contains(&"/C"));
701 assert!(args.contains(&"echo hello"));
702 }
703
704 #[test]
705 fn sh_build_command_uses_dash_c() {
706 let dispatcher = ShellDispatcher {
707 kind: ShellKind::Sh,
708 };
709 let cmd = dispatcher.build_command("echo hello");
710 let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
711 assert!(args.contains(&"-c"));
712 assert!(args.contains(&"echo hello"));
713 }
714
715 #[cfg(test)]
716 #[test]
717 fn build_direct_preserves_args() {
718 let dispatcher = ShellDispatcher {
719 kind: ShellKind::Cmd,
720 };
721 let args = vec!["-m".to_string(), "commit message".to_string()];
722 let cmd = dispatcher.build_direct("git", &args);
723 let cmd_args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
724 assert_eq!(cmd_args, vec!["-m", "commit message"]);
725 }
726
727 #[cfg(test)]
728 #[test]
729 fn powershell_flags_are_correct() {
730 assert!(ShellKind::Pwsh.needs_command_flag());
731 assert!(ShellKind::WindowsPowerShell.needs_command_flag());
732 assert!(!ShellKind::Cmd.needs_command_flag());
733 assert!(!ShellKind::Sh.needs_command_flag());
734 assert!(!ShellKind::Bash.needs_command_flag());
735 }
736
737 #[cfg(test)]
738 #[test]
739 fn is_powershell_detects_both_variants() {
740 assert!(ShellKind::Pwsh.is_powershell());
741 assert!(ShellKind::WindowsPowerShell.is_powershell());
742 assert!(!ShellKind::Cmd.is_powershell());
743 assert!(!ShellKind::Sh.is_powershell());
744 assert!(!ShellKind::Bash.is_powershell());
745 }
746
747 #[cfg(test)]
748 #[test]
749 fn build_command_quotes_spaces_for_cmd() {
750 let dispatcher = ShellDispatcher {
751 kind: ShellKind::Cmd,
752 };
753 let cmd = dispatcher.build_command("git commit -m \"msg with spaces\"");
754 let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
755 assert_eq!(args.len(), 2);
756 assert_eq!(args[0], "/C");
757 assert!(args[1].contains("msg with spaces"));
758 assert!(args[1].starts_with("git "));
759 }
760
761 #[cfg(test)]
762 #[test]
763 fn build_command_quotes_spaces_for_pwsh() {
764 let dispatcher = ShellDispatcher {
765 kind: ShellKind::Pwsh,
766 };
767 let cmd = dispatcher.build_command("git commit -m \"msg with spaces\"");
768 let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
769 assert!(args.contains(&"-NoLogo"));
770 assert!(args.contains(&"-NoProfile"));
771 assert!(args.contains(&"-NonInteractive"));
772 assert!(args.contains(&"-Command"));
773 assert!(
774 args.iter().any(|a| a.contains("msg with spaces")),
775 "quoted payload missing: {args:?}"
776 );
777 }
778
779 #[cfg(test)]
780 #[test]
781 fn build_direct_handles_empty_args() {
782 let dispatcher = ShellDispatcher {
783 kind: ShellKind::Sh,
784 };
785 let cmd = dispatcher.build_direct("echo", &[]);
786 let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
787 assert!(args.is_empty());
788 }
789
790 #[cfg(windows)]
791 #[test]
792 fn find_exe_finds_cmd_on_path() {
793 // cmd.exe is always on PATH on Windows.
794 assert!(ShellDispatcher::find_exe("cmd.exe"));
795 }
796
797 #[cfg(windows)]
798 #[test]
799 fn find_exe_rejects_nonexistent_binary() {
800 assert!(!ShellDispatcher::find_exe("nonexistent_xyz_12345.exe"));
801 }
802
803 #[cfg(windows)]
804 #[test]
805 fn find_exe_falls_back_to_known_dirs() {
806 // Verify the known-dirs fallback path actually exists on this system.
807 let ps_path = r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe";
808 if std::path::Path::new(ps_path).is_file() {
809 // The fallback directory exists — find_exe should locate it.
810 assert!(ShellDispatcher::find_exe("powershell.exe"));
811 } else {
812 eprintln!("Skipping: {ps_path} not present on this system");
813 }
814 }
815
816 #[test]
817 fn custom_shell_uses_provided_binary_and_flag() {
818 let kind = ShellKind::Custom {
819 binary: "/bin/zsh".to_string(),
820 flag: "-c".to_string(),
821 };
822 assert_eq!(kind.binary(), "/bin/zsh");
823 assert_eq!(kind.command_flag(), "-c");
824 }
825 }
826
826 lines RUST