use std::io::{stdout, Write};
use std::path::Path;
use std::process::Command;
use crossterm::execute;
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use thiserror::Error;
#[cfg(windows)]
pub const DEFAULT_SHELL_ARGV: &[&str] = &["cmd.exe", "/C"];
#[cfg(not(windows))]
pub const DEFAULT_SHELL_ARGV: &[&str] = &["sh", "-c"];
const PAUSE_PROMPT: &str = "\n[trail] press Enter to return… ";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShellPause {
Always,
OnError,
Never,
}
impl ShellPause {
pub fn parse(value: &str) -> Option<ShellPause> {
match value.trim().to_ascii_lowercase().as_str() {
"always" => Some(ShellPause::Always),
"on_error" | "on-error" => Some(ShellPause::OnError),
"never" | "off" => Some(ShellPause::Never),
_ => None,
}
}
fn applies(self, success: bool) -> bool {
match self {
ShellPause::Always => true,
ShellPause::OnError => !success,
ShellPause::Never => false,
}
}
}
#[derive(Debug, Error)]
pub enum ShellExecError {
#[error("terminal error: {0}")]
Terminal(#[source] std::io::Error),
#[error("failed to spawn process: {0}")]
Spawn(#[source] std::io::Error),
#[error("failed to wait for process: {0}")]
Wait(#[source] std::io::Error),
}
pub fn run_external(argv: &[&str], cwd: &Path, pause: ShellPause) -> Result<(), ShellExecError> {
if argv.is_empty() {
return Ok(());
}
execute!(stdout(), LeaveAlternateScreen).map_err(ShellExecError::Terminal)?;
terminal::disable_raw_mode().map_err(ShellExecError::Terminal)?;
let spawn_result = Command::new(argv[0])
.args(&argv[1..])
.current_dir(cwd)
.spawn();
let wait_result = match spawn_result {
Ok(mut child) => child.wait().map_err(ShellExecError::Wait),
Err(e) => Err(ShellExecError::Spawn(e)),
};
let succeeded = wait_result
.as_ref()
.map(|status| status.success())
.unwrap_or(false);
if pause.applies(succeeded) {
wait_for_enter();
}
let wait_result = wait_result.map(|_| ());
if let Err(e) = terminal::enable_raw_mode() {
tracing::error!("failed to re-enable raw mode after subprocess: {e}");
}
if let Err(e) = execute!(stdout(), EnterAlternateScreen) {
tracing::error!("failed to re-enter alternate screen after subprocess: {e}");
}
wait_result
}
fn wait_for_enter() {
let mut out = stdout();
let _ = out.write_all(PAUSE_PROMPT.as_bytes());
let _ = out.flush();
let mut discard = String::new();
let _ = std::io::stdin().read_line(&mut discard);
}
#[must_use]
pub fn split_shell_spec(spec: &str) -> Option<Vec<String>> {
let mut tokens: Vec<String> = Vec::new();
let mut current = String::new();
let mut started = false;
let mut in_quotes = false;
for ch in spec.chars() {
match ch {
'"' => {
in_quotes = !in_quotes;
started = true;
}
ch if ch.is_whitespace() && !in_quotes => {
if started {
tokens.push(std::mem::take(&mut current));
started = false;
}
}
ch => {
current.push(ch);
started = true;
}
}
}
if in_quotes {
return None;
}
if started {
tokens.push(current);
}
match tokens.first() {
Some(program) if !program.is_empty() => Some(tokens),
_ => None,
}
}
#[must_use]
pub fn shell_argv(spec: &str, command: &str) -> Vec<String> {
let mut argv = split_shell_spec(spec).unwrap_or_else(|| {
DEFAULT_SHELL_ARGV
.iter()
.map(|arg| (*arg).to_owned())
.collect()
});
argv.push(command.to_owned());
argv
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blank_spec_falls_back_to_the_platform_default() {
for blank in ["", " ", "\t"] {
let argv = shell_argv(blank, "echo hi");
let expected: Vec<String> = DEFAULT_SHELL_ARGV
.iter()
.map(|arg| (*arg).to_owned())
.chain(std::iter::once("echo hi".to_owned()))
.collect();
assert_eq!(argv, expected, "{blank:?} should use the default shell");
}
}
#[test]
fn the_command_stays_one_argument() {
let argv = shell_argv("sh -c", "ls -la | grep foo && echo \"done\"");
assert_eq!(argv.len(), 3);
assert_eq!(argv[2], "ls -la | grep foo && echo \"done\"");
}
#[test]
fn a_quoted_program_path_keeps_its_spaces() {
let argv = split_shell_spec("\"/usr/local/my shell/sh\" -c").unwrap();
assert_eq!(argv, vec!["/usr/local/my shell/sh", "-c"]);
}
#[test]
fn runs_of_whitespace_do_not_produce_empty_tokens() {
let argv = split_shell_spec(" pwsh -NoProfile -Command ").unwrap();
assert_eq!(argv, vec!["pwsh", "-NoProfile", "-Command"]);
}
#[test]
fn a_program_name_alone_is_a_valid_spec() {
assert_eq!(split_shell_spec("bash"), Some(vec!["bash".to_owned()]));
}
#[test]
fn unusable_specs_are_rejected() {
for bad in ["\"pwsh -Command", "\"\" -c", "\"\""] {
assert_eq!(split_shell_spec(bad), None, "{bad:?} should be rejected");
}
}
#[test]
fn the_argv_actually_spawns_and_the_shell_parses_the_command() {
let argv = shell_argv("", "echo trail-ok");
let output = Command::new(&argv[0])
.args(&argv[1..])
.output()
.expect("the default shell must be spawnable");
assert!(
output.status.success(),
"shell exited with {:?}",
output.status
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("trail-ok"),
"the shell did not interpret the command; stdout was {stdout:?}"
);
}
#[test]
fn pause_policies_parse_from_what_the_config_can_say() {
assert_eq!(ShellPause::parse("always"), Some(ShellPause::Always));
assert_eq!(ShellPause::parse(" ALWAYS "), Some(ShellPause::Always));
assert_eq!(ShellPause::parse("on_error"), Some(ShellPause::OnError));
assert_eq!(ShellPause::parse("on-error"), Some(ShellPause::OnError));
assert_eq!(ShellPause::parse("never"), Some(ShellPause::Never));
assert_eq!(ShellPause::parse("off"), Some(ShellPause::Never));
assert_eq!(ShellPause::parse("sometimes"), None);
assert_eq!(ShellPause::parse(""), None);
}
#[test]
fn on_error_waits_only_for_a_command_that_failed() {
assert!(ShellPause::Always.applies(true));
assert!(ShellPause::Always.applies(false));
assert!(!ShellPause::OnError.applies(true));
assert!(ShellPause::OnError.applies(false));
assert!(!ShellPause::Never.applies(true));
assert!(!ShellPause::Never.applies(false));
}
#[test]
fn the_default_spec_is_itself_a_usable_argv() {
assert!(!DEFAULT_SHELL_ARGV.is_empty());
assert!(!DEFAULT_SHELL_ARGV[0].is_empty());
}
}