1use std::io;
4use std::process::{Command, ExitStatus};
5use std::string::FromUtf8Error;
6
7use crate::error::{Result, UsageErr};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11enum ShellKind {
12 Posix,
14 Cmd,
17}
18
19fn shell_argv(kind: ShellKind) -> (&'static str, &'static str) {
20 match kind {
21 ShellKind::Posix => ("sh", "-c"),
22 ShellKind::Cmd => ("cmd", "/c"),
23 }
24}
25
26fn fallback_for(kind: ShellKind, err: io::ErrorKind) -> Option<ShellKind> {
32 match (kind, err) {
33 (ShellKind::Posix, io::ErrorKind::NotFound) if cfg!(windows) => Some(ShellKind::Cmd),
34 _ => None,
35 }
36}
37
38fn script_excerpt(script: &str) -> String {
43 let first_line = script.lines().next().unwrap_or_default();
44 match script.lines().nth(1) {
45 Some(_) => format!("{first_line} …"),
46 None => first_line.to_string(),
47 }
48}
49
50fn no_shell_message(script: &str) -> String {
51 format!(
52 "failed to run `run=` script: neither `sh` nor `cmd` could be started\n \
53 script: {}\n \
54 `run=` is executed with `sh -c`, falling back to `cmd /c` on Windows. \
55 Install a POSIX shell (Git for Windows ships sh.exe) and make sure it is on PATH.",
56 script_excerpt(script)
57 )
58}
59
60fn non_utf8_message(shell: &str, flag: &str, script: &str, err: &FromUtf8Error) -> String {
61 format!(
62 "`run=` script produced output that is not valid UTF-8: {err}\n \
63 script: {}\n \
64 shell: {shell} {flag}",
65 script_excerpt(script)
66 )
67}
68
69pub fn sh(script: &str) -> Result<String> {
86 sh_with_env(script, None)
87}
88
89pub fn sh_with_env(
96 script: &str,
97 env: Option<&std::collections::HashMap<String, String>>,
98) -> Result<String> {
99 let mut kind = ShellKind::Posix;
100 let output = loop {
101 let (shell, flag) = shell_argv(kind);
102 let err = match run(shell, flag, script, env) {
103 Ok(output) => break output,
104 Err(err) => err,
105 };
106 match fallback_for(kind, err.kind()) {
107 Some(next) => kind = next,
108 None if err.kind() == io::ErrorKind::NotFound && cfg!(windows) => {
109 return Err(UsageErr::ShellError(no_shell_message(script)));
110 }
111 None => {
112 return Err(UsageErr::ShellError(format!(
113 "{err}\n{shell} {flag} {script}"
114 )));
115 }
116 }
117 };
118
119 let (shell, flag) = shell_argv(kind);
120 if let Some(failure) = status_failure(output.status) {
121 return Err(UsageErr::ShellError(format!(
122 "{failure}\n{shell} {flag} {script}"
123 )));
124 }
125 String::from_utf8(output.stdout)
126 .map_err(|err| UsageErr::ShellError(non_utf8_message(shell, flag, script, &err)))
127}
128
129fn status_failure(status: ExitStatus) -> Option<String> {
133 if status.success() {
134 return None;
135 }
136 Some(match status.code() {
137 Some(code) => format!("exited with code {code}"),
138 None => "terminated by signal".to_string(),
139 })
140}
141
142fn run(
143 shell: &str,
144 flag: &str,
145 script: &str,
146 env: Option<&std::collections::HashMap<String, String>>,
147) -> io::Result<std::process::Output> {
148 let mut command = Command::new(shell);
149 command
150 .arg(flag)
151 .arg(script)
152 .stdin(std::process::Stdio::null())
153 .stderr(std::process::Stdio::inherit());
154 if let Some(env) = env {
155 command.envs(env);
156 }
157 command.env("__USAGE", env!("CARGO_PKG_VERSION")).output()
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn shell_argv_maps_each_kind() {
166 assert_eq!(shell_argv(ShellKind::Posix), ("sh", "-c"));
167 assert_eq!(shell_argv(ShellKind::Cmd), ("cmd", "/c"));
168 }
169
170 #[test]
171 fn a_missing_posix_shell_falls_back_only_on_windows() {
172 let fallback = fallback_for(ShellKind::Posix, io::ErrorKind::NotFound);
173 if cfg!(windows) {
174 assert_eq!(fallback, Some(ShellKind::Cmd));
175 } else {
176 assert_eq!(fallback, None);
177 }
178 }
179
180 #[test]
181 fn a_shell_that_exists_but_fails_is_not_demoted() {
182 assert_eq!(
185 fallback_for(ShellKind::Posix, io::ErrorKind::PermissionDenied),
186 None
187 );
188 }
189
190 #[test]
191 fn cmd_is_the_last_resort() {
192 assert_eq!(fallback_for(ShellKind::Cmd, io::ErrorKind::NotFound), None);
193 }
194
195 #[test]
196 fn no_shell_message_names_both_shells_and_the_script() {
197 let msg = no_shell_message("echo hello");
198 assert!(msg.contains("`sh`"), "{msg}");
199 assert!(msg.contains("`cmd`"), "{msg}");
200 assert!(msg.contains("echo hello"), "{msg}");
201 }
202
203 #[test]
204 fn no_shell_message_truncates_a_multi_line_script() {
205 let msg = no_shell_message("case $cur in\n a) echo a ;;\nesac");
206 assert!(msg.contains("case $cur in …"), "{msg}");
207 assert!(!msg.contains("esac"), "{msg}");
208 }
209
210 #[test]
211 fn non_utf8_message_names_the_script_and_the_shell() {
212 let err = String::from_utf8(vec![0xff]).unwrap_err();
213 let msg = non_utf8_message("cmd", "/c", "chcp 932 && dir", &err);
214 assert!(msg.contains("chcp 932 && dir"), "{msg}");
215 assert!(msg.contains("cmd /c"), "{msg}");
216 assert!(msg.contains("not valid UTF-8"), "{msg}");
217 }
218
219 #[cfg(unix)]
220 #[test]
221 fn sh_reports_non_utf8_output_instead_of_panicking() {
222 let err = sh(r"printf '\377'").unwrap_err();
225 assert!(
226 err.to_string().contains("not valid UTF-8"),
227 "{}",
228 err.to_string()
229 );
230 }
231
232 #[test]
233 fn sh_returns_stdout() {
234 assert!(sh("echo hello").unwrap().contains("hello"));
236 }
237
238 #[test]
239 fn sh_fails_on_a_nonzero_exit() {
240 assert!(sh("exit 1").is_err());
241 }
242
243 #[cfg(unix)]
244 #[test]
245 fn sh_with_env_adds_to_the_inherited_environment() {
246 let env = std::collections::HashMap::from([(
247 "USAGE_TEST_SH_WITH_ENV".to_string(),
248 "from the map".to_string(),
249 )]);
250 assert_eq!(
252 sh_with_env(
253 "echo \"$USAGE_TEST_SH_WITH_ENV${PATH:+ with path}\"",
254 Some(&env)
255 )
256 .unwrap()
257 .trim(),
258 "from the map with path"
259 );
260 }
261
262 #[cfg(unix)]
263 #[test]
264 fn sh_exposes_the_usage_version() {
265 assert_eq!(
266 sh("echo $__USAGE").unwrap().trim(),
267 env!("CARGO_PKG_VERSION")
268 );
269 }
270}