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}