tess/shell.rs
1//! "Drop TUI -> run shell command -> restore TUI" helper. Used by `!cmd` at
2//! runtime AND by lesskey's `!shell command` bindings (Task 3).
3
4use std::io::{self, Read, Write};
5use std::process::{Command, Stdio};
6
7use crate::terminal::restore_terminal_best_effort;
8
9/// Run a shell command with the user's `$SHELL` (falling back to `/bin/sh`).
10/// Tears down and rebuilds the terminal around the command.
11///
12/// On success returns `Ok(())` — the terminal is restored and the caller
13/// should redraw. On failure (shell not found, etc.) returns `Err(...)`;
14/// the terminal is still restored. The error message is safe to surface in
15/// the status line.
16pub fn run_shell_command(cmd_text: &str) -> io::Result<()> {
17 // Tear down the TUI. `restore_terminal_best_effort` is idempotent and
18 // does the same work the active TerminalGuard's Drop would: disable
19 // raw mode + LeaveAlternateScreen + Show cursor.
20 restore_terminal_best_effort();
21
22 // Print a separator so the user sees where command output begins.
23 let _ = writeln!(io::stderr(), "---");
24
25 // Resolve the shell.
26 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
27
28 // Spawn the child with inherited stdin/stdout/stderr.
29 let status_result = Command::new(&shell)
30 .arg("-c")
31 .arg(cmd_text)
32 .stdin(Stdio::inherit())
33 .stdout(Stdio::inherit())
34 .stderr(Stdio::inherit())
35 .status();
36
37 let _ = writeln!(io::stderr(), "[Press any key to continue]");
38 let _ = io::stderr().flush();
39
40 // Re-enable raw mode BEFORE reading the keystroke. In canonical
41 // (cooked) mode, read() would block until a newline; raw mode
42 // delivers single bytes immediately so any keypress unblocks.
43 // Both calls are best-effort: in test environments there is no real
44 // TTY and enable_raw_mode() returns ENXIO — that's fine, the caller's
45 // TerminalGuard will clean up anyway.
46 use crossterm::cursor::Hide;
47 use crossterm::terminal::{enable_raw_mode, EnterAlternateScreen};
48 let _ = enable_raw_mode();
49
50 // Read one byte from stdin. If stdin is closed or fails, proceed
51 // anyway — the user will see the terminal restore happen.
52 let mut buf = [0u8; 1];
53 let _ = io::stdin().read(&mut buf);
54
55 // NOW enter the alt-screen so the next frame draw paints over the
56 // shell-command output.
57 let _ = crossterm::execute!(io::stdout(), EnterAlternateScreen, Hide);
58
59 status_result.map(|_| ())
60}
61
62#[cfg(test)]
63mod tests {
64 use super::*;
65
66 // These tests exec real subprocesses and toggle terminal modes.
67 // They must run serial (the project uses --test-threads=1 across the
68 // board, so this is enforced at the cargo-test command level).
69
70 #[test]
71 fn run_shell_command_happy_path() {
72 let result = run_shell_command("true");
73 assert!(result.is_ok(), "expected Ok, got {:?}", result);
74 }
75
76 #[test]
77 fn run_shell_command_propagates_nonzero_exit_as_ok() {
78 // The helper returns Ok even when the child exits non-zero —
79 // it's the spawn result that matters, not the exit code.
80 let result = run_shell_command("false");
81 assert!(result.is_ok(), "expected Ok, got {:?}", result);
82 }
83
84 #[test]
85 fn run_shell_command_missing_executable_returns_err() {
86 let prev = std::env::var("SHELL").ok();
87 std::env::set_var("SHELL", "/this/path/does/not/exist/x9z");
88 let result = run_shell_command("true");
89 // Restore SHELL first so a failed assertion doesn't pollute later tests.
90 if let Some(p) = prev {
91 std::env::set_var("SHELL", p);
92 } else {
93 std::env::remove_var("SHELL");
94 }
95 assert!(result.is_err(), "expected Err for missing shell, got {:?}", result);
96 }
97}