1use std::borrow::Cow;
2
3use crate::shell::get_system_shell;
4use crate::shell::{Shell, ShellKind};
5
6pub struct ShellBuilder {
9 program: String,
11 args: Vec<String>,
12 interactive: bool,
13 redirect_stdin: bool,
15 kind: ShellKind,
16}
17
18impl ShellBuilder {
19 pub fn new(shell: &Shell, is_windows: bool) -> Self {
21 let (program, args) = match shell {
22 Shell::System => (get_system_shell(), Vec::new()),
23 Shell::Program(shell) => (shell.clone(), Vec::new()),
24 Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()),
25 };
26
27 let kind = ShellKind::new(&program, is_windows);
28 Self {
29 program,
30 args,
31 interactive: true,
32 kind,
33 redirect_stdin: false,
34 }
35 }
36 pub fn non_interactive(mut self) -> Self {
37 self.interactive = false;
38 self
39 }
40
41 pub fn command_label(&self, command_to_use_in_label: &str) -> String {
43 if command_to_use_in_label.trim().is_empty() {
44 self.program.clone()
45 } else {
46 match self.kind {
47 ShellKind::PowerShell | ShellKind::Pwsh => {
48 format!("{} -C '{}'", self.program, command_to_use_in_label)
49 }
50 ShellKind::Cmd => {
51 format!("{} /C \"{}\"", self.program, command_to_use_in_label)
52 }
53 ShellKind::Posix
54 | ShellKind::Nushell
55 | ShellKind::Fish
56 | ShellKind::Csh
57 | ShellKind::Tcsh
58 | ShellKind::Rc
59 | ShellKind::Xonsh
60 | ShellKind::Elvish => {
61 let interactivity = self.interactive.then_some("-i ").unwrap_or_default();
62 format!(
63 "{PROGRAM} {interactivity}-c '{command_to_use_in_label}'",
64 PROGRAM = self.program
65 )
66 }
67 }
68 }
69 }
70
71 pub fn redirect_stdin_to_dev_null(mut self) -> Self {
72 self.redirect_stdin = true;
73 self
74 }
75
76 pub fn build(
78 mut self,
79 task_command: Option<String>,
80 task_args: &[String],
81 ) -> (String, Vec<String>) {
82 if let Some(task_command) = task_command {
83 let task_command = if !task_args.is_empty() {
84 match self.kind.try_quote_prefix_aware(&task_command) {
85 Some(task_command) => task_command.into_owned(),
86 None => task_command,
87 }
88 } else {
89 task_command
90 };
91 let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| {
92 command.push(' ');
93 let shell_variable = self.kind.to_shell_variable(arg);
94 command.push_str(&match self.kind.try_quote(&shell_variable) {
95 Some(shell_variable) => shell_variable,
96 None => Cow::Owned(shell_variable),
97 });
98 command
99 });
100 if self.redirect_stdin {
101 match self.kind {
102 ShellKind::Posix => {
103 combined_command.insert_str(0, "exec </dev/null\n");
109 }
110 ShellKind::Fish => {
111 combined_command.insert_str(0, "begin; ");
112 combined_command.push_str("; end </dev/null");
113 }
114 ShellKind::Nushell
115 | ShellKind::Csh
116 | ShellKind::Tcsh
117 | ShellKind::Rc
118 | ShellKind::Xonsh
119 | ShellKind::Elvish => {
120 combined_command.insert(0, '(');
121 combined_command.push_str("\n) </dev/null");
122 }
123 ShellKind::PowerShell | ShellKind::Pwsh => {
124 combined_command.insert_str(0, "$null | & {");
125 combined_command.push_str("}");
126 }
127 ShellKind::Cmd => {
128 combined_command.push_str("< NUL");
129 }
130 }
131 }
132
133 self.args
134 .extend(self.kind.args_for_shell(self.interactive, combined_command));
135 }
136
137 (self.program, self.args)
138 }
139
140 #[doc(hidden)]
142 pub fn build_no_quote(
143 mut self,
144 task_command: Option<String>,
145 task_args: &[String],
146 ) -> (String, Vec<String>) {
147 if let Some(task_command) = task_command {
148 let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| {
149 command.push(' ');
150 command.push_str(&self.kind.to_shell_variable(arg));
151 command
152 });
153 if self.redirect_stdin {
154 match self.kind {
155 ShellKind::Posix => {
156 combined_command.insert_str(0, "exec </dev/null\n");
157 }
158 ShellKind::Fish => {
159 combined_command.insert_str(0, "begin; ");
160 combined_command.push_str("; end </dev/null");
161 }
162 ShellKind::Nushell
163 | ShellKind::Csh
164 | ShellKind::Tcsh
165 | ShellKind::Rc
166 | ShellKind::Xonsh
167 | ShellKind::Elvish => {
168 combined_command.insert(0, '(');
169 combined_command.push_str("\n) </dev/null");
170 }
171 ShellKind::PowerShell | ShellKind::Pwsh => {
172 combined_command.insert_str(0, "$null | & {");
173 combined_command.push_str("}");
174 }
175 ShellKind::Cmd => {
176 combined_command.push_str("< NUL");
177 }
178 }
179 }
180
181 self.args
182 .extend(self.kind.args_for_shell(self.interactive, combined_command));
183 }
184
185 (self.program, self.args)
186 }
187
188 pub fn build_smol_command(
193 self,
194 task_command: Option<String>,
195 task_args: &[String],
196 ) -> smol::process::Command {
197 smol::process::Command::from(self.build_std_command(task_command, task_args))
198 }
199
200 pub fn build_std_command(
205 self,
206 mut task_command: Option<String>,
207 task_args: &[String],
208 ) -> std::process::Command {
209 #[cfg(windows)]
210 let kind = self.kind;
211 if task_args.is_empty() {
212 task_command = task_command
213 .as_ref()
214 .map(|cmd| self.kind.try_quote_prefix_aware(&cmd).map(Cow::into_owned))
215 .unwrap_or(task_command);
216 }
217 let (program, args) = self.build(task_command, task_args);
218
219 let mut child = crate::command::new_std_command(program);
220
221 #[cfg(windows)]
222 if kind == ShellKind::Cmd {
223 use std::os::windows::process::CommandExt;
224
225 for arg in args {
226 child.raw_arg(arg);
227 }
228 } else {
229 child.args(args);
230 }
231
232 #[cfg(not(windows))]
233 child.args(args);
234
235 child
236 }
237
238 pub fn kind(&self) -> ShellKind {
239 self.kind
240 }
241}
242
243#[cfg(test)]
244mod test {
245 use super::*;
246
247 #[test]
248 fn test_nu_shell_variable_substitution() {
249 let shell = Shell::Program("nu".to_owned());
250 let shell_builder = ShellBuilder::new(&shell, false);
251
252 let (program, args) = shell_builder.build(
253 Some("echo".into()),
254 &[
255 "${hello}".to_string(),
256 "$world".to_string(),
257 "nothing".to_string(),
258 "--$something".to_string(),
259 "$".to_string(),
260 "${test".to_string(),
261 ],
262 );
263
264 assert_eq!(program, "nu");
265 assert_eq!(
266 args,
267 vec![
268 "-i",
269 "-c",
270 "echo '$env.hello' '$env.world' nothing '--($env.something)' '$' '${test'"
271 ]
272 );
273 }
274
275 #[test]
276 fn redirect_stdin_to_dev_null_precedence() {
277 let shell = Shell::Program("nu".to_owned());
278 let shell_builder = ShellBuilder::new(&shell, false);
279
280 let (program, args) = shell_builder
281 .redirect_stdin_to_dev_null()
282 .build(Some("echo".into()), &["nothing".to_string()]);
283
284 assert_eq!(program, "nu");
285 assert_eq!(args, vec!["-i", "-c", "(echo nothing\n) </dev/null"]);
286 }
287
288 #[test]
289 fn redirect_stdin_to_dev_null_fish() {
290 let shell = Shell::Program("fish".to_owned());
291 let shell_builder = ShellBuilder::new(&shell, false);
292
293 let (program, args) = shell_builder
294 .redirect_stdin_to_dev_null()
295 .build(Some("echo".into()), &["test".to_string()]);
296
297 assert_eq!(program, "fish");
298 assert_eq!(args, vec!["-i", "-c", "begin; echo test; end </dev/null"]);
299 }
300
301 #[test]
302 fn redirect_stdin_to_dev_null_preserves_heredoc() {
303 let shell = Shell::Program("sh".to_owned());
304 let shell_builder = ShellBuilder::new(&shell, false);
305
306 let command = "cat <<EOF\nhello\nEOF";
307 let (program, args) = shell_builder
308 .redirect_stdin_to_dev_null()
309 .build(Some(command.into()), &[]);
310
311 assert_eq!(program, "sh");
312 assert_eq!(
313 args,
314 vec!["-i", "-c", "exec </dev/null\ncat <<EOF\nhello\nEOF"]
315 );
316 }
317
318 #[test]
319 fn non_interactive_omits_interactive_flag() {
320 let shell = Shell::Program("sh".to_owned());
323 let shell_builder = ShellBuilder::new(&shell, false).non_interactive();
324
325 let (program, args) = shell_builder.build(Some("echo hello".into()), &[]);
326
327 assert_eq!(program, "sh");
328 assert_eq!(args, vec!["-c", "echo hello"]);
329 assert!(
330 !args.iter().any(|arg| arg == "-i"),
331 "non-interactive shell command must not include `-i`"
332 );
333 }
334
335 #[test]
336 fn does_not_quote_sole_command_only() {
337 let shell = Shell::Program("fish".to_owned());
338 let shell_builder = ShellBuilder::new(&shell, false);
339
340 let (program, args) = shell_builder.build(Some("echo".into()), &[]);
341
342 assert_eq!(program, "fish");
343 assert_eq!(args, vec!["-i", "-c", "echo"]);
344
345 let shell = Shell::Program("fish".to_owned());
346 let shell_builder = ShellBuilder::new(&shell, false);
347
348 let (program, args) = shell_builder.build(Some("echo oo".into()), &[]);
349
350 assert_eq!(program, "fish");
351 assert_eq!(args, vec!["-i", "-c", "echo oo"]);
352 }
353
354 #[test]
355 fn windows_powershell_preserves_spaced_arg_as_single_shell_argument() {
356 let worktree_root = r"C:\worktrees\Godot Projects\sample-game";
357 let shell = Shell::Program("powershell".to_owned());
358
359 let (program, args) = ShellBuilder::new(&shell, true)
360 .build(Some("echo".into()), &[worktree_root.to_string()]);
361
362 assert_eq!(program, "powershell");
363 assert_eq!(
364 args,
365 vec!["-C".to_string(), format!("echo '{worktree_root}'")]
366 );
367 }
368
369 #[test]
370 fn windows_cmd_preserves_spaced_arg_as_single_shell_argument() {
371 let worktree_root = r"C:\worktrees\Godot Projects\sample-game";
372 let shell = Shell::Program("cmd".to_owned());
373
374 let (program, args) = ShellBuilder::new(&shell, true)
375 .build(Some("echo".into()), &[worktree_root.to_string()]);
376
377 assert_eq!(program, "cmd");
378 assert_eq!(
379 args,
380 vec![
381 "/S".to_string(),
382 "/C".to_string(),
383 format!("\"echo ^\"{worktree_root}^\"\""),
384 ]
385 );
386 }
387}