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    kind: ShellKind,
16}
17
18impl ShellBuilder {
19    /// Create a new ShellBuilder as configured.
20    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    /// Returns the label to show in the terminal tab
42    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    /// Returns the program and arguments to run this task in a shell.
77    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                        // Perform the STDIN redirection prior to the actual
104                        // command on a separate line, so that it is already
105                        // active if the command contains a syntax error.
106                        // Otherwise, with -i, dash will fall back to an
107                        // interactive shell in this case.
108                        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    // This should not exist, but our task infra is broken beyond repair right now
141    #[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    /// Builds a `smol::process::Command` with the given task command and arguments.
189    ///
190    /// Prefer this over manually constructing a command with the output of `Self::build`,
191    /// as this method handles `cmd` weirdness on windows correctly.
192    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    /// Builds a `std::process::Command` with the given task command and arguments.
201    ///
202    /// Prefer this over manually constructing a command with the output of `Self::build`,
203    /// as this method handles `cmd` weirdness on windows correctly.
204    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        // Headless hosts (e.g. the eval CLI) build the agent's shell command
321        // non-interactively so it works without a controlling TTY.
322        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}