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