Skip to main content

util/
shell_builder.rs

1use std::borrow::Cow;
2
3use crate::shell::get_system_shell;
4use crate::shell::{Shell, ShellKind};
5
6/// ShellBuilder is used to turn a user-requested task into a
7/// program that can be executed by the shell.
8pub struct ShellBuilder {
9    /// The shell to run
10    program: String,
11    args: Vec<String>,
12    interactive: bool,
13    /// Whether to redirect stdin to /dev/null for the spawned command as a subshell.
14    redirect_stdin: bool,
15    substitute_variables: bool,
16    kind: ShellKind,
17}
18
19impl ShellBuilder {
20    /// Create a new ShellBuilder as configured.
21    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    /// Returns the label to show in the terminal tab
49    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    /// Returns the program and arguments to run this task in a shell.
84    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                        // Perform the STDIN redirection prior to the actual
115                        // command on a separate line, so that it is already
116                        // active if the command contains a syntax error.
117                        // Otherwise, with -i, dash will fall back to an
118                        // interactive shell in this case.
119                        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    // This should not exist, but our task infra is broken beyond repair right now
152    #[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    /// Builds a `smol::process::Command` with the given task command and arguments.
200    ///
201    /// Prefer this over manually constructing a command with the output of `Self::build`,
202    /// as this method handles `cmd` weirdness on windows correctly.
203    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    /// Builds a `std::process::Command` with the given task command and arguments.
212    ///
213    /// Prefer this over manually constructing a command with the output of `Self::build`,
214    /// as this method handles `cmd` weirdness on windows correctly.
215    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        // Headless hosts (e.g. the eval CLI) build the agent's shell command
357        // non-interactively so it works without a controlling TTY.
358        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}