Skip to main content

util/
shell.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::{borrow::Cow, fmt, path::Path};
4#[cfg(windows)]
5use std::{path::PathBuf, sync::LazyLock};
6
7/// Shell configuration to open the terminal with.
8#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Hash)]
9#[serde(rename_all = "snake_case")]
10pub enum Shell {
11    /// Use the system's default terminal configuration in /etc/passwd
12    #[default]
13    System,
14    /// Use a specific program with no arguments.
15    Program(String),
16    /// Use a specific program with arguments.
17    WithArguments {
18        /// The program to run.
19        program: String,
20        /// The arguments to pass to the program.
21        args: Vec<String>,
22        /// An optional string to override the title of the terminal tab
23        title_override: Option<String>,
24    },
25}
26
27impl Shell {
28    pub fn program(&self) -> String {
29        match self {
30            Shell::Program(program) => program.clone(),
31            Shell::WithArguments { program, .. } => program.clone(),
32            Shell::System => get_system_shell(),
33        }
34    }
35
36    pub fn program_and_args(&self) -> (String, &[String]) {
37        match self {
38            Shell::Program(program) => (program.clone(), &[]),
39            Shell::WithArguments { program, args, .. } => (program.clone(), args),
40            Shell::System => (get_system_shell(), &[]),
41        }
42    }
43
44    pub fn shell_kind(&self, is_windows: bool) -> ShellKind {
45        match self {
46            Shell::Program(program) => ShellKind::new(program, is_windows),
47            Shell::WithArguments { program, .. } => ShellKind::new(program, is_windows),
48            Shell::System => ShellKind::system(),
49        }
50    }
51}
52
53#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub enum ShellKind {
55    #[default]
56    Posix,
57    Csh,
58    Tcsh,
59    Rc,
60    Fish,
61    /// Pre-installed "legacy" powershell for windows
62    PowerShell,
63    /// PowerShell 7.x
64    Pwsh,
65    Nushell,
66    Cmd,
67    Xonsh,
68    Elvish,
69}
70
71pub fn get_system_shell() -> String {
72    if cfg!(windows) {
73        get_windows_system_shell()
74    } else {
75        std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
76    }
77}
78
79pub fn get_default_system_shell() -> String {
80    if cfg!(windows) {
81        get_windows_system_shell()
82    } else {
83        "/bin/sh".to_string()
84    }
85}
86
87/// Get the default system shell, preferring bash on Windows.
88pub fn get_default_system_shell_preferring_bash() -> String {
89    #[cfg(windows)]
90    {
91        get_windows_bash().unwrap_or_else(|| get_windows_system_shell())
92    }
93
94    #[cfg(not(windows))]
95    {
96        "/bin/sh".to_string()
97    }
98}
99
100#[cfg(windows)]
101pub fn get_windows_bash() -> Option<String> {
102    fn find_bash_in_installation(install_root: &Path) -> Option<PathBuf> {
103        if !install_root.join("git-bash.exe").is_file() {
104            return None;
105        }
106        let bash = install_root.join("bin").join("bash.exe");
107        bash.is_file().then_some(bash)
108    }
109
110    fn find_bash_in_git() -> Option<PathBuf> {
111        if let Some(bash) = std::env::var_os("GIT_INSTALL_ROOT")
112            .map(PathBuf::from)
113            .and_then(|path| find_bash_in_installation(&path))
114        {
115            return Some(bash);
116        }
117        let git = which::which("git").ok()?;
118        let binary_directory = git.parent()?;
119        let parent = binary_directory.parent()?;
120        find_bash_in_installation(parent).or_else(|| find_bash_in_installation(parent.parent()?))
121    }
122
123    static BASH: LazyLock<Option<String>> = LazyLock::new(|| {
124        let bash = find_bash_in_git().map(|p| p.to_string_lossy().into_owned());
125        if let Some(ref path) = bash {
126            log::info!("Found bash at {}", path);
127        }
128        bash
129    });
130
131    (*BASH).clone()
132}
133
134pub fn get_windows_system_shell() -> String {
135    #[cfg(windows)]
136    return gpui_util::get_windows_system_shell();
137
138    #[cfg(not(windows))]
139    return "cmd.exe".to_string();
140}
141
142impl fmt::Display for ShellKind {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        match self {
145            ShellKind::Posix => write!(f, "sh"),
146            ShellKind::Csh => write!(f, "csh"),
147            ShellKind::Tcsh => write!(f, "tcsh"),
148            ShellKind::Fish => write!(f, "fish"),
149            ShellKind::PowerShell => write!(f, "powershell"),
150            ShellKind::Pwsh => write!(f, "pwsh"),
151            ShellKind::Nushell => write!(f, "nu"),
152            ShellKind::Cmd => write!(f, "cmd"),
153            ShellKind::Rc => write!(f, "rc"),
154            ShellKind::Xonsh => write!(f, "xonsh"),
155            ShellKind::Elvish => write!(f, "elvish"),
156        }
157    }
158}
159
160impl ShellKind {
161    pub fn system() -> Self {
162        Self::new(&get_system_shell(), cfg!(windows))
163    }
164
165    /// Returns whether this shell's command chaining syntax can be parsed by brush-parser.
166    ///
167    /// This is used to determine if we can safely parse shell commands to extract sub-commands
168    /// for security purposes (e.g., preventing shell injection in "always allow" patterns).
169    ///
170    /// The brush-parser handles `;` (sequential execution) and `|` (piping), which are
171    /// supported by all common shells. It also handles `&&` and `||` for conditional
172    /// execution, `$()` and backticks for command substitution, and process substitution.
173    ///
174    /// # Shell Notes
175    ///
176    /// - **Nushell**: Uses `;` for sequential execution. The `and`/`or` keywords are boolean
177    ///   operators on values (e.g., `$true and $false`), not command chaining operators.
178    /// - **Elvish**: Uses `;` to separate pipelines, which brush-parser handles. Elvish does
179    ///   not have `&&` or `||` operators. Its `and`/`or` are special commands that operate
180    ///   on values, not command chaining (e.g., `and $true $false`).
181    /// - **Rc (Plan 9)**: Uses `;` for sequential execution and `|` for piping. Does not
182    ///   have `&&`/`||` operators for conditional chaining.
183    /// All current shell variants are listed here because brush-parser can handle
184    /// their syntax. If a new `ShellKind` variant is added, evaluate whether
185    /// brush-parser can safely parse its command chaining syntax before including
186    /// it. Omitting a variant will cause `tool_permissions::from_input` to deny
187    /// terminal commands that have `always_allow` patterns configured.
188    pub fn supports_posix_chaining(&self) -> bool {
189        matches!(
190            self,
191            ShellKind::Posix
192                | ShellKind::Fish
193                | ShellKind::PowerShell
194                | ShellKind::Pwsh
195                | ShellKind::Cmd
196                | ShellKind::Xonsh
197                | ShellKind::Csh
198                | ShellKind::Tcsh
199                | ShellKind::Nushell
200                | ShellKind::Elvish
201                | ShellKind::Rc
202        )
203    }
204
205    pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
206        let program = program.as_ref();
207        let program = program
208            .file_stem()
209            .unwrap_or_else(|| program.as_os_str())
210            .to_string_lossy();
211
212        match &*program {
213            "powershell" => ShellKind::PowerShell,
214            "pwsh" => ShellKind::Pwsh,
215            "cmd" => ShellKind::Cmd,
216            "nu" => ShellKind::Nushell,
217            "fish" => ShellKind::Fish,
218            "csh" => ShellKind::Csh,
219            "tcsh" => ShellKind::Tcsh,
220            "rc" => ShellKind::Rc,
221            "xonsh" => ShellKind::Xonsh,
222            "elvish" => ShellKind::Elvish,
223            "sh" | "bash" | "zsh" => ShellKind::Posix,
224            _ if is_windows => ShellKind::PowerShell,
225            // Some other shell detected, the user might install and use a
226            // unix-like shell.
227            _ => ShellKind::Posix,
228        }
229    }
230
231    pub fn to_shell_variable(self, input: &str) -> String {
232        match self {
233            Self::PowerShell | Self::Pwsh => Self::to_powershell_variable(input),
234            Self::Cmd => Self::to_cmd_variable(input),
235            Self::Posix => input.to_owned(),
236            Self::Fish => input.to_owned(),
237            Self::Csh => input.to_owned(),
238            Self::Tcsh => input.to_owned(),
239            Self::Rc => input.to_owned(),
240            Self::Nushell => Self::to_nushell_variable(input),
241            Self::Xonsh => input.to_owned(),
242            Self::Elvish => input.to_owned(),
243        }
244    }
245
246    fn to_cmd_variable(input: &str) -> String {
247        if let Some(var_str) = input.strip_prefix("${") {
248            match var_str.strip_suffix('}') {
249                Some(var_name) if !var_name.is_empty() && !var_name.contains(':') => {
250                    format!("%{var_name}%")
251                }
252                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
253                // which will result in the task failing to run in such cases.
254                _ => input.into(),
255            }
256        } else if let Some(var_str) = input.strip_prefix('$') {
257            // If the input starts with "$", directly append to "$env:"
258            format!("%{}%", var_str)
259        } else {
260            // If no prefix is found, return the input as is
261            input.into()
262        }
263    }
264
265    fn to_powershell_variable(input: &str) -> String {
266        if let Some(var_str) = input.strip_prefix("${") {
267            match var_str.strip_suffix('}') {
268                Some(var_name) if !var_name.is_empty() && !var_name.contains(':') => {
269                    format!("$env:{var_name}")
270                }
271                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
272                // which will result in the task failing to run in such cases.
273                _ => input.into(),
274            }
275        } else if let Some(var_str) = input.strip_prefix('$') {
276            // If the input starts with "$", directly append to "$env:"
277            format!("$env:{}", var_str)
278        } else {
279            // If no prefix is found, return the input as is
280            input.into()
281        }
282    }
283
284    fn to_nushell_variable(input: &str) -> String {
285        let mut result = String::new();
286        let mut source = input;
287        let mut is_start = true;
288
289        loop {
290            match source.chars().next() {
291                None => return result,
292                Some('$') => {
293                    source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
294                    is_start = false;
295                }
296                Some(_) => {
297                    is_start = false;
298                    let chunk_end = source.find('$').unwrap_or(source.len());
299                    let (chunk, rest) = source.split_at(chunk_end);
300                    result.push_str(chunk);
301                    source = rest;
302                }
303            }
304        }
305    }
306
307    fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
308        if source.starts_with("env.") {
309            text.push('$');
310            return source;
311        }
312
313        match source.chars().next() {
314            Some('{') => {
315                let source = &source[1..];
316                if let Some(end) = source.find('}') {
317                    let var_name = &source[..end];
318                    if !var_name.is_empty() {
319                        if !is_start {
320                            text.push_str("(");
321                        }
322                        text.push_str("$env.");
323                        text.push_str(var_name);
324                        if !is_start {
325                            text.push_str(")");
326                        }
327                        &source[end + 1..]
328                    } else {
329                        text.push_str("${}");
330                        &source[end + 1..]
331                    }
332                } else {
333                    text.push_str("${");
334                    source
335                }
336            }
337            Some(c) if c.is_alphabetic() || c == '_' => {
338                let end = source
339                    .find(|c: char| !c.is_alphanumeric() && c != '_')
340                    .unwrap_or(source.len());
341                let var_name = &source[..end];
342                if !is_start {
343                    text.push_str("(");
344                }
345                text.push_str("$env.");
346                text.push_str(var_name);
347                if !is_start {
348                    text.push_str(")");
349                }
350                &source[end..]
351            }
352            _ => {
353                text.push('$');
354                source
355            }
356        }
357    }
358
359    pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
360        match self {
361            ShellKind::PowerShell | ShellKind::Pwsh => vec!["-C".to_owned(), combined_command],
362            ShellKind::Cmd => vec![
363                "/S".to_owned(),
364                "/C".to_owned(),
365                format!("\"{combined_command}\""),
366            ],
367            ShellKind::Posix
368            | ShellKind::Nushell
369            | ShellKind::Fish
370            | ShellKind::Csh
371            | ShellKind::Tcsh
372            | ShellKind::Rc
373            | ShellKind::Xonsh
374            | ShellKind::Elvish => interactive
375                .then(|| "-i".to_owned())
376                .into_iter()
377                .chain(["-c".to_owned(), combined_command])
378                .collect(),
379        }
380    }
381
382    pub const fn command_prefix(&self) -> Option<char> {
383        match self {
384            ShellKind::PowerShell | ShellKind::Pwsh => Some('&'),
385            ShellKind::Nushell => Some('^'),
386            ShellKind::Posix
387            | ShellKind::Csh
388            | ShellKind::Tcsh
389            | ShellKind::Rc
390            | ShellKind::Fish
391            | ShellKind::Cmd
392            | ShellKind::Xonsh
393            | ShellKind::Elvish => None,
394        }
395    }
396
397    pub fn prepend_command_prefix<'a>(&self, command: &'a str) -> Cow<'a, str> {
398        match self.command_prefix() {
399            Some(prefix) if !command.starts_with(prefix) => {
400                Cow::Owned(format!("{prefix}{command}"))
401            }
402            _ => Cow::Borrowed(command),
403        }
404    }
405
406    pub const fn sequential_commands_separator(&self) -> char {
407        match self {
408            ShellKind::Cmd => '&',
409            ShellKind::Posix
410            | ShellKind::Csh
411            | ShellKind::Tcsh
412            | ShellKind::Rc
413            | ShellKind::Fish
414            | ShellKind::PowerShell
415            | ShellKind::Pwsh
416            | ShellKind::Nushell
417            | ShellKind::Xonsh
418            | ShellKind::Elvish => ';',
419        }
420    }
421
422    pub const fn sequential_and_commands_separator(&self) -> &'static str {
423        match self {
424            ShellKind::Cmd
425            | ShellKind::Posix
426            | ShellKind::Csh
427            | ShellKind::Tcsh
428            | ShellKind::Rc
429            | ShellKind::Fish
430            | ShellKind::Pwsh
431            | ShellKind::Xonsh => "&&",
432            ShellKind::PowerShell | ShellKind::Nushell | ShellKind::Elvish => ";",
433        }
434    }
435
436    pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
437        match self {
438            ShellKind::PowerShell => Some(Self::quote_powershell(arg)),
439            ShellKind::Pwsh => Some(Self::quote_pwsh(arg)),
440            ShellKind::Cmd => Some(Self::quote_cmd(arg)),
441            ShellKind::Posix
442            | ShellKind::Csh
443            | ShellKind::Tcsh
444            | ShellKind::Rc
445            | ShellKind::Fish
446            | ShellKind::Nushell
447            | ShellKind::Xonsh
448            | ShellKind::Elvish => shlex::try_quote(arg).ok(),
449        }
450    }
451
452    fn quote_windows(arg: &str, enclose: bool) -> Cow<'_, str> {
453        if arg.is_empty() {
454            return Cow::Borrowed("\"\"");
455        }
456
457        let needs_quoting = arg.chars().any(|c| c == ' ' || c == '\t' || c == '"');
458        if !needs_quoting {
459            return Cow::Borrowed(arg);
460        }
461
462        let mut result = String::with_capacity(arg.len() + 2);
463
464        if enclose {
465            result.push('"');
466        }
467
468        let chars: Vec<char> = arg.chars().collect();
469        let mut i = 0;
470
471        while i < chars.len() {
472            if chars[i] == '\\' {
473                let mut num_backslashes = 0;
474                while i < chars.len() && chars[i] == '\\' {
475                    num_backslashes += 1;
476                    i += 1;
477                }
478
479                if i < chars.len() && chars[i] == '"' {
480                    // Backslashes followed by quote: double the backslashes and escape the quote
481                    for _ in 0..(num_backslashes * 2 + 1) {
482                        result.push('\\');
483                    }
484                    result.push('"');
485                    i += 1;
486                } else if i >= chars.len() {
487                    // Trailing backslashes: double them (they precede the closing quote)
488                    for _ in 0..(num_backslashes * 2) {
489                        result.push('\\');
490                    }
491                } else {
492                    // Backslashes not followed by quote: output as-is
493                    for _ in 0..num_backslashes {
494                        result.push('\\');
495                    }
496                }
497            } else if chars[i] == '"' {
498                // Quote not preceded by backslash: escape it
499                result.push('\\');
500                result.push('"');
501                i += 1;
502            } else {
503                result.push(chars[i]);
504                i += 1;
505            }
506        }
507
508        if enclose {
509            result.push('"');
510        }
511        Cow::Owned(result)
512    }
513
514    fn needs_quoting_powershell(s: &str) -> bool {
515        s.is_empty()
516            || s.chars().any(|c| {
517                c.is_whitespace()
518                    || matches!(
519                        c,
520                        '"' | '`'
521                            | '$'
522                            | '&'
523                            | '|'
524                            | '<'
525                            | '>'
526                            | ';'
527                            | '('
528                            | ')'
529                            | '['
530                            | ']'
531                            | '{'
532                            | '}'
533                            | ','
534                            | '\''
535                            | '@'
536                    )
537            })
538    }
539
540    fn need_quotes_powershell(arg: &str) -> bool {
541        let mut quote_count = 0;
542        for c in arg.chars() {
543            if c == '"' {
544                quote_count += 1;
545            } else if c.is_whitespace() && (quote_count % 2 == 0) {
546                return true;
547            }
548        }
549        false
550    }
551
552    fn escape_powershell_quotes(s: &str) -> String {
553        let mut result = String::with_capacity(s.len() + 4);
554        result.push('\'');
555        for c in s.chars() {
556            if c == '\'' {
557                result.push('\'');
558            }
559            result.push(c);
560        }
561        result.push('\'');
562        result
563    }
564
565    pub fn quote_powershell(arg: &str) -> Cow<'_, str> {
566        let ps_will_quote = Self::need_quotes_powershell(arg);
567        let crt_quoted = Self::quote_windows(arg, !ps_will_quote);
568
569        if !Self::needs_quoting_powershell(arg) {
570            return crt_quoted;
571        }
572
573        Cow::Owned(Self::escape_powershell_quotes(&crt_quoted))
574    }
575
576    pub fn quote_pwsh(arg: &str) -> Cow<'_, str> {
577        if arg.is_empty() {
578            return Cow::Borrowed("''");
579        }
580
581        if !Self::needs_quoting_powershell(arg) {
582            return Cow::Borrowed(arg);
583        }
584
585        Cow::Owned(Self::escape_powershell_quotes(arg))
586    }
587
588    pub fn quote_cmd(arg: &str) -> Cow<'_, str> {
589        let crt_quoted = Self::quote_windows(arg, true);
590
591        let needs_cmd_escaping = crt_quoted.contains(['"', '%', '^', '<', '>', '&', '|', '(', ')']);
592
593        if !needs_cmd_escaping {
594            return crt_quoted;
595        }
596
597        let mut result = String::with_capacity(crt_quoted.len() * 2);
598        for c in crt_quoted.chars() {
599            match c {
600                '^' | '"' | '<' | '>' | '&' | '|' | '(' | ')' => {
601                    result.push('^');
602                    result.push(c);
603                }
604                '%' => {
605                    result.push_str("%%cd:~,%");
606                }
607                _ => result.push(c),
608            }
609        }
610        Cow::Owned(result)
611    }
612
613    /// Quotes the given argument if necessary, taking into account the command prefix.
614    ///
615    /// In other words, this will consider quoting arg without its command prefix to not break the command.
616    /// You should use this over `try_quote` when you want to quote a shell command.
617    pub fn try_quote_prefix_aware<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
618        if let Some(char) = self.command_prefix() {
619            if let Some(arg) = arg.strip_prefix(char) {
620                // we have a command that is prefixed
621                for quote in ['\'', '"'] {
622                    if let Some(arg) = arg
623                        .strip_prefix(quote)
624                        .and_then(|arg| arg.strip_suffix(quote))
625                    {
626                        // and the command itself is wrapped as a literal, that
627                        // means the prefix exists to interpret a literal as a
628                        // command. So strip the quotes, quote the command, and
629                        // re-add the quotes if they are missing after requoting
630                        let quoted = self.try_quote(arg)?;
631                        return Some(if quoted.starts_with(['\'', '"']) {
632                            Cow::Owned(self.prepend_command_prefix(&quoted).into_owned())
633                        } else {
634                            Cow::Owned(
635                                self.prepend_command_prefix(&format!("{quote}{quoted}{quote}"))
636                                    .into_owned(),
637                            )
638                        });
639                    }
640                }
641                return self
642                    .try_quote(arg)
643                    .map(|quoted| Cow::Owned(self.prepend_command_prefix(&quoted).into_owned()));
644            }
645        }
646        self.try_quote(arg).map(|quoted| match quoted {
647            unquoted @ Cow::Borrowed(_) => unquoted,
648            Cow::Owned(quoted) => Cow::Owned(self.prepend_command_prefix(&quoted).into_owned()),
649        })
650    }
651
652    pub fn split(&self, input: &str) -> Option<Vec<String>> {
653        shlex::split(input)
654    }
655
656    pub const fn activate_keyword(&self) -> &'static str {
657        match self {
658            ShellKind::Cmd => "",
659            ShellKind::Nushell => "overlay use",
660            ShellKind::PowerShell | ShellKind::Pwsh => ".",
661            ShellKind::Fish
662            | ShellKind::Csh
663            | ShellKind::Tcsh
664            | ShellKind::Posix
665            | ShellKind::Rc
666            | ShellKind::Xonsh
667            | ShellKind::Elvish => "source",
668        }
669    }
670
671    pub const fn clear_screen_command(&self) -> &'static str {
672        match self {
673            ShellKind::Cmd => "cls",
674            ShellKind::Posix
675            | ShellKind::Csh
676            | ShellKind::Tcsh
677            | ShellKind::Rc
678            | ShellKind::Fish
679            | ShellKind::PowerShell
680            | ShellKind::Pwsh
681            | ShellKind::Nushell
682            | ShellKind::Xonsh
683            | ShellKind::Elvish => "clear",
684        }
685    }
686
687    #[cfg(windows)]
688    /// We do not want to escape arguments if we are using CMD as our shell.
689    /// If we do we end up with too many quotes/escaped quotes for CMD to handle.
690    pub const fn tty_escape_args(&self) -> bool {
691        match self {
692            ShellKind::Cmd => false,
693            ShellKind::Posix
694            | ShellKind::Csh
695            | ShellKind::Tcsh
696            | ShellKind::Rc
697            | ShellKind::Fish
698            | ShellKind::PowerShell
699            | ShellKind::Pwsh
700            | ShellKind::Nushell
701            | ShellKind::Xonsh
702            | ShellKind::Elvish => true,
703        }
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710
711    // Examples
712    // WSL
713    // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "echo hello"
714    // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "\"echo hello\"" | grep hello"
715    // wsl.exe --distribution NixOS --cd ~ env RUST_LOG=info,remote=debug .zed_wsl_server/zed-remote-server-dev-build proxy --identifier dev-workspace-53
716    // PowerShell from Nushell
717    // nu -c overlay use "C:\Users\kubko\dev\python\39007\tests\.venv\Scripts\activate.nu"; ^"C:\Program Files\PowerShell\7\pwsh.exe" -C "C:\Users\kubko\dev\python\39007\tests\.venv\Scripts\python.exe -m pytest \"test_foo.py::test_foo\""
718    // PowerShell from CMD
719    // cmd /C \" \"C:\\\\Users\\\\kubko\\\\dev\\\\python\\\\39007\\\\tests\\\\.venv\\\\Scripts\\\\activate.bat\"& \"C:\\\\Program Files\\\\PowerShell\\\\7\\\\pwsh.exe\" -C \"C:\\\\Users\\\\kubko\\\\dev\\\\python\\\\39007\\\\tests\\\\.venv\\\\Scripts\\\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"\"\"
720
721    #[test]
722    fn test_try_quote_powershell() {
723        let shell_kind = ShellKind::PowerShell;
724        assert_eq!(
725            shell_kind
726                .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
727                .unwrap()
728                .into_owned(),
729            "'C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"'".to_string()
730        );
731    }
732
733    #[test]
734    fn test_try_quote_cmd() {
735        let shell_kind = ShellKind::Cmd;
736        assert_eq!(
737            shell_kind
738                .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
739                .unwrap()
740                .into_owned(),
741            "^\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\^\"test_foo.py::test_foo\\^\"^\"".to_string()
742        );
743    }
744
745    #[test]
746    fn test_try_quote_powershell_edge_cases() {
747        let shell_kind = ShellKind::PowerShell;
748
749        // Empty string
750        assert_eq!(
751            shell_kind.try_quote("").unwrap().into_owned(),
752            "'\"\"'".to_string()
753        );
754
755        // String without special characters (no quoting needed)
756        assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
757
758        // String with spaces
759        assert_eq!(
760            shell_kind.try_quote("hello world").unwrap().into_owned(),
761            "'hello world'".to_string()
762        );
763
764        // String with dollar signs
765        assert_eq!(
766            shell_kind.try_quote("$variable").unwrap().into_owned(),
767            "'$variable'".to_string()
768        );
769
770        // String with backticks
771        assert_eq!(
772            shell_kind.try_quote("test`command").unwrap().into_owned(),
773            "'test`command'".to_string()
774        );
775
776        // String with multiple special characters
777        assert_eq!(
778            shell_kind
779                .try_quote("test `\"$var`\" end")
780                .unwrap()
781                .into_owned(),
782            "'test `\\\"$var`\\\" end'".to_string()
783        );
784
785        // String with backslashes and colon (path without spaces doesn't need quoting)
786        assert_eq!(
787            shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
788            "C:\\path\\to\\file"
789        );
790    }
791
792    #[test]
793    fn test_try_quote_cmd_edge_cases() {
794        let shell_kind = ShellKind::Cmd;
795
796        // Empty string
797        assert_eq!(
798            shell_kind.try_quote("").unwrap().into_owned(),
799            "^\"^\"".to_string()
800        );
801
802        // String without special characters (no quoting needed)
803        assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
804
805        // String with spaces
806        assert_eq!(
807            shell_kind.try_quote("hello world").unwrap().into_owned(),
808            "^\"hello world^\"".to_string()
809        );
810
811        // String with space and backslash (backslash not at end, so not doubled)
812        assert_eq!(
813            shell_kind.try_quote("path\\ test").unwrap().into_owned(),
814            "^\"path\\ test^\"".to_string()
815        );
816
817        // String ending with backslash (must be doubled before closing quote)
818        assert_eq!(
819            shell_kind.try_quote("test path\\").unwrap().into_owned(),
820            "^\"test path\\\\^\"".to_string()
821        );
822
823        // String ending with multiple backslashes (all doubled before closing quote)
824        assert_eq!(
825            shell_kind.try_quote("test path\\\\").unwrap().into_owned(),
826            "^\"test path\\\\\\\\^\"".to_string()
827        );
828
829        // String with embedded quote (quote is escaped, backslash before it is doubled)
830        assert_eq!(
831            shell_kind.try_quote("test\\\"quote").unwrap().into_owned(),
832            "^\"test\\\\\\^\"quote^\"".to_string()
833        );
834
835        // String with multiple backslashes before embedded quote (all doubled)
836        assert_eq!(
837            shell_kind
838                .try_quote("test\\\\\"quote")
839                .unwrap()
840                .into_owned(),
841            "^\"test\\\\\\\\\\^\"quote^\"".to_string()
842        );
843
844        // String with backslashes not before quotes (path without spaces doesn't need quoting)
845        assert_eq!(
846            shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
847            "C:\\path\\to\\file"
848        );
849    }
850
851    #[test]
852    fn test_try_quote_nu_command() {
853        let shell_kind = ShellKind::Nushell;
854        assert_eq!(
855            shell_kind.try_quote("'uname'").unwrap().into_owned(),
856            "\"'uname'\"".to_string()
857        );
858        assert_eq!(
859            shell_kind
860                .try_quote_prefix_aware("'uname'")
861                .unwrap()
862                .into_owned(),
863            "^\"'uname'\"".to_string()
864        );
865        assert_eq!(
866            shell_kind.try_quote("^uname").unwrap().into_owned(),
867            "'^uname'".to_string()
868        );
869        assert_eq!(
870            shell_kind
871                .try_quote_prefix_aware("^uname")
872                .unwrap()
873                .into_owned(),
874            "^uname".to_string()
875        );
876        assert_eq!(
877            shell_kind.try_quote("^'uname'").unwrap().into_owned(),
878            "'^'\"'uname\'\"".to_string()
879        );
880        assert_eq!(
881            shell_kind
882                .try_quote_prefix_aware("^'uname'")
883                .unwrap()
884                .into_owned(),
885            "^'uname'".to_string()
886        );
887        assert_eq!(
888            shell_kind.try_quote("'uname a'").unwrap().into_owned(),
889            "\"'uname a'\"".to_string()
890        );
891        assert_eq!(
892            shell_kind
893                .try_quote_prefix_aware("'uname a'")
894                .unwrap()
895                .into_owned(),
896            "^\"'uname a'\"".to_string()
897        );
898        assert_eq!(
899            shell_kind.try_quote("^'uname a'").unwrap().into_owned(),
900            "'^'\"'uname a'\"".to_string()
901        );
902        assert_eq!(
903            shell_kind
904                .try_quote_prefix_aware("^'uname a'")
905                .unwrap()
906                .into_owned(),
907            "^'uname a'".to_string()
908        );
909        assert_eq!(
910            shell_kind.try_quote("uname").unwrap().into_owned(),
911            "uname".to_string()
912        );
913        assert_eq!(
914            shell_kind
915                .try_quote_prefix_aware("uname")
916                .unwrap()
917                .into_owned(),
918            "uname".to_string()
919        );
920    }
921
922    #[test]
923    fn test_try_quote_single_quote_paths() {
924        let path_with_quote = r"C:\Temp\O'Brien\repo";
925        let shlex_shells = [
926            ShellKind::Posix,
927            ShellKind::Fish,
928            ShellKind::Csh,
929            ShellKind::Tcsh,
930            ShellKind::Rc,
931            ShellKind::Xonsh,
932            ShellKind::Elvish,
933            ShellKind::Nushell,
934        ];
935
936        for shell_kind in shlex_shells {
937            let quoted = shell_kind.try_quote(path_with_quote).unwrap().into_owned();
938            assert_ne!(quoted, path_with_quote);
939            assert_eq!(
940                shlex::split(&quoted),
941                Some(vec![path_with_quote.to_string()])
942            );
943
944            if shell_kind == ShellKind::Nushell {
945                let prefixed = shell_kind.prepend_command_prefix(&quoted);
946                assert!(prefixed.starts_with('^'));
947            }
948        }
949
950        for shell_kind in [ShellKind::PowerShell, ShellKind::Pwsh] {
951            let quoted = shell_kind.try_quote(path_with_quote).unwrap().into_owned();
952            assert!(quoted.starts_with('\''));
953            assert!(quoted.ends_with('\''));
954            assert!(quoted.contains("O''Brien"));
955        }
956    }
957
958    #[test]
959    fn test_to_shell_variable() {
960        assert_eq!(
961            ShellKind::PowerShell.to_shell_variable("${FOO}"),
962            "$env:FOO"
963        );
964        assert_eq!(ShellKind::Pwsh.to_shell_variable("${FOO}"), "$env:FOO");
965        assert_eq!(ShellKind::Cmd.to_shell_variable("${FOO}"), "%FOO%");
966        assert_eq!(ShellKind::Nushell.to_shell_variable("${FOO}"), "$env.FOO");
967        assert_eq!(ShellKind::Posix.to_shell_variable("${FOO}"), "${FOO}");
968
969        assert_eq!(ShellKind::PowerShell.to_shell_variable("$FOO"), "$env:FOO");
970        assert_eq!(
971            ShellKind::PowerShell.to_shell_variable("${日本}"),
972            "$env:日本"
973        );
974        assert_eq!(
975            ShellKind::PowerShell.to_shell_variable("${FOO:-bar}"),
976            "${FOO:-bar}"
977        );
978    }
979
980    #[test]
981    fn test_to_shell_variable_malformed_is_passed_through() {
982        for input in ["${", "${FOO", "${café", "${}", "${日本"] {
983            for shell_kind in [
984                ShellKind::PowerShell,
985                ShellKind::Pwsh,
986                ShellKind::Cmd,
987                ShellKind::Nushell,
988            ] {
989                assert_eq!(shell_kind.to_shell_variable(input), input);
990            }
991        }
992    }
993}