rmux-server 0.2.0

Tokio daemon and request dispatcher for the RMUX terminal multiplexer.
Documentation
#![cfg(windows)]

use std::error::Error;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::OnceLock;
use std::time::{Duration, Instant};

type TestResult<T = ()> = Result<T, Box<dyn Error>>;

const STEP_TIMEOUT: Duration = Duration::from_secs(5);
static UNIQUE_ID: AtomicUsize = AtomicUsize::new(0);

#[test]
fn send_keys_writes_to_the_correct_pane_through_the_windows_pipe() -> TestResult {
    let harness = CliHarness::new("sendkeysone")?;
    harness.success_quiet(&["new-session", "-d", "-s", "alpha", "cmd.exe", "/d"])?;

    harness.success_quiet(&["send-keys", "-t", "alpha:0.0", "echo send-keys-ok", "Enter"])?;
    let capture = harness.capture_until_contains("alpha:0.0", "send-keys-ok")?;
    assert!(capture.contains("send-keys-ok"));

    harness.failure(&["send-keys", "-t", "missing:0.0", "x"])?;
    harness.finish()
}

#[test]
fn send_keys_targets_the_correct_pane_in_a_multi_pane_session_windows() -> TestResult {
    let harness = CliHarness::new("sendkeysmulti")?;
    harness.success_quiet(&["new-session", "-d", "-s", "beta", "cmd.exe", "/d"])?;
    harness.success_quiet(&["split-window", "-h", "-t", "beta:0", "cmd.exe", "/d"])?;

    harness.success_quiet(&["send-keys", "-t", "beta:0.0", "echo pane-zero", "Enter"])?;
    assert!(harness
        .capture_until_contains("beta:0.0", "pane-zero")?
        .contains("pane-zero"));

    harness.success_quiet(&["send-keys", "-t", "beta:0.1", "echo pane-one", "Enter"])?;
    assert!(harness
        .capture_until_contains("beta:0.1", "pane-one")?
        .contains("pane-one"));
    harness.finish()
}

struct CliHarness {
    label: String,
    armed: bool,
}

impl CliHarness {
    fn new(label: &str) -> TestResult<Self> {
        Ok(Self {
            label: format!("win{}{}", std::process::id(), unique_id(label)),
            armed: true,
        })
    }

    fn success(&self, args: &[&str]) -> TestResult<Output> {
        let output = self.run(args)?;
        if !output.status.success() {
            return Err(format!(
                "rmux {:?} failed with {:?}\nstdout:\n{}\nstderr:\n{}",
                args,
                output.status.code(),
                String::from_utf8_lossy(&output.stdout),
                String::from_utf8_lossy(&output.stderr)
            )
            .into());
        }
        Ok(output)
    }

    fn success_quiet(&self, args: &[&str]) -> TestResult {
        let status = Command::new(rmux_binary()?)
            .arg("-L")
            .arg(&self.label)
            .args(args)
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()?;
        if !status.success() {
            return Err(format!("rmux {:?} failed with {:?}", args, status.code()).into());
        }
        Ok(())
    }

    fn failure(&self, args: &[&str]) -> TestResult<Output> {
        let output = self.run(args)?;
        if output.status.success() {
            return Err(format!("rmux {:?} unexpectedly succeeded", args).into());
        }
        Ok(output)
    }

    fn run(&self, args: &[&str]) -> TestResult<Output> {
        let mut command = Command::new(rmux_binary()?);
        command.arg("-L").arg(&self.label).args(args);
        Ok(command.output()?)
    }

    fn capture_until_contains(&self, target: &str, needle: &str) -> TestResult<String> {
        let deadline = Instant::now() + STEP_TIMEOUT;
        let mut last = String::new();
        while Instant::now() < deadline {
            let output = self.success(&["capture-pane", "-p", "-t", target])?;
            last = String::from_utf8_lossy(&output.stdout).into_owned();
            if last.contains(needle) {
                return Ok(last);
            }
            std::thread::sleep(Duration::from_millis(50));
        }
        Err(format!("capture-pane never surfaced {needle:?}; last capture: {last:?}").into())
    }

    fn finish(mut self) -> TestResult {
        self.armed = false;
        self.kill_server();
        Ok(())
    }

    fn kill_server(&self) {
        let _ = Command::new(rmux_binary().unwrap_or_else(|_| Path::new("rmux")))
            .arg("-L")
            .arg(&self.label)
            .arg("kill-server")
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
    }
}

impl Drop for CliHarness {
    fn drop(&mut self) {
        if self.armed {
            self.kill_server();
        }
    }
}

fn unique_id(label: &str) -> String {
    format!(
        "{}{}",
        UNIQUE_ID.fetch_add(1, Ordering::Relaxed),
        label
            .chars()
            .filter(|ch| ch.is_ascii_alphanumeric())
            .collect::<String>()
    )
}

fn rmux_binary() -> TestResult<&'static Path> {
    static RMUX_BINARY: OnceLock<Result<PathBuf, String>> = OnceLock::new();
    match RMUX_BINARY.get_or_init(|| resolve_rmux_binary().map_err(|error| error.to_string())) {
        Ok(path) => Ok(path.as_path()),
        Err(error) => Err(std::io::Error::other(error.clone()).into()),
    }
}

fn resolve_rmux_binary() -> TestResult<PathBuf> {
    let target_dir = target_dir()?;
    let candidate = target_dir.join("debug").join("rmux.exe");
    if candidate.is_file() {
        return Ok(candidate);
    }
    let status = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
        .arg("build")
        .arg("--bin")
        .arg("rmux")
        .arg("--locked")
        .arg("--manifest-path")
        .arg(workspace_root().join("Cargo.toml"))
        .env("CARGO_TARGET_DIR", &target_dir)
        .status()?;
    if !status.success() {
        return Err(
            format!("failed to build rmux binary for Windows send-keys smoke: {status}").into(),
        );
    }
    Ok(candidate)
}

fn target_dir() -> TestResult<PathBuf> {
    if let Some(target_dir) = std::env::var_os("CARGO_TARGET_DIR") {
        return Ok(PathBuf::from(target_dir));
    }
    let current = std::env::current_exe()?;
    current
        .parent()
        .and_then(Path::parent)
        .and_then(Path::parent)
        .map(Path::to_path_buf)
        .ok_or_else(|| "test executable is not under a target directory".into())
}

fn workspace_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Path::parent)
        .expect("rmux-server manifest lives under crates/rmux-server")
        .to_path_buf()
}