Skip to main content

util/
shell.rs

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