rmux-server 0.1.2

Tokio daemon and request dispatcher for the RMUX terminal multiplexer.
Documentation
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command as StdCommand;

use rmux_pty::ChildCommand;
use tokio::process::Command as TokioCommand;

#[cfg(windows)]
use super::executable_name;

#[derive(Debug, Clone, Eq, PartialEq)]
pub(super) struct ShellSpec {
    program: PathBuf,
    kind: ShellKind,
}

impl ShellSpec {
    pub(super) fn new(shell: &Path) -> Self {
        Self {
            program: shell.to_path_buf(),
            kind: detect_shell_kind(shell),
        }
    }

    pub(super) fn command_child(&self, cwd: &Path, command: &str) -> ChildCommand {
        self.command_plan(cwd, command).into_child_command()
    }

    pub(super) fn command_tokio_child(&self, cwd: &Path, command: &str) -> TokioCommand {
        self.command_plan(cwd, command).into_tokio_command()
    }

    pub(super) fn command_std_child(&self, cwd: &Path, command: &str) -> StdCommand {
        self.command_plan(cwd, command).into_std_command()
    }

    pub(super) fn interactive_child(&self, cwd: &Path) -> ChildCommand {
        self.interactive_plan(cwd).into_child_command()
    }

    fn command_plan(&self, cwd: &Path, command: &str) -> ShellCommandPlan {
        #[cfg(unix)]
        let _ = cwd;

        match self.kind {
            #[cfg(unix)]
            ShellKind::Unix => ShellCommandPlan::new(&self.program).arg("-c").arg(command),
            #[cfg(windows)]
            ShellKind::PowerShell => ShellCommandPlan::new(&self.program)
                .arg("-NoProfile")
                .arg("-Command")
                .arg(format!(
                    "Set-Location -LiteralPath {}; {command}",
                    powershell_single_quoted(cwd)
                )),
            #[cfg(windows)]
            ShellKind::Cmd => ShellCommandPlan::new(&self.program)
                .arg("/D")
                .arg("/S")
                .arg("/C")
                .arg(command),
            #[cfg(windows)]
            ShellKind::Posix => ShellCommandPlan::new(&self.program).arg("-lc").arg(command),
            #[cfg(windows)]
            ShellKind::Nu => ShellCommandPlan::new(&self.program).arg("-c").arg(command),
            #[cfg(windows)]
            ShellKind::Other => ShellCommandPlan::new(&self.program).arg(command),
        }
    }

    fn interactive_plan(&self, cwd: &Path) -> ShellCommandPlan {
        #[cfg(unix)]
        let _ = cwd;

        match self.kind {
            #[cfg(unix)]
            ShellKind::Unix => {
                ShellCommandPlan::new(&self.program).arg0(login_shell_argv0(&self.program))
            }
            #[cfg(windows)]
            ShellKind::PowerShell => ShellCommandPlan::new(&self.program)
                .arg("-NoExit")
                .arg("-Command")
                .arg(format!(
                    "Set-Location -LiteralPath {}",
                    powershell_single_quoted(cwd)
                )),
            #[cfg(windows)]
            ShellKind::Cmd => ShellCommandPlan::new(&self.program).arg("/D").arg("/K"),
            #[cfg(windows)]
            ShellKind::Posix | ShellKind::Nu | ShellKind::Other => {
                ShellCommandPlan::new(&self.program)
            }
        }
    }
}

#[derive(Debug, Clone, Eq, PartialEq)]
struct ShellCommandPlan {
    program: PathBuf,
    arg0: Option<OsString>,
    args: Vec<OsString>,
}

impl ShellCommandPlan {
    fn new(program: &Path) -> Self {
        Self {
            program: program.to_path_buf(),
            arg0: None,
            args: Vec::new(),
        }
    }

    fn arg0(mut self, arg0: impl Into<OsString>) -> Self {
        self.arg0 = Some(arg0.into());
        self
    }

    fn arg(mut self, arg: impl Into<OsString>) -> Self {
        self.args.push(arg.into());
        self
    }

    fn into_child_command(self) -> ChildCommand {
        let mut command = ChildCommand::new(self.program);
        if let Some(arg0) = self.arg0 {
            command = command.arg0(arg0);
        }
        command.args(self.args)
    }

    fn into_tokio_command(self) -> TokioCommand {
        let mut command = TokioCommand::new(self.program);
        command.args(self.args);
        command
    }

    fn into_std_command(self) -> StdCommand {
        let mut command = StdCommand::new(self.program);
        #[cfg(unix)]
        if let Some(arg0) = self.arg0 {
            use std::os::unix::process::CommandExt as _;
            command.arg0(arg0);
        }
        command.args(self.args);
        command
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ShellKind {
    #[cfg(unix)]
    Unix,
    #[cfg(windows)]
    Cmd,
    #[cfg(windows)]
    PowerShell,
    #[cfg(windows)]
    Posix,
    #[cfg(windows)]
    Nu,
    #[cfg(windows)]
    Other,
}

#[cfg(unix)]
fn detect_shell_kind(_shell: &Path) -> ShellKind {
    ShellKind::Unix
}

#[cfg(windows)]
fn detect_shell_kind(shell: &Path) -> ShellKind {
    match executable_name(shell)
        .as_deref()
        .map(str::to_ascii_lowercase)
        .as_deref()
    {
        Some("cmd.exe" | "cmd") => ShellKind::Cmd,
        Some("powershell.exe" | "powershell" | "pwsh.exe" | "pwsh") => ShellKind::PowerShell,
        Some("bash.exe" | "bash" | "sh.exe" | "sh" | "zsh.exe" | "zsh") => ShellKind::Posix,
        Some("nu.exe" | "nu") => ShellKind::Nu,
        _ => ShellKind::Other,
    }
}

#[cfg(windows)]
fn powershell_single_quoted(path: &Path) -> String {
    format!("'{}'", path.to_string_lossy().replace('\'', "''"))
}

#[cfg(unix)]
fn login_shell_argv0(shell: &Path) -> OsString {
    let name = shell
        .file_name()
        .unwrap_or(shell.as_os_str())
        .to_os_string();
    let mut login_name = OsString::from("-");
    login_name.push(name);
    login_name
}

#[cfg(test)]
mod tests {
    use super::*;

    #[cfg(windows)]
    #[test]
    fn detects_windows_shell_families_by_executable_name() {
        assert_eq!(
            detect_shell_kind(Path::new(r"C:\Windows\System32\cmd.exe")),
            ShellKind::Cmd
        );
        assert_eq!(
            detect_shell_kind(Path::new("powershell")),
            ShellKind::PowerShell
        );
        assert_eq!(
            detect_shell_kind(Path::new("pwsh.exe")),
            ShellKind::PowerShell
        );
        assert_eq!(detect_shell_kind(Path::new("bash.exe")), ShellKind::Posix);
        assert_eq!(detect_shell_kind(Path::new("nu.exe")), ShellKind::Nu);
    }

    #[cfg(windows)]
    #[test]
    fn cmd_interactive_uses_current_dir_instead_of_cd_wrapper() {
        let spec = ShellSpec::new(Path::new("cmd.exe"));
        let plan = spec.interactive_plan(Path::new(r"C:\Users\RMUXUser\Documents\rmux"));

        assert_eq!(plan.program, PathBuf::from("cmd.exe"));
        assert_eq!(plan.arg0, None);
        assert_eq!(plan.args, os_args(["/D", "/K"]));
    }

    #[cfg(windows)]
    #[test]
    fn cmd_command_preserves_command_text_without_wrapping_cwd() {
        let spec = ShellSpec::new(Path::new("cmd.exe"));
        let plan = spec.command_plan(Path::new(r"C:\tmp"), "echo RMUX_OK");

        assert_eq!(plan.args, os_args(["/D", "/S", "/C", "echo RMUX_OK"]));
    }

    #[cfg(windows)]
    #[test]
    fn powershell_plans_quote_cwd_with_literal_path() {
        let spec = ShellSpec::new(Path::new("pwsh.exe"));
        let cwd = Path::new(r"C:\Users\RMUXUser's Workspace\rmux");

        let interactive = spec.interactive_plan(cwd);
        assert_eq!(
            interactive.args,
            os_args([
                "-NoExit",
                "-Command",
                "Set-Location -LiteralPath 'C:\\Users\\RMUXUser''s Workspace\\rmux'",
            ])
        );

        let one_shot = spec.command_plan(cwd, "Write-Output RMUX_OK");
        assert_eq!(
            one_shot.args,
            os_args([
                "-NoProfile",
                "-Command",
                "Set-Location -LiteralPath 'C:\\Users\\RMUXUser''s Workspace\\rmux'; Write-Output RMUX_OK",
            ])
        );
    }

    #[cfg(windows)]
    #[test]
    fn posix_shell_command_uses_lc_not_cmd_c() {
        let spec = ShellSpec::new(Path::new("bash.exe"));
        let plan = spec.command_plan(Path::new(r"C:\tmp"), "echo RMUX_OK");

        assert_eq!(plan.args, os_args(["-lc", "echo RMUX_OK"]));
    }

    #[cfg(windows)]
    #[test]
    fn nushell_command_uses_c_not_cmd_c() {
        let spec = ShellSpec::new(Path::new("nu.exe"));
        let plan = spec.command_plan(Path::new(r"C:\tmp"), "echo RMUX_OK");

        assert_eq!(plan.args, os_args(["-c", "echo RMUX_OK"]));
    }

    #[cfg(windows)]
    #[test]
    fn unknown_windows_shell_does_not_receive_cmd_c_flag() {
        let spec = ShellSpec::new(Path::new("custom-shell.exe"));
        let plan = spec.command_plan(Path::new(r"C:\tmp"), "echo RMUX_OK");

        assert_eq!(plan.args, os_args(["echo RMUX_OK"]));
    }

    #[cfg(unix)]
    #[test]
    fn unix_interactive_shell_uses_login_argv0() {
        let spec = ShellSpec::new(Path::new("/bin/bash"));
        let plan = spec.interactive_plan(Path::new("/tmp"));

        assert_eq!(plan.program, PathBuf::from("/bin/bash"));
        assert_eq!(plan.arg0, Some(OsString::from("-bash")));
        assert!(plan.args.is_empty());
    }

    #[cfg(windows)]
    fn os_args<const N: usize>(args: [&str; N]) -> Vec<OsString> {
        args.into_iter().map(OsString::from).collect()
    }
}