Skip to main content

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 that inherit the process `SHELL`
67    // env var, and one of them mutates `SHELL`. They share the crate-wide
68    // env lock so the mutator can't swap `SHELL` out from under the readers
69    // when cargo runs tests in parallel.
70
71    #[test]
72    fn run_shell_command_happy_path() {
73        let _guard = crate::test_env::lock();
74        let result = run_shell_command("true");
75        assert!(result.is_ok(), "expected Ok, got {:?}", result);
76    }
77
78    #[test]
79    fn run_shell_command_propagates_nonzero_exit_as_ok() {
80        // The helper returns Ok even when the child exits non-zero —
81        // it's the spawn result that matters, not the exit code.
82        let _guard = crate::test_env::lock();
83        let result = run_shell_command("false");
84        assert!(result.is_ok(), "expected Ok, got {:?}", result);
85    }
86
87    #[test]
88    fn run_shell_command_missing_executable_returns_err() {
89        let _guard = crate::test_env::lock();
90        let prev = std::env::var("SHELL").ok();
91        std::env::set_var("SHELL", "/this/path/does/not/exist/x9z");
92        let result = run_shell_command("true");
93        // Restore SHELL first so a failed assertion doesn't pollute later tests.
94        if let Some(p) = prev {
95            std::env::set_var("SHELL", p);
96        } else {
97            std::env::remove_var("SHELL");
98        }
99        assert!(result.is_err(), "expected Err for missing shell, got {:?}", result);
100    }
101}