1use std::io;
4use std::process::Command;
5use std::string::FromUtf8Error;
6use xx::process::check_status;
7use xx::XXError;
8
9use crate::error::Result;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13enum ShellKind {
14 Posix,
16 Cmd,
19}
20
21fn shell_argv(kind: ShellKind) -> (&'static str, &'static str) {
22 match kind {
23 ShellKind::Posix => ("sh", "-c"),
24 ShellKind::Cmd => ("cmd", "/c"),
25 }
26}
27
28fn fallback_for(kind: ShellKind, err: io::ErrorKind) -> Option<ShellKind> {
34 match (kind, err) {
35 (ShellKind::Posix, io::ErrorKind::NotFound) if cfg!(windows) => Some(ShellKind::Cmd),
36 _ => None,
37 }
38}
39
40fn script_excerpt(script: &str) -> String {
45 let first_line = script.lines().next().unwrap_or_default();
46 match script.lines().nth(1) {
47 Some(_) => format!("{first_line} …"),
48 None => first_line.to_string(),
49 }
50}
51
52fn no_shell_message(script: &str) -> String {
53 format!(
54 "failed to run `run=` script: neither `sh` nor `cmd` could be started\n \
55 script: {}\n \
56 `run=` is executed with `sh -c`, falling back to `cmd /c` on Windows. \
57 Install a POSIX shell (Git for Windows ships sh.exe) and make sure it is on PATH.",
58 script_excerpt(script)
59 )
60}
61
62fn non_utf8_message(shell: &str, flag: &str, script: &str, err: &FromUtf8Error) -> String {
63 format!(
64 "`run=` script produced output that is not valid UTF-8: {err}\n \
65 script: {}\n \
66 shell: {shell} {flag}",
67 script_excerpt(script)
68 )
69}
70
71pub fn sh(script: &str) -> Result<String> {
88 let mut kind = ShellKind::Posix;
89 let output = loop {
90 let (shell, flag) = shell_argv(kind);
91 let err = match run(shell, flag, script) {
92 Ok(output) => break output,
93 Err(err) => err,
94 };
95 match fallback_for(kind, err.kind()) {
96 Some(next) => kind = next,
97 None if err.kind() == io::ErrorKind::NotFound && cfg!(windows) => {
98 return Err(XXError::Error(no_shell_message(script)).into());
99 }
100 None => {
101 return Err(XXError::ProcessError(err, format!("{shell} {flag} {script}")).into());
102 }
103 }
104 };
105
106 let (shell, flag) = shell_argv(kind);
107 check_status(output.status)
108 .map_err(|err| XXError::ProcessError(err, format!("{shell} {flag} {script}")))?;
109 String::from_utf8(output.stdout)
110 .map_err(|err| XXError::Error(non_utf8_message(shell, flag, script, &err)).into())
111}
112
113fn run(shell: &str, flag: &str, script: &str) -> io::Result<std::process::Output> {
114 Command::new(shell)
115 .arg(flag)
116 .arg(script)
117 .stdin(std::process::Stdio::null())
118 .stderr(std::process::Stdio::inherit())
119 .env("__USAGE", env!("CARGO_PKG_VERSION"))
120 .output()
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 #[test]
128 fn shell_argv_maps_each_kind() {
129 assert_eq!(shell_argv(ShellKind::Posix), ("sh", "-c"));
130 assert_eq!(shell_argv(ShellKind::Cmd), ("cmd", "/c"));
131 }
132
133 #[test]
134 fn a_missing_posix_shell_falls_back_only_on_windows() {
135 let fallback = fallback_for(ShellKind::Posix, io::ErrorKind::NotFound);
136 if cfg!(windows) {
137 assert_eq!(fallback, Some(ShellKind::Cmd));
138 } else {
139 assert_eq!(fallback, None);
140 }
141 }
142
143 #[test]
144 fn a_shell_that_exists_but_fails_is_not_demoted() {
145 assert_eq!(
148 fallback_for(ShellKind::Posix, io::ErrorKind::PermissionDenied),
149 None
150 );
151 }
152
153 #[test]
154 fn cmd_is_the_last_resort() {
155 assert_eq!(fallback_for(ShellKind::Cmd, io::ErrorKind::NotFound), None);
156 }
157
158 #[test]
159 fn no_shell_message_names_both_shells_and_the_script() {
160 let msg = no_shell_message("echo hello");
161 assert!(msg.contains("`sh`"), "{msg}");
162 assert!(msg.contains("`cmd`"), "{msg}");
163 assert!(msg.contains("echo hello"), "{msg}");
164 }
165
166 #[test]
167 fn no_shell_message_truncates_a_multi_line_script() {
168 let msg = no_shell_message("case $cur in\n a) echo a ;;\nesac");
169 assert!(msg.contains("case $cur in …"), "{msg}");
170 assert!(!msg.contains("esac"), "{msg}");
171 }
172
173 #[test]
174 fn non_utf8_message_names_the_script_and_the_shell() {
175 let err = String::from_utf8(vec![0xff]).unwrap_err();
176 let msg = non_utf8_message("cmd", "/c", "chcp 932 && dir", &err);
177 assert!(msg.contains("chcp 932 && dir"), "{msg}");
178 assert!(msg.contains("cmd /c"), "{msg}");
179 assert!(msg.contains("not valid UTF-8"), "{msg}");
180 }
181
182 #[cfg(unix)]
183 #[test]
184 fn sh_reports_non_utf8_output_instead_of_panicking() {
185 let err = sh(r"printf '\377'").unwrap_err();
188 assert!(
189 err.to_string().contains("not valid UTF-8"),
190 "{}",
191 err.to_string()
192 );
193 }
194
195 #[test]
196 fn sh_returns_stdout() {
197 assert!(sh("echo hello").unwrap().contains("hello"));
199 }
200
201 #[test]
202 fn sh_fails_on_a_nonzero_exit() {
203 assert!(sh("exit 1").is_err());
204 }
205
206 #[cfg(unix)]
207 #[test]
208 fn sh_exposes_the_usage_version() {
209 assert_eq!(
210 sh("echo $__USAGE").unwrap().trim(),
211 env!("CARGO_PKG_VERSION")
212 );
213 }
214}