use std::io;
use std::process::Command;
use std::string::FromUtf8Error;
use xx::process::check_status;
use xx::XXError;
use crate::error::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ShellKind {
Posix,
Cmd,
}
fn shell_argv(kind: ShellKind) -> (&'static str, &'static str) {
match kind {
ShellKind::Posix => ("sh", "-c"),
ShellKind::Cmd => ("cmd", "/c"),
}
}
fn fallback_for(kind: ShellKind, err: io::ErrorKind) -> Option<ShellKind> {
match (kind, err) {
(ShellKind::Posix, io::ErrorKind::NotFound) if cfg!(windows) => Some(ShellKind::Cmd),
_ => None,
}
}
fn script_excerpt(script: &str) -> String {
let first_line = script.lines().next().unwrap_or_default();
match script.lines().nth(1) {
Some(_) => format!("{first_line} …"),
None => first_line.to_string(),
}
}
fn no_shell_message(script: &str) -> String {
format!(
"failed to run `run=` script: neither `sh` nor `cmd` could be started\n \
script: {}\n \
`run=` is executed with `sh -c`, falling back to `cmd /c` on Windows. \
Install a POSIX shell (Git for Windows ships sh.exe) and make sure it is on PATH.",
script_excerpt(script)
)
}
fn non_utf8_message(shell: &str, flag: &str, script: &str, err: &FromUtf8Error) -> String {
format!(
"`run=` script produced output that is not valid UTF-8: {err}\n \
script: {}\n \
shell: {shell} {flag}",
script_excerpt(script)
)
}
pub fn sh(script: &str) -> Result<String> {
let mut kind = ShellKind::Posix;
let output = loop {
let (shell, flag) = shell_argv(kind);
let err = match run(shell, flag, script) {
Ok(output) => break output,
Err(err) => err,
};
match fallback_for(kind, err.kind()) {
Some(next) => kind = next,
None if err.kind() == io::ErrorKind::NotFound && cfg!(windows) => {
return Err(XXError::Error(no_shell_message(script)).into());
}
None => {
return Err(XXError::ProcessError(err, format!("{shell} {flag} {script}")).into());
}
}
};
let (shell, flag) = shell_argv(kind);
check_status(output.status)
.map_err(|err| XXError::ProcessError(err, format!("{shell} {flag} {script}")))?;
String::from_utf8(output.stdout)
.map_err(|err| XXError::Error(non_utf8_message(shell, flag, script, &err)).into())
}
fn run(shell: &str, flag: &str, script: &str) -> io::Result<std::process::Output> {
Command::new(shell)
.arg(flag)
.arg(script)
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::inherit())
.env("__USAGE", env!("CARGO_PKG_VERSION"))
.output()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_argv_maps_each_kind() {
assert_eq!(shell_argv(ShellKind::Posix), ("sh", "-c"));
assert_eq!(shell_argv(ShellKind::Cmd), ("cmd", "/c"));
}
#[test]
fn a_missing_posix_shell_falls_back_only_on_windows() {
let fallback = fallback_for(ShellKind::Posix, io::ErrorKind::NotFound);
if cfg!(windows) {
assert_eq!(fallback, Some(ShellKind::Cmd));
} else {
assert_eq!(fallback, None);
}
}
#[test]
fn a_shell_that_exists_but_fails_is_not_demoted() {
assert_eq!(
fallback_for(ShellKind::Posix, io::ErrorKind::PermissionDenied),
None
);
}
#[test]
fn cmd_is_the_last_resort() {
assert_eq!(fallback_for(ShellKind::Cmd, io::ErrorKind::NotFound), None);
}
#[test]
fn no_shell_message_names_both_shells_and_the_script() {
let msg = no_shell_message("echo hello");
assert!(msg.contains("`sh`"), "{msg}");
assert!(msg.contains("`cmd`"), "{msg}");
assert!(msg.contains("echo hello"), "{msg}");
}
#[test]
fn no_shell_message_truncates_a_multi_line_script() {
let msg = no_shell_message("case $cur in\n a) echo a ;;\nesac");
assert!(msg.contains("case $cur in …"), "{msg}");
assert!(!msg.contains("esac"), "{msg}");
}
#[test]
fn non_utf8_message_names_the_script_and_the_shell() {
let err = String::from_utf8(vec![0xff]).unwrap_err();
let msg = non_utf8_message("cmd", "/c", "chcp 932 && dir", &err);
assert!(msg.contains("chcp 932 && dir"), "{msg}");
assert!(msg.contains("cmd /c"), "{msg}");
assert!(msg.contains("not valid UTF-8"), "{msg}");
}
#[cfg(unix)]
#[test]
fn sh_reports_non_utf8_output_instead_of_panicking() {
let err = sh(r"printf '\377'").unwrap_err();
assert!(
err.to_string().contains("not valid UTF-8"),
"{}",
err.to_string()
);
}
#[test]
fn sh_returns_stdout() {
assert!(sh("echo hello").unwrap().contains("hello"));
}
#[test]
fn sh_fails_on_a_nonzero_exit() {
assert!(sh("exit 1").is_err());
}
#[cfg(unix)]
#[test]
fn sh_exposes_the_usage_version() {
assert_eq!(
sh("echo $__USAGE").unwrap().trim(),
env!("CARGO_PKG_VERSION")
);
}
}