shell.rs

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