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 uses POSIX-like command chaining syntax (`&&`, `||`, `;`, `|`).
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    /// **Compatible shells:** Posix (sh, bash, dash, zsh), Fish 3.0+, PowerShell 7+/Pwsh,
265    /// Cmd, Xonsh, Csh, Tcsh
266    ///
267    /// **Incompatible shells:** Nushell (uses `and`/`or` keywords), Elvish (uses `and`/`or`
268    /// keywords), Rc (Plan 9 shell - no `&&`/`||` operators)
269    pub fn supports_posix_chaining(&self) -> bool {
270        matches!(
271            self,
272            ShellKind::Posix
273                | ShellKind::Fish
274                | ShellKind::PowerShell
275                | ShellKind::Pwsh
276                | ShellKind::Cmd
277                | ShellKind::Xonsh
278                | ShellKind::Csh
279                | ShellKind::Tcsh
280        )
281    }
282
283    pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
284        let program = program.as_ref();
285        let program = program
286            .file_stem()
287            .unwrap_or_else(|| program.as_os_str())
288            .to_string_lossy();
289
290        match &*program {
291            "powershell" => ShellKind::PowerShell,
292            "pwsh" => ShellKind::Pwsh,
293            "cmd" => ShellKind::Cmd,
294            "nu" => ShellKind::Nushell,
295            "fish" => ShellKind::Fish,
296            "csh" => ShellKind::Csh,
297            "tcsh" => ShellKind::Tcsh,
298            "rc" => ShellKind::Rc,
299            "xonsh" => ShellKind::Xonsh,
300            "elvish" => ShellKind::Elvish,
301            "sh" | "bash" | "zsh" => ShellKind::Posix,
302            _ if is_windows => ShellKind::PowerShell,
303            // Some other shell detected, the user might install and use a
304            // unix-like shell.
305            _ => ShellKind::Posix,
306        }
307    }
308
309    pub fn to_shell_variable(self, input: &str) -> String {
310        match self {
311            Self::PowerShell | Self::Pwsh => Self::to_powershell_variable(input),
312            Self::Cmd => Self::to_cmd_variable(input),
313            Self::Posix => input.to_owned(),
314            Self::Fish => input.to_owned(),
315            Self::Csh => input.to_owned(),
316            Self::Tcsh => input.to_owned(),
317            Self::Rc => input.to_owned(),
318            Self::Nushell => Self::to_nushell_variable(input),
319            Self::Xonsh => input.to_owned(),
320            Self::Elvish => input.to_owned(),
321        }
322    }
323
324    fn to_cmd_variable(input: &str) -> String {
325        if let Some(var_str) = input.strip_prefix("${") {
326            if var_str.find(':').is_none() {
327                // If the input starts with "${", remove the trailing "}"
328                format!("%{}%", &var_str[..var_str.len() - 1])
329            } else {
330                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
331                // which will result in the task failing to run in such cases.
332                input.into()
333            }
334        } else if let Some(var_str) = input.strip_prefix('$') {
335            // If the input starts with "$", directly append to "$env:"
336            format!("%{}%", var_str)
337        } else {
338            // If no prefix is found, return the input as is
339            input.into()
340        }
341    }
342
343    fn to_powershell_variable(input: &str) -> String {
344        if let Some(var_str) = input.strip_prefix("${") {
345            if var_str.find(':').is_none() {
346                // If the input starts with "${", remove the trailing "}"
347                format!("$env:{}", &var_str[..var_str.len() - 1])
348            } else {
349                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
350                // which will result in the task failing to run in such cases.
351                input.into()
352            }
353        } else if let Some(var_str) = input.strip_prefix('$') {
354            // If the input starts with "$", directly append to "$env:"
355            format!("$env:{}", var_str)
356        } else {
357            // If no prefix is found, return the input as is
358            input.into()
359        }
360    }
361
362    fn to_nushell_variable(input: &str) -> String {
363        let mut result = String::new();
364        let mut source = input;
365        let mut is_start = true;
366
367        loop {
368            match source.chars().next() {
369                None => return result,
370                Some('$') => {
371                    source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
372                    is_start = false;
373                }
374                Some(_) => {
375                    is_start = false;
376                    let chunk_end = source.find('$').unwrap_or(source.len());
377                    let (chunk, rest) = source.split_at(chunk_end);
378                    result.push_str(chunk);
379                    source = rest;
380                }
381            }
382        }
383    }
384
385    fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
386        if source.starts_with("env.") {
387            text.push('$');
388            return source;
389        }
390
391        match source.chars().next() {
392            Some('{') => {
393                let source = &source[1..];
394                if let Some(end) = source.find('}') {
395                    let var_name = &source[..end];
396                    if !var_name.is_empty() {
397                        if !is_start {
398                            text.push_str("(");
399                        }
400                        text.push_str("$env.");
401                        text.push_str(var_name);
402                        if !is_start {
403                            text.push_str(")");
404                        }
405                        &source[end + 1..]
406                    } else {
407                        text.push_str("${}");
408                        &source[end + 1..]
409                    }
410                } else {
411                    text.push_str("${");
412                    source
413                }
414            }
415            Some(c) if c.is_alphabetic() || c == '_' => {
416                let end = source
417                    .find(|c: char| !c.is_alphanumeric() && c != '_')
418                    .unwrap_or(source.len());
419                let var_name = &source[..end];
420                if !is_start {
421                    text.push_str("(");
422                }
423                text.push_str("$env.");
424                text.push_str(var_name);
425                if !is_start {
426                    text.push_str(")");
427                }
428                &source[end..]
429            }
430            _ => {
431                text.push('$');
432                source
433            }
434        }
435    }
436
437    pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
438        match self {
439            ShellKind::PowerShell | ShellKind::Pwsh => vec!["-C".to_owned(), combined_command],
440            ShellKind::Cmd => vec![
441                "/S".to_owned(),
442                "/C".to_owned(),
443                format!("\"{combined_command}\""),
444            ],
445            ShellKind::Posix
446            | ShellKind::Nushell
447            | ShellKind::Fish
448            | ShellKind::Csh
449            | ShellKind::Tcsh
450            | ShellKind::Rc
451            | ShellKind::Xonsh
452            | ShellKind::Elvish => interactive
453                .then(|| "-i".to_owned())
454                .into_iter()
455                .chain(["-c".to_owned(), combined_command])
456                .collect(),
457        }
458    }
459
460    pub const fn command_prefix(&self) -> Option<char> {
461        match self {
462            ShellKind::PowerShell | ShellKind::Pwsh => Some('&'),
463            ShellKind::Nushell => Some('^'),
464            ShellKind::Posix
465            | ShellKind::Csh
466            | ShellKind::Tcsh
467            | ShellKind::Rc
468            | ShellKind::Fish
469            | ShellKind::Cmd
470            | ShellKind::Xonsh
471            | ShellKind::Elvish => None,
472        }
473    }
474
475    pub fn prepend_command_prefix<'a>(&self, command: &'a str) -> Cow<'a, str> {
476        match self.command_prefix() {
477            Some(prefix) if !command.starts_with(prefix) => {
478                Cow::Owned(format!("{prefix}{command}"))
479            }
480            _ => Cow::Borrowed(command),
481        }
482    }
483
484    pub const fn sequential_commands_separator(&self) -> char {
485        match self {
486            ShellKind::Cmd => '&',
487            ShellKind::Posix
488            | ShellKind::Csh
489            | ShellKind::Tcsh
490            | ShellKind::Rc
491            | ShellKind::Fish
492            | ShellKind::PowerShell
493            | ShellKind::Pwsh
494            | ShellKind::Nushell
495            | ShellKind::Xonsh
496            | ShellKind::Elvish => ';',
497        }
498    }
499
500    pub const fn sequential_and_commands_separator(&self) -> &'static str {
501        match self {
502            ShellKind::Cmd
503            | ShellKind::Posix
504            | ShellKind::Csh
505            | ShellKind::Tcsh
506            | ShellKind::Rc
507            | ShellKind::Fish
508            | ShellKind::Pwsh
509            | ShellKind::Xonsh => "&&",
510            ShellKind::PowerShell | ShellKind::Nushell | ShellKind::Elvish => ";",
511        }
512    }
513
514    pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
515        match self {
516            ShellKind::PowerShell => Some(Self::quote_powershell(arg)),
517            ShellKind::Pwsh => Some(Self::quote_pwsh(arg)),
518            ShellKind::Cmd => Some(Self::quote_cmd(arg)),
519            ShellKind::Posix
520            | ShellKind::Csh
521            | ShellKind::Tcsh
522            | ShellKind::Rc
523            | ShellKind::Fish
524            | ShellKind::Nushell
525            | ShellKind::Xonsh
526            | ShellKind::Elvish => shlex::try_quote(arg).ok(),
527        }
528    }
529
530    fn quote_windows(arg: &str, enclose: bool) -> Cow<'_, str> {
531        if arg.is_empty() {
532            return Cow::Borrowed("\"\"");
533        }
534
535        let needs_quoting = arg.chars().any(|c| c == ' ' || c == '\t' || c == '"');
536        if !needs_quoting {
537            return Cow::Borrowed(arg);
538        }
539
540        let mut result = String::with_capacity(arg.len() + 2);
541
542        if enclose {
543            result.push('"');
544        }
545
546        let chars: Vec<char> = arg.chars().collect();
547        let mut i = 0;
548
549        while i < chars.len() {
550            if chars[i] == '\\' {
551                let mut num_backslashes = 0;
552                while i < chars.len() && chars[i] == '\\' {
553                    num_backslashes += 1;
554                    i += 1;
555                }
556
557                if i < chars.len() && chars[i] == '"' {
558                    // Backslashes followed by quote: double the backslashes and escape the quote
559                    for _ in 0..(num_backslashes * 2 + 1) {
560                        result.push('\\');
561                    }
562                    result.push('"');
563                    i += 1;
564                } else if i >= chars.len() {
565                    // Trailing backslashes: double them (they precede the closing quote)
566                    for _ in 0..(num_backslashes * 2) {
567                        result.push('\\');
568                    }
569                } else {
570                    // Backslashes not followed by quote: output as-is
571                    for _ in 0..num_backslashes {
572                        result.push('\\');
573                    }
574                }
575            } else if chars[i] == '"' {
576                // Quote not preceded by backslash: escape it
577                result.push('\\');
578                result.push('"');
579                i += 1;
580            } else {
581                result.push(chars[i]);
582                i += 1;
583            }
584        }
585
586        if enclose {
587            result.push('"');
588        }
589        Cow::Owned(result)
590    }
591
592    fn needs_quoting_powershell(s: &str) -> bool {
593        s.is_empty()
594            || s.chars().any(|c| {
595                c.is_whitespace()
596                    || matches!(
597                        c,
598                        '"' | '`'
599                            | '$'
600                            | '&'
601                            | '|'
602                            | '<'
603                            | '>'
604                            | ';'
605                            | '('
606                            | ')'
607                            | '['
608                            | ']'
609                            | '{'
610                            | '}'
611                            | ','
612                            | '\''
613                            | '@'
614                    )
615            })
616    }
617
618    fn need_quotes_powershell(arg: &str) -> bool {
619        let mut quote_count = 0;
620        for c in arg.chars() {
621            if c == '"' {
622                quote_count += 1;
623            } else if c.is_whitespace() && (quote_count % 2 == 0) {
624                return true;
625            }
626        }
627        false
628    }
629
630    fn escape_powershell_quotes(s: &str) -> String {
631        let mut result = String::with_capacity(s.len() + 4);
632        result.push('\'');
633        for c in s.chars() {
634            if c == '\'' {
635                result.push('\'');
636            }
637            result.push(c);
638        }
639        result.push('\'');
640        result
641    }
642
643    pub fn quote_powershell(arg: &str) -> Cow<'_, str> {
644        let ps_will_quote = Self::need_quotes_powershell(arg);
645        let crt_quoted = Self::quote_windows(arg, !ps_will_quote);
646
647        if !Self::needs_quoting_powershell(arg) {
648            return crt_quoted;
649        }
650
651        Cow::Owned(Self::escape_powershell_quotes(&crt_quoted))
652    }
653
654    pub fn quote_pwsh(arg: &str) -> Cow<'_, str> {
655        if arg.is_empty() {
656            return Cow::Borrowed("''");
657        }
658
659        if !Self::needs_quoting_powershell(arg) {
660            return Cow::Borrowed(arg);
661        }
662
663        Cow::Owned(Self::escape_powershell_quotes(arg))
664    }
665
666    pub fn quote_cmd(arg: &str) -> Cow<'_, str> {
667        let crt_quoted = Self::quote_windows(arg, true);
668
669        let needs_cmd_escaping = crt_quoted.contains(['"', '%', '^', '<', '>', '&', '|', '(', ')']);
670
671        if !needs_cmd_escaping {
672            return crt_quoted;
673        }
674
675        let mut result = String::with_capacity(crt_quoted.len() * 2);
676        for c in crt_quoted.chars() {
677            match c {
678                '^' | '"' | '<' | '>' | '&' | '|' | '(' | ')' => {
679                    result.push('^');
680                    result.push(c);
681                }
682                '%' => {
683                    result.push_str("%%cd:~,%");
684                }
685                _ => result.push(c),
686            }
687        }
688        Cow::Owned(result)
689    }
690
691    /// Quotes the given argument if necessary, taking into account the command prefix.
692    ///
693    /// In other words, this will consider quoting arg without its command prefix to not break the command.
694    /// You should use this over `try_quote` when you want to quote a shell command.
695    pub fn try_quote_prefix_aware<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
696        if let Some(char) = self.command_prefix() {
697            if let Some(arg) = arg.strip_prefix(char) {
698                // we have a command that is prefixed
699                for quote in ['\'', '"'] {
700                    if let Some(arg) = arg
701                        .strip_prefix(quote)
702                        .and_then(|arg| arg.strip_suffix(quote))
703                    {
704                        // and the command itself is wrapped as a literal, that
705                        // means the prefix exists to interpret a literal as a
706                        // command. So strip the quotes, quote the command, and
707                        // re-add the quotes if they are missing after requoting
708                        let quoted = self.try_quote(arg)?;
709                        return Some(if quoted.starts_with(['\'', '"']) {
710                            Cow::Owned(self.prepend_command_prefix(&quoted).into_owned())
711                        } else {
712                            Cow::Owned(
713                                self.prepend_command_prefix(&format!("{quote}{quoted}{quote}"))
714                                    .into_owned(),
715                            )
716                        });
717                    }
718                }
719                return self
720                    .try_quote(arg)
721                    .map(|quoted| Cow::Owned(self.prepend_command_prefix(&quoted).into_owned()));
722            }
723        }
724        self.try_quote(arg).map(|quoted| match quoted {
725            unquoted @ Cow::Borrowed(_) => unquoted,
726            Cow::Owned(quoted) => Cow::Owned(self.prepend_command_prefix(&quoted).into_owned()),
727        })
728    }
729
730    pub fn split(&self, input: &str) -> Option<Vec<String>> {
731        shlex::split(input)
732    }
733
734    pub const fn activate_keyword(&self) -> &'static str {
735        match self {
736            ShellKind::Cmd => "",
737            ShellKind::Nushell => "overlay use",
738            ShellKind::PowerShell | ShellKind::Pwsh => ".",
739            ShellKind::Fish
740            | ShellKind::Csh
741            | ShellKind::Tcsh
742            | ShellKind::Posix
743            | ShellKind::Rc
744            | ShellKind::Xonsh
745            | ShellKind::Elvish => "source",
746        }
747    }
748
749    pub const fn clear_screen_command(&self) -> &'static str {
750        match self {
751            ShellKind::Cmd => "cls",
752            ShellKind::Posix
753            | ShellKind::Csh
754            | ShellKind::Tcsh
755            | ShellKind::Rc
756            | ShellKind::Fish
757            | ShellKind::PowerShell
758            | ShellKind::Pwsh
759            | ShellKind::Nushell
760            | ShellKind::Xonsh
761            | ShellKind::Elvish => "clear",
762        }
763    }
764
765    #[cfg(windows)]
766    /// We do not want to escape arguments if we are using CMD as our shell.
767    /// If we do we end up with too many quotes/escaped quotes for CMD to handle.
768    pub const fn tty_escape_args(&self) -> bool {
769        match self {
770            ShellKind::Cmd => false,
771            ShellKind::Posix
772            | ShellKind::Csh
773            | ShellKind::Tcsh
774            | ShellKind::Rc
775            | ShellKind::Fish
776            | ShellKind::PowerShell
777            | ShellKind::Pwsh
778            | ShellKind::Nushell
779            | ShellKind::Xonsh
780            | ShellKind::Elvish => true,
781        }
782    }
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788
789    // Examples
790    // WSL
791    // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "echo hello"
792    // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "\"echo hello\"" | grep hello"
793    // wsl.exe --distribution NixOS --cd ~ env RUST_LOG=info,remote=debug .zed_wsl_server/zed-remote-server-dev-build proxy --identifier dev-workspace-53
794    // PowerShell from Nushell
795    // 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\""
796    // PowerShell from CMD
797    // 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\\\"\"\"
798
799    #[test]
800    fn test_try_quote_powershell() {
801        let shell_kind = ShellKind::PowerShell;
802        assert_eq!(
803            shell_kind
804                .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
805                .unwrap()
806                .into_owned(),
807            "'C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"'".to_string()
808        );
809    }
810
811    #[test]
812    fn test_try_quote_cmd() {
813        let shell_kind = ShellKind::Cmd;
814        assert_eq!(
815            shell_kind
816                .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
817                .unwrap()
818                .into_owned(),
819            "^\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\^\"test_foo.py::test_foo\\^\"^\"".to_string()
820        );
821    }
822
823    #[test]
824    fn test_try_quote_powershell_edge_cases() {
825        let shell_kind = ShellKind::PowerShell;
826
827        // Empty string
828        assert_eq!(
829            shell_kind.try_quote("").unwrap().into_owned(),
830            "'\"\"'".to_string()
831        );
832
833        // String without special characters (no quoting needed)
834        assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
835
836        // String with spaces
837        assert_eq!(
838            shell_kind.try_quote("hello world").unwrap().into_owned(),
839            "'hello world'".to_string()
840        );
841
842        // String with dollar signs
843        assert_eq!(
844            shell_kind.try_quote("$variable").unwrap().into_owned(),
845            "'$variable'".to_string()
846        );
847
848        // String with backticks
849        assert_eq!(
850            shell_kind.try_quote("test`command").unwrap().into_owned(),
851            "'test`command'".to_string()
852        );
853
854        // String with multiple special characters
855        assert_eq!(
856            shell_kind
857                .try_quote("test `\"$var`\" end")
858                .unwrap()
859                .into_owned(),
860            "'test `\\\"$var`\\\" end'".to_string()
861        );
862
863        // String with backslashes and colon (path without spaces doesn't need quoting)
864        assert_eq!(
865            shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
866            "C:\\path\\to\\file"
867        );
868    }
869
870    #[test]
871    fn test_try_quote_cmd_edge_cases() {
872        let shell_kind = ShellKind::Cmd;
873
874        // Empty string
875        assert_eq!(
876            shell_kind.try_quote("").unwrap().into_owned(),
877            "^\"^\"".to_string()
878        );
879
880        // String without special characters (no quoting needed)
881        assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
882
883        // String with spaces
884        assert_eq!(
885            shell_kind.try_quote("hello world").unwrap().into_owned(),
886            "^\"hello world^\"".to_string()
887        );
888
889        // String with space and backslash (backslash not at end, so not doubled)
890        assert_eq!(
891            shell_kind.try_quote("path\\ test").unwrap().into_owned(),
892            "^\"path\\ test^\"".to_string()
893        );
894
895        // String ending with backslash (must be doubled before closing quote)
896        assert_eq!(
897            shell_kind.try_quote("test path\\").unwrap().into_owned(),
898            "^\"test path\\\\^\"".to_string()
899        );
900
901        // String ending with multiple backslashes (all doubled before closing quote)
902        assert_eq!(
903            shell_kind.try_quote("test path\\\\").unwrap().into_owned(),
904            "^\"test path\\\\\\\\^\"".to_string()
905        );
906
907        // String with embedded quote (quote is escaped, backslash before it is doubled)
908        assert_eq!(
909            shell_kind.try_quote("test\\\"quote").unwrap().into_owned(),
910            "^\"test\\\\\\^\"quote^\"".to_string()
911        );
912
913        // String with multiple backslashes before embedded quote (all doubled)
914        assert_eq!(
915            shell_kind
916                .try_quote("test\\\\\"quote")
917                .unwrap()
918                .into_owned(),
919            "^\"test\\\\\\\\\\^\"quote^\"".to_string()
920        );
921
922        // String with backslashes not before quotes (path without spaces doesn't need quoting)
923        assert_eq!(
924            shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
925            "C:\\path\\to\\file"
926        );
927    }
928
929    #[test]
930    fn test_try_quote_nu_command() {
931        let shell_kind = ShellKind::Nushell;
932        assert_eq!(
933            shell_kind.try_quote("'uname'").unwrap().into_owned(),
934            "\"'uname'\"".to_string()
935        );
936        assert_eq!(
937            shell_kind
938                .try_quote_prefix_aware("'uname'")
939                .unwrap()
940                .into_owned(),
941            "^\"'uname'\"".to_string()
942        );
943        assert_eq!(
944            shell_kind.try_quote("^uname").unwrap().into_owned(),
945            "'^uname'".to_string()
946        );
947        assert_eq!(
948            shell_kind
949                .try_quote_prefix_aware("^uname")
950                .unwrap()
951                .into_owned(),
952            "^uname".to_string()
953        );
954        assert_eq!(
955            shell_kind.try_quote("^'uname'").unwrap().into_owned(),
956            "'^'\"'uname\'\"".to_string()
957        );
958        assert_eq!(
959            shell_kind
960                .try_quote_prefix_aware("^'uname'")
961                .unwrap()
962                .into_owned(),
963            "^'uname'".to_string()
964        );
965        assert_eq!(
966            shell_kind.try_quote("'uname a'").unwrap().into_owned(),
967            "\"'uname a'\"".to_string()
968        );
969        assert_eq!(
970            shell_kind
971                .try_quote_prefix_aware("'uname a'")
972                .unwrap()
973                .into_owned(),
974            "^\"'uname a'\"".to_string()
975        );
976        assert_eq!(
977            shell_kind.try_quote("^'uname a'").unwrap().into_owned(),
978            "'^'\"'uname a'\"".to_string()
979        );
980        assert_eq!(
981            shell_kind
982                .try_quote_prefix_aware("^'uname a'")
983                .unwrap()
984                .into_owned(),
985            "^'uname a'".to_string()
986        );
987        assert_eq!(
988            shell_kind.try_quote("uname").unwrap().into_owned(),
989            "uname".to_string()
990        );
991        assert_eq!(
992            shell_kind
993                .try_quote_prefix_aware("uname")
994                .unwrap()
995                .into_owned(),
996            "uname".to_string()
997        );
998    }
999}