Skip to main content

trail/actions/
shell_exec.rs

1//! Suspend/resume subprocess execution.
2//!
3//! Implements the suspend/resume sequence from the architecture doc §3:
4//!   1. Leave alternate screen and restore cooked terminal mode.
5//!   2. Spawn the subprocess with inherited stdio, relative to `cwd`.
6//!   3. Wait for the subprocess to exit.
7//!   4. Re-enter raw mode and the alternate screen.
8//!   5. Signal the caller that a full redraw is required (the subprocess
9//!      may have overwritten the terminal).
10//!
11//! The same path serves both "open in configured editor" and Command Mode's
12//! `!<shell command>` — the only difference is the argv passed in.
13//!
14//! For the command forms that need an interpreter (`!<command>` and `:git`),
15//! [`shell_argv`] builds that argv from the `[general] shell` config key, so
16//! which shell runs them is the user's choice rather than a compile-time
17//! constant.
18
19use std::io::{stdout, IsTerminal, Write};
20use std::path::Path;
21use std::process::Command;
22use std::time::{Duration, Instant};
23
24use crossterm::event::{self, Event};
25use crossterm::style::{Color, Print, ResetColor, SetForegroundColor};
26use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
27use crossterm::{execute, queue};
28use thiserror::Error;
29
30use super::run_report::{BlockAnswer, RerunKey, ResultBlock, RunReport, RunResult};
31
32/// Interpreter used for `!<command>` and `:git` when `[general] shell` is blank.
33///
34/// These are the values Trail hardcoded before the key existed, so a config
35/// that does not mention `shell` keeps behaving exactly as it did. The final
36/// element of the spawned argv is always the command string, which is why the
37/// flag here is the one that means "read the next argument as a script".
38#[cfg(windows)]
39pub const DEFAULT_SHELL_ARGV: &[&str] = &["cmd.exe", "/C"];
40/// Interpreter used for `!<command>` and `:git` when `[general] shell` is blank.
41///
42/// `sh` rather than `$SHELL`: the command string is POSIX shell syntax, and a
43/// login shell such as fish would interpret parts of it differently.
44#[cfg(not(windows))]
45pub const DEFAULT_SHELL_ARGV: &[&str] = &["sh", "-c"];
46
47/// When Trail prints the result block and waits for a key before reclaiming the
48/// screen from a command.
49///
50/// Without a pause, `run_external` re-enters the alternate screen the instant the
51/// child exits, so the output of `!ls` is painted and erased in the same breath.
52/// The editor is the reason this is a choice rather than always on: nobody wants
53/// to press Enter after closing their editor.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum ShellPause {
56    /// Always wait — the default for `!<command>` and `:git`.
57    Always,
58    /// Wait only when the command exited non-zero, or could not be run at all.
59    OnError,
60    /// Never wait. Used for the editor and for OS-handler opens, whatever the
61    /// configured policy is, since those draw their own screens.
62    Never,
63}
64
65impl ShellPause {
66    /// Parses a `[general] shell_pause` value.
67    ///
68    /// Returns `None` for an unrecognised name so the config layer can report it
69    /// with the key that carried it.
70    pub fn parse(value: &str) -> Option<ShellPause> {
71        match value.trim().to_ascii_lowercase().as_str() {
72            "always" => Some(ShellPause::Always),
73            "on_error" | "on-error" => Some(ShellPause::OnError),
74            "never" | "off" => Some(ShellPause::Never),
75            _ => None,
76        }
77    }
78
79    /// Whether a command that finished with `success` should hold the screen.
80    fn applies(self, success: bool) -> bool {
81        match self {
82            ShellPause::Always => true,
83            ShellPause::OnError => !success,
84            ShellPause::Never => false,
85        }
86    }
87}
88
89/// Errors that can arise while running an external process.
90#[derive(Debug, Error)]
91pub enum ShellExecError {
92    /// Terminal state manipulation failed.
93    #[error("terminal error: {0}")]
94    Terminal(#[source] std::io::Error),
95}
96
97/// Leaves the alternate screen, runs `argv` in `cwd`, then restores the TUI.
98///
99/// The suspend/resume cycle is:
100///   1. `LeaveAlternateScreen` + `disable_raw_mode` — hand the terminal back
101///      to the spawned process so it can draw its own UI (e.g., a text editor).
102///   2. Spawn `argv[0]` with `argv[1..]` as arguments, with `cwd` as the
103///      working directory and all three stdio streams inherited from the
104///      current process.
105///   3. Block until the child exits.
106///   4. `enable_raw_mode` + `EnterAlternateScreen` — reclaim the terminal.
107///
108/// **Redraw**: callers must force a full redraw after this returns, since the
109/// child process may have written to the terminal. See `main.rs`'s handling of
110/// [`crate::actions::Action::RunExternal`].
111///
112/// **Pause**: `pause` decides whether step 4 waits for an answer first. A
113/// command's output lives on the normal screen, which step 4 replaces, so without
114/// a pause anything `!ls` printed is gone before it can be read. The wait prints
115/// the result block ([`ResultBlock`]), and `r` at the block runs the command
116/// again, so steps 2 and 3 may repeat. The alternate screen is inactive
117/// throughout, which is the one time writing to stdout is legitimate.
118///
119/// `label` is the command as typed, for the block and the report; `None` for what
120/// Trail starts on the user's behalf.
121///
122/// **Must be called from the key handler**, as it is: the block reads keys
123/// through crossterm directly, and `EventStream` holds crossterm's input lock
124/// from a background thread while it waits. Once the stream has just delivered a
125/// key that thread has finished, so the lock is free until the stream is next
126/// polled.
127///
128/// # Errors
129///
130/// Returns [`ShellExecError`] if the terminal could not be handed over. A command
131/// that fails, or cannot be started, is not an error here: that is the report's
132/// to say.
133pub fn run_external(
134    argv: &[&str],
135    cwd: &Path,
136    pause: ShellPause,
137    label: Option<&str>,
138) -> Result<RunReport, ShellExecError> {
139    if argv.is_empty() {
140        return Ok(RunReport {
141            label: label.map(str::to_owned),
142            program: String::new(),
143            result: RunResult::CouldNotRun("nothing to run".to_owned()),
144            elapsed: Duration::ZERO,
145        });
146    }
147
148    // ── Step 1: leave alternate screen / cooked mode ──────────────────────────
149    // Best-effort: if either of these fails we still attempt to restore later.
150    execute!(stdout(), LeaveAlternateScreen).map_err(ShellExecError::Terminal)?;
151    terminal::disable_raw_mode().map_err(ShellExecError::Terminal)?;
152
153    // ── Steps 2 & 3: run it, for as long as the block is answered with `r` ────
154    let mut rerun_key = RerunKey::default();
155    let report = run_until_answered(
156        pause,
157        || run_once(argv, cwd, label),
158        |report| ask_at_the_block(report, cwd, &mut rerun_key),
159    );
160
161    // ── Step 4: restore raw mode and alternate screen ─────────────────────────
162    // Always restore regardless of how the command ended, so the TUI isn't left
163    // in a broken state. Log restoration failures rather than losing the report.
164    if let Err(e) = terminal::enable_raw_mode() {
165        tracing::error!("failed to re-enable raw mode after subprocess: {e}");
166    }
167    if let Err(e) = execute!(stdout(), EnterAlternateScreen) {
168        tracing::error!("failed to re-enter alternate screen after subprocess: {e}");
169    }
170
171    Ok(report)
172}
173
174/// Runs the command until something other than `r` ends the loop, returning the
175/// report of the last run.
176///
177/// `shell_pause` decides whether there is a block to answer: with `on_error`, a
178/// command that succeeds goes straight back to Trail with no chance to press `r`.
179/// A command that could not be spawned counts as a failure, so `on_error` pauses
180/// for it too.
181///
182/// Both halves are passed in so this decision can be tested without a terminal.
183fn run_until_answered(
184    pause: ShellPause,
185    mut run: impl FnMut() -> RunReport,
186    mut ask: impl FnMut(&RunReport) -> BlockAnswer,
187) -> RunReport {
188    loop {
189        let report = run();
190        if !pause.applies(report.succeeded()) || ask(&report) != BlockAnswer::RunAgain {
191            return report;
192        }
193    }
194}
195
196/// Spawns `argv` in `cwd` with inherited stdio and waits for it to exit.
197fn run_once(argv: &[&str], cwd: &Path, label: Option<&str>) -> RunReport {
198    let started = Instant::now();
199    let result = match Command::new(argv[0])
200        .args(&argv[1..])
201        .current_dir(cwd)
202        .spawn()
203        .and_then(|mut child| child.wait())
204    {
205        Ok(status) if status.success() => RunResult::Success,
206        Ok(status) => RunResult::Failed {
207            code: status.code(),
208        },
209        Err(e) => RunResult::CouldNotRun(e.to_string()),
210    };
211    RunReport {
212        label: label.map(str::to_owned),
213        program: argv[0].to_owned(),
214        result,
215        elapsed: started.elapsed(),
216    }
217}
218
219/// Prints the result block and waits for it to be answered.
220///
221/// Called only between the child exiting and the alternate screen coming back,
222/// so stdout is the user's normal screen — the one window where Trail may write
223/// to it.
224///
225/// Reading one key needs raw mode, which is switched off again before this
226/// returns, because `r` hands the terminal straight to the command. What was
227/// typed while the command ran is thrown away first: otherwise an `r` typed
228/// during a long command would be read the moment it finished and repeat
229/// something like `!del old.log`, and an early Enter would dismiss the result
230/// before it was seen. `rerun_key` still sees those keys, so an `r` held through
231/// the command is known to be down when the block appears.
232///
233/// A closed or redirected stdin means "do not wait", and so does any failure to
234/// read: Trail must not refuse to come back because it could not ask a question.
235fn ask_at_the_block(report: &RunReport, cwd: &Path, rerun_key: &mut RerunKey) -> BlockAnswer {
236    let interactive = std::io::stdin().is_terminal() && terminal::enable_raw_mode().is_ok();
237    if interactive {
238        discard_pending_input(rerun_key);
239    }
240
241    let columns = terminal::size().map_or(80, |(columns, _)| columns);
242    let block = ResultBlock::new(report, cwd, columns);
243    if let Err(e) = print_block(&block) {
244        tracing::debug!("could not print the result block: {e}");
245    }
246
247    let answer = if interactive {
248        wait_for_answer(rerun_key)
249    } else {
250        BlockAnswer::Return
251    };
252    if interactive {
253        if let Err(e) = terminal::disable_raw_mode() {
254            tracing::debug!("could not leave raw mode at the result block: {e}");
255        }
256    }
257    // The blank line that keeps the next command's output off the closing rule.
258    let mut out = stdout();
259    let _ = out.write_all(b"\r\n");
260    let _ = out.flush();
261    answer
262}
263
264/// Drops every input event already waiting, showing the keys to `rerun_key`.
265fn discard_pending_input(rerun_key: &mut RerunKey) {
266    while matches!(event::poll(Duration::ZERO), Ok(true)) {
267        match event::read() {
268            Ok(Event::Key(key)) => {
269                rerun_key.answer(&key, Instant::now());
270            }
271            Ok(_) => {}
272            Err(_) => return,
273        }
274    }
275}
276
277/// Blocks until a key answers the block; a failure to read answers "go back".
278fn wait_for_answer(rerun_key: &mut RerunKey) -> BlockAnswer {
279    loop {
280        match event::read() {
281            Ok(Event::Key(key)) => match rerun_key.answer(&key, Instant::now()) {
282                BlockAnswer::Ignore => {}
283                answer => return answer,
284            },
285            Ok(_) => {}
286            Err(e) => {
287                tracing::debug!("could not read a key at the result block: {e}");
288                return BlockAnswer::Return;
289            }
290        }
291    }
292}
293
294/// Writes the block, with the cursor left on the line below it.
295///
296/// Lines end in `\r\n` because raw mode may be on, where `\n` alone does not
297/// return to the first column. The leading one ends the command's output, whether
298/// or not it ended in a newline of its own.
299fn print_block(block: &ResultBlock) -> std::io::Result<()> {
300    let colour = if block.succeeded {
301        Color::Green
302    } else {
303        Color::Red
304    };
305    let mut out = stdout();
306    queue!(
307        out,
308        Print("\r\n"),
309        Print(&block.header_start),
310        SetForegroundColor(colour),
311        Print(&block.outcome),
312        ResetColor,
313        Print(&block.header_end),
314        Print("\r\n"),
315    )?;
316    for line in block.lines().iter().skip(1) {
317        queue!(out, Print(line), Print("\r\n"))?;
318    }
319    out.flush()
320}
321
322/// Splits a `[general] shell` spec into the argv prefix it stands for.
323///
324/// The spec is a program name followed by the flags that make that program read
325/// a command string, for example `pwsh -NoProfile -Command`. Tokens are
326/// separated by whitespace; a double-quoted run is one token, which is how a
327/// program path containing spaces is written. Backslashes are literal, so a
328/// Windows path needs no doubling here — though TOML itself still requires a
329/// literal string (`'...'`) or doubled backslashes to deliver one.
330///
331/// Returns `None` when the spec cannot be used as an argv prefix: it is blank,
332/// it leaves a quote unterminated, or its first token — the program to spawn —
333/// is empty. [`shell_argv`] reads `None` as "fall back to
334/// [`DEFAULT_SHELL_ARGV`]", and `TrailConfig::validate` rejects a non-blank
335/// spec that returns `None` so the user hears about a typo at startup.
336///
337/// ```
338/// use trail::actions::shell_exec::split_shell_spec;
339///
340/// assert_eq!(
341///     split_shell_spec("pwsh -NoProfile -Command"),
342///     Some(vec!["pwsh".to_owned(), "-NoProfile".to_owned(), "-Command".to_owned()])
343/// );
344/// assert_eq!(
345///     split_shell_spec("\"C:/Program Files/PowerShell/7/pwsh.exe\" -Command"),
346///     Some(vec![
347///         "C:/Program Files/PowerShell/7/pwsh.exe".to_owned(),
348///         "-Command".to_owned(),
349///     ])
350/// );
351/// assert_eq!(split_shell_spec("   "), None);
352/// assert_eq!(split_shell_spec("\"unterminated -c"), None);
353/// ```
354#[must_use]
355pub fn split_shell_spec(spec: &str) -> Option<Vec<String>> {
356    let mut tokens: Vec<String> = Vec::new();
357    let mut current = String::new();
358    let mut started = false;
359    let mut in_quotes = false;
360
361    for ch in spec.chars() {
362        match ch {
363            // A quote delimits a token without contributing a character, and
364            // opening one starts a token even if it turns out to be empty.
365            '"' => {
366                in_quotes = !in_quotes;
367                started = true;
368            }
369            ch if ch.is_whitespace() && !in_quotes => {
370                if started {
371                    tokens.push(std::mem::take(&mut current));
372                    started = false;
373                }
374            }
375            ch => {
376                current.push(ch);
377                started = true;
378            }
379        }
380    }
381
382    if in_quotes {
383        return None;
384    }
385    if started {
386        tokens.push(current);
387    }
388
389    match tokens.first() {
390        Some(program) if !program.is_empty() => Some(tokens),
391        _ => None,
392    }
393}
394
395/// Builds the argv that runs `command` through the shell named by `spec`.
396///
397/// `spec` is the `[general] shell` value; a blank or unusable one falls back to
398/// [`DEFAULT_SHELL_ARGV`], so this never fails. `command` is appended as a
399/// single final argument — it is the shell's job to parse it, which is what
400/// makes pipelines, quoting and builtins work.
401///
402/// ```
403/// use trail::actions::shell_exec::shell_argv;
404///
405/// assert_eq!(
406///     shell_argv("pwsh -Command", "./gradlew runClient"),
407///     vec!["pwsh".to_owned(), "-Command".to_owned(), "./gradlew runClient".to_owned()]
408/// );
409/// ```
410#[must_use]
411pub fn shell_argv(spec: &str, command: &str) -> Vec<String> {
412    let mut argv = split_shell_spec(spec).unwrap_or_else(|| {
413        DEFAULT_SHELL_ARGV
414            .iter()
415            .map(|arg| (*arg).to_owned())
416            .collect()
417    });
418    argv.push(command.to_owned());
419    argv
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn blank_spec_falls_back_to_the_platform_default() {
428        for blank in ["", "   ", "\t"] {
429            let argv = shell_argv(blank, "echo hi");
430            let expected: Vec<String> = DEFAULT_SHELL_ARGV
431                .iter()
432                .map(|arg| (*arg).to_owned())
433                .chain(std::iter::once("echo hi".to_owned()))
434                .collect();
435            assert_eq!(argv, expected, "{blank:?} should use the default shell");
436        }
437    }
438
439    #[test]
440    fn the_command_stays_one_argument() {
441        // The whole point of routing through a shell: the command keeps its
442        // pipes and quotes instead of being split into argv here.
443        let argv = shell_argv("sh -c", "ls -la | grep foo && echo \"done\"");
444        assert_eq!(argv.len(), 3);
445        assert_eq!(argv[2], "ls -la | grep foo && echo \"done\"");
446    }
447
448    #[test]
449    fn a_quoted_program_path_keeps_its_spaces() {
450        let argv = split_shell_spec("\"/usr/local/my shell/sh\" -c").unwrap();
451        assert_eq!(argv, vec!["/usr/local/my shell/sh", "-c"]);
452    }
453
454    #[test]
455    fn runs_of_whitespace_do_not_produce_empty_tokens() {
456        let argv = split_shell_spec("  pwsh   -NoProfile  -Command  ").unwrap();
457        assert_eq!(argv, vec!["pwsh", "-NoProfile", "-Command"]);
458    }
459
460    #[test]
461    fn a_program_name_alone_is_a_valid_spec() {
462        assert_eq!(split_shell_spec("bash"), Some(vec!["bash".to_owned()]));
463    }
464
465    #[test]
466    fn unusable_specs_are_rejected() {
467        // Unterminated quote, and an explicitly empty program name.
468        for bad in ["\"pwsh -Command", "\"\" -c", "\"\""] {
469            assert_eq!(split_shell_spec(bad), None, "{bad:?} should be rejected");
470        }
471    }
472
473    #[test]
474    fn the_argv_actually_spawns_and_the_shell_parses_the_command() {
475        // The vector-shape tests above would all still pass if the argv were
476        // ordered wrongly, so spawn one for real. The default shell is used
477        // because it is the only one guaranteed to exist on every CI target,
478        // and `echo` is a builtin in both cmd.exe and sh.
479        let argv = shell_argv("", "echo trail-ok");
480        let output = Command::new(&argv[0])
481            .args(&argv[1..])
482            .output()
483            .expect("the default shell must be spawnable");
484
485        assert!(
486            output.status.success(),
487            "shell exited with {:?}",
488            output.status
489        );
490        let stdout = String::from_utf8_lossy(&output.stdout);
491        assert!(
492            stdout.contains("trail-ok"),
493            "the shell did not interpret the command; stdout was {stdout:?}"
494        );
495    }
496
497    #[test]
498    fn pause_policies_parse_from_what_the_config_can_say() {
499        assert_eq!(ShellPause::parse("always"), Some(ShellPause::Always));
500        assert_eq!(ShellPause::parse(" ALWAYS "), Some(ShellPause::Always));
501        assert_eq!(ShellPause::parse("on_error"), Some(ShellPause::OnError));
502        assert_eq!(ShellPause::parse("on-error"), Some(ShellPause::OnError));
503        assert_eq!(ShellPause::parse("never"), Some(ShellPause::Never));
504        assert_eq!(ShellPause::parse("off"), Some(ShellPause::Never));
505        assert_eq!(ShellPause::parse("sometimes"), None);
506        assert_eq!(ShellPause::parse(""), None);
507    }
508
509    #[test]
510    fn on_error_waits_only_for_a_command_that_failed() {
511        assert!(ShellPause::Always.applies(true));
512        assert!(ShellPause::Always.applies(false));
513        assert!(!ShellPause::OnError.applies(true));
514        assert!(ShellPause::OnError.applies(false));
515        assert!(!ShellPause::Never.applies(true));
516        assert!(!ShellPause::Never.applies(false));
517    }
518
519    fn ran(result: RunResult) -> RunReport {
520        RunReport {
521            label: Some("!x".to_owned()),
522            program: "sh".to_owned(),
523            result,
524            elapsed: Duration::ZERO,
525        }
526    }
527
528    /// How many runs and how many questions a policy leads to, given the
529    /// results the runs produce and the answers the block receives.
530    fn drive(
531        pause: ShellPause,
532        results: Vec<RunResult>,
533        answers: Vec<BlockAnswer>,
534    ) -> (usize, usize, RunReport) {
535        let (mut runs, mut asks) = (0, 0);
536        let mut results = results.into_iter();
537        let mut answers = answers.into_iter();
538        let report = run_until_answered(
539            pause,
540            || {
541                runs += 1;
542                ran(results.next().expect("no more runs were expected"))
543            },
544            |_| {
545                asks += 1;
546                answers.next().expect("no more questions were expected")
547            },
548        );
549        (runs, asks, report)
550    }
551
552    #[test]
553    fn r_runs_again_and_the_last_run_is_the_one_reported() {
554        let (runs, asks, report) = drive(
555            ShellPause::Always,
556            vec![RunResult::Failed { code: Some(1) }, RunResult::Success],
557            vec![BlockAnswer::RunAgain, BlockAnswer::Return],
558        );
559        assert_eq!((runs, asks), (2, 2));
560        assert_eq!(report.result, RunResult::Success);
561    }
562
563    #[test]
564    fn on_error_never_asks_about_a_success() {
565        let (runs, asks, _) = drive(ShellPause::OnError, vec![RunResult::Success], vec![]);
566        assert_eq!((runs, asks), (1, 0));
567    }
568
569    /// `r` can make a failing command succeed, and then there is no block left
570    /// to answer under `on_error`: it goes straight back to Trail.
571    #[test]
572    fn on_error_stops_asking_once_a_rerun_succeeds() {
573        let (runs, asks, report) = drive(
574            ShellPause::OnError,
575            vec![RunResult::Failed { code: Some(1) }, RunResult::Success],
576            vec![BlockAnswer::RunAgain],
577        );
578        assert_eq!((runs, asks), (2, 1));
579        assert!(report.succeeded());
580    }
581
582    #[test]
583    fn on_error_asks_about_a_command_that_could_not_start() {
584        let (_, asks, _) = drive(
585            ShellPause::OnError,
586            vec![RunResult::CouldNotRun("no such program".to_owned())],
587            vec![BlockAnswer::Return],
588        );
589        assert_eq!(asks, 1);
590    }
591
592    /// Under `never` there is no block, so the report is all there is, and it
593    /// has to say the command failed.
594    #[test]
595    fn never_does_not_ask_but_still_reports_a_failure() {
596        let (runs, asks, report) = drive(
597            ShellPause::Never,
598            vec![RunResult::Failed { code: Some(2) }],
599            vec![],
600        );
601        assert_eq!((runs, asks), (1, 0));
602        assert!(report.status_line().unwrap().contains("failed: exit 2"));
603    }
604
605    #[test]
606    fn a_real_exit_code_reaches_the_report() {
607        let cwd = std::env::current_dir().unwrap();
608        let argv = shell_argv("", "exit 3");
609        let argv: Vec<&str> = argv.iter().map(String::as_str).collect();
610
611        let report = run_once(&argv, &cwd, Some("!exit 3"));
612
613        assert_eq!(report.result, RunResult::Failed { code: Some(3) });
614    }
615
616    #[test]
617    fn a_program_that_cannot_start_is_reported_not_returned_as_an_error() {
618        let cwd = std::env::current_dir().unwrap();
619        let report = run_once(&["trail-no-such-program"], &cwd, None);
620        assert!(
621            matches!(report.result, RunResult::CouldNotRun(_)),
622            "{report:?}"
623        );
624        assert!(report.status_line().is_some(), "a failure is always said");
625    }
626
627    #[test]
628    fn the_default_spec_is_itself_a_usable_argv() {
629        // Guards against a typo in the const leaving an empty program name.
630        assert!(!DEFAULT_SHELL_ARGV.is_empty());
631        assert!(!DEFAULT_SHELL_ARGV[0].is_empty());
632    }
633}