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 let mut kind = ShellKind::Posix;
87 let output = loop {
88 let (shell, flag) = shell_argv(kind);
89 let err = match run(shell, flag, script) {
90 Ok(output) => break output,
91 Err(err) => err,
92 };
93 match fallback_for(kind, err.kind()) {
94 Some(next) => kind = next,
95 None if err.kind() == io::ErrorKind::NotFound && cfg!(windows) => {
96 return Err(UsageErr::ShellError(no_shell_message(script)));
97 }
98 None => {
99 return Err(UsageErr::ShellError(format!(
100 "{err}\n{shell} {flag} {script}"
101 )));
102 }
103 }
104 };
105
106 let (shell, flag) = shell_argv(kind);
107 if let Some(failure) = status_failure(output.status) {
108 return Err(UsageErr::ShellError(format!(
109 "{failure}\n{shell} {flag} {script}"
110 )));
111 }
112 String::from_utf8(output.stdout)
113 .map_err(|err| UsageErr::ShellError(non_utf8_message(shell, flag, script, &err)))
114}
115
116fn status_failure(status: ExitStatus) -> Option<String> {
120 if status.success() {
121 return None;
122 }
123 Some(match status.code() {
124 Some(code) => format!("exited with code {code}"),
125 None => "terminated by signal".to_string(),
126 })
127}
128
129fn run(shell: &str, flag: &str, script: &str) -> io::Result<std::process::Output> {
130 Command::new(shell)
131 .arg(flag)
132 .arg(script)
133 .stdin(std::process::Stdio::null())
134 .stderr(std::process::Stdio::inherit())
135 .env("__USAGE", env!("CARGO_PKG_VERSION"))
136 .output()
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn shell_argv_maps_each_kind() {
145 assert_eq!(shell_argv(ShellKind::Posix), ("sh", "-c"));
146 assert_eq!(shell_argv(ShellKind::Cmd), ("cmd", "/c"));
147 }
148
149 #[test]
150 fn a_missing_posix_shell_falls_back_only_on_windows() {
151 let fallback = fallback_for(ShellKind::Posix, io::ErrorKind::NotFound);
152 if cfg!(windows) {
153 assert_eq!(fallback, Some(ShellKind::Cmd));
154 } else {
155 assert_eq!(fallback, None);
156 }
157 }
158
159 #[test]
160 fn a_shell_that_exists_but_fails_is_not_demoted() {
161 assert_eq!(
164 fallback_for(ShellKind::Posix, io::ErrorKind::PermissionDenied),
165 None
166 );
167 }
168
169 #[test]
170 fn cmd_is_the_last_resort() {
171 assert_eq!(fallback_for(ShellKind::Cmd, io::ErrorKind::NotFound), None);
172 }
173
174 #[test]
175 fn no_shell_message_names_both_shells_and_the_script() {
176 let msg = no_shell_message("echo hello");
177 assert!(msg.contains("`sh`"), "{msg}");
178 assert!(msg.contains("`cmd`"), "{msg}");
179 assert!(msg.contains("echo hello"), "{msg}");
180 }
181
182 #[test]
183 fn no_shell_message_truncates_a_multi_line_script() {
184 let msg = no_shell_message("case $cur in\n a) echo a ;;\nesac");
185 assert!(msg.contains("case $cur in …"), "{msg}");
186 assert!(!msg.contains("esac"), "{msg}");
187 }
188
189 #[test]
190 fn non_utf8_message_names_the_script_and_the_shell() {
191 let err = String::from_utf8(vec![0xff]).unwrap_err();
192 let msg = non_utf8_message("cmd", "/c", "chcp 932 && dir", &err);
193 assert!(msg.contains("chcp 932 && dir"), "{msg}");
194 assert!(msg.contains("cmd /c"), "{msg}");
195 assert!(msg.contains("not valid UTF-8"), "{msg}");
196 }
197
198 #[cfg(unix)]
199 #[test]
200 fn sh_reports_non_utf8_output_instead_of_panicking() {
201 let err = sh(r"printf '\377'").unwrap_err();
204 assert!(
205 err.to_string().contains("not valid UTF-8"),
206 "{}",
207 err.to_string()
208 );
209 }
210
211 #[test]
212 fn sh_returns_stdout() {
213 assert!(sh("echo hello").unwrap().contains("hello"));
215 }
216
217 #[test]
218 fn sh_fails_on_a_nonzero_exit() {
219 assert!(sh("exit 1").is_err());
220 }
221
222 #[cfg(unix)]
223 #[test]
224 fn sh_exposes_the_usage_version() {
225 assert_eq!(
226 sh("echo $__USAGE").unwrap().trim(),
227 env!("CARGO_PKG_VERSION")
228 );
229 }
230}