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}