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