use std::io::{stdout, IsTerminal, Write};
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
use crossterm::event::{self, Event};
use crossterm::style::{Color, Print, ResetColor, SetForegroundColor};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::{execute, queue};
use thiserror::Error;
use super::run_report::{BlockAnswer, RerunKey, ResultBlock, RunReport, RunResult};
#[cfg(windows)]
pub const DEFAULT_SHELL_ARGV: &[&str] = &["cmd.exe", "/C"];
#[cfg(not(windows))]
pub const DEFAULT_SHELL_ARGV: &[&str] = &["sh", "-c"];
#[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),
}
pub fn run_external(
argv: &[&str],
cwd: &Path,
pause: ShellPause,
label: Option<&str>,
) -> Result<RunReport, ShellExecError> {
if argv.is_empty() {
return Ok(RunReport {
label: label.map(str::to_owned),
program: String::new(),
result: RunResult::CouldNotRun("nothing to run".to_owned()),
elapsed: Duration::ZERO,
});
}
execute!(stdout(), LeaveAlternateScreen).map_err(ShellExecError::Terminal)?;
terminal::disable_raw_mode().map_err(ShellExecError::Terminal)?;
let mut rerun_key = RerunKey::default();
let report = run_until_answered(
pause,
|| run_once(argv, cwd, label),
|report| ask_at_the_block(report, cwd, &mut rerun_key),
);
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}");
}
Ok(report)
}
fn run_until_answered(
pause: ShellPause,
mut run: impl FnMut() -> RunReport,
mut ask: impl FnMut(&RunReport) -> BlockAnswer,
) -> RunReport {
loop {
let report = run();
if !pause.applies(report.succeeded()) || ask(&report) != BlockAnswer::RunAgain {
return report;
}
}
}
fn run_once(argv: &[&str], cwd: &Path, label: Option<&str>) -> RunReport {
let started = Instant::now();
let result = match Command::new(argv[0])
.args(&argv[1..])
.current_dir(cwd)
.spawn()
.and_then(|mut child| child.wait())
{
Ok(status) if status.success() => RunResult::Success,
Ok(status) => RunResult::Failed {
code: status.code(),
},
Err(e) => RunResult::CouldNotRun(e.to_string()),
};
RunReport {
label: label.map(str::to_owned),
program: argv[0].to_owned(),
result,
elapsed: started.elapsed(),
}
}
fn ask_at_the_block(report: &RunReport, cwd: &Path, rerun_key: &mut RerunKey) -> BlockAnswer {
let interactive = std::io::stdin().is_terminal() && terminal::enable_raw_mode().is_ok();
if interactive {
discard_pending_input(rerun_key);
}
let columns = terminal::size().map_or(80, |(columns, _)| columns);
let block = ResultBlock::new(report, cwd, columns);
if let Err(e) = print_block(&block) {
tracing::debug!("could not print the result block: {e}");
}
let answer = if interactive {
wait_for_answer(rerun_key)
} else {
BlockAnswer::Return
};
if interactive {
if let Err(e) = terminal::disable_raw_mode() {
tracing::debug!("could not leave raw mode at the result block: {e}");
}
}
let mut out = stdout();
let _ = out.write_all(b"\r\n");
let _ = out.flush();
answer
}
fn discard_pending_input(rerun_key: &mut RerunKey) {
while matches!(event::poll(Duration::ZERO), Ok(true)) {
match event::read() {
Ok(Event::Key(key)) => {
rerun_key.answer(&key, Instant::now());
}
Ok(_) => {}
Err(_) => return,
}
}
}
fn wait_for_answer(rerun_key: &mut RerunKey) -> BlockAnswer {
loop {
match event::read() {
Ok(Event::Key(key)) => match rerun_key.answer(&key, Instant::now()) {
BlockAnswer::Ignore => {}
answer => return answer,
},
Ok(_) => {}
Err(e) => {
tracing::debug!("could not read a key at the result block: {e}");
return BlockAnswer::Return;
}
}
}
}
fn print_block(block: &ResultBlock) -> std::io::Result<()> {
let colour = if block.succeeded {
Color::Green
} else {
Color::Red
};
let mut out = stdout();
queue!(
out,
Print("\r\n"),
Print(&block.header_start),
SetForegroundColor(colour),
Print(&block.outcome),
ResetColor,
Print(&block.header_end),
Print("\r\n"),
)?;
for line in block.lines().iter().skip(1) {
queue!(out, Print(line), Print("\r\n"))?;
}
out.flush()
}
#[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));
}
fn ran(result: RunResult) -> RunReport {
RunReport {
label: Some("!x".to_owned()),
program: "sh".to_owned(),
result,
elapsed: Duration::ZERO,
}
}
fn drive(
pause: ShellPause,
results: Vec<RunResult>,
answers: Vec<BlockAnswer>,
) -> (usize, usize, RunReport) {
let (mut runs, mut asks) = (0, 0);
let mut results = results.into_iter();
let mut answers = answers.into_iter();
let report = run_until_answered(
pause,
|| {
runs += 1;
ran(results.next().expect("no more runs were expected"))
},
|_| {
asks += 1;
answers.next().expect("no more questions were expected")
},
);
(runs, asks, report)
}
#[test]
fn r_runs_again_and_the_last_run_is_the_one_reported() {
let (runs, asks, report) = drive(
ShellPause::Always,
vec![RunResult::Failed { code: Some(1) }, RunResult::Success],
vec![BlockAnswer::RunAgain, BlockAnswer::Return],
);
assert_eq!((runs, asks), (2, 2));
assert_eq!(report.result, RunResult::Success);
}
#[test]
fn on_error_never_asks_about_a_success() {
let (runs, asks, _) = drive(ShellPause::OnError, vec![RunResult::Success], vec![]);
assert_eq!((runs, asks), (1, 0));
}
#[test]
fn on_error_stops_asking_once_a_rerun_succeeds() {
let (runs, asks, report) = drive(
ShellPause::OnError,
vec![RunResult::Failed { code: Some(1) }, RunResult::Success],
vec![BlockAnswer::RunAgain],
);
assert_eq!((runs, asks), (2, 1));
assert!(report.succeeded());
}
#[test]
fn on_error_asks_about_a_command_that_could_not_start() {
let (_, asks, _) = drive(
ShellPause::OnError,
vec![RunResult::CouldNotRun("no such program".to_owned())],
vec![BlockAnswer::Return],
);
assert_eq!(asks, 1);
}
#[test]
fn never_does_not_ask_but_still_reports_a_failure() {
let (runs, asks, report) = drive(
ShellPause::Never,
vec![RunResult::Failed { code: Some(2) }],
vec![],
);
assert_eq!((runs, asks), (1, 0));
assert!(report.status_line().unwrap().contains("failed: exit 2"));
}
#[test]
fn a_real_exit_code_reaches_the_report() {
let cwd = std::env::current_dir().unwrap();
let argv = shell_argv("", "exit 3");
let argv: Vec<&str> = argv.iter().map(String::as_str).collect();
let report = run_once(&argv, &cwd, Some("!exit 3"));
assert_eq!(report.result, RunResult::Failed { code: Some(3) });
}
#[test]
fn a_program_that_cannot_start_is_reported_not_returned_as_an_error() {
let cwd = std::env::current_dir().unwrap();
let report = run_once(&["trail-no-such-program"], &cwd, None);
assert!(
matches!(report.result, RunResult::CouldNotRun(_)),
"{report:?}"
);
assert!(report.status_line().is_some(), "a failure is always said");
}
#[test]
fn the_default_spec_is_itself_a_usable_argv() {
assert!(!DEFAULT_SHELL_ARGV.is_empty());
assert!(!DEFAULT_SHELL_ARGV[0].is_empty());
}
}