rmux 0.10.0

A local terminal multiplexer with a tmux-style CLI, daemon runtime, Rust SDK, and ratatui integration.
#![cfg(unix)]

mod common;

use std::error::Error;
use std::fs;
use std::path::Path;
use std::time::{Duration, Instant};

use common::{assert_success, stderr, stdout, terminate_child, CliHarness};

const FILE_TIMEOUT: Duration = Duration::from_secs(5);

#[test]
fn pane_transfer_commands_round_trip_through_the_binary() -> Result<(), Box<dyn Error>> {
    let harness = CliHarness::new("pane-transfer-binary")?;
    let mut daemon = harness.start_hidden_daemon()?;
    let join_path = harness.tmpdir().join("join.txt");
    let break_path = harness.tmpdir().join("break.txt");
    let swap_source_path = harness.tmpdir().join("swap-source.txt");
    let swap_target_path = harness.tmpdir().join("swap-target.txt");

    assert_success(&harness.run(&["new-session", "-d", "-s", "alpha"])?);
    assert_success(&harness.run(&["split-window", "-v", "-t", "alpha"])?);
    assert_success(&harness.run(&["select-pane", "-t", "alpha:0.1"])?);
    assert_success(&harness.run(&["select-pane", "-t", "alpha:0.0"])?);
    assert_success(&harness.run(&["last-pane", "-t", "alpha:0"])?);
    assert_success(&harness.run(&[
        "send-keys",
        "-t",
        "alpha:0.1",
        "export RMUX_TRANSFER_MARK=joined",
        "Enter",
    ])?);

    assert_success(&harness.run(&["new-window", "-t", "alpha", "-d", "-n", "dest"])?);
    assert_success(&harness.run(&["join-pane", "-d", "-s", "alpha:0.1", "-t", "alpha:1.0"])?);
    assert_success(&harness.run(&[
        "send-keys",
        "-t",
        "alpha:1.1",
        &format!(
            "printf \"$RMUX_TRANSFER_MARK\" > {}",
            shell_quote(&join_path)
        ),
        "Enter",
    ])?);
    wait_for_file_contents(&join_path, "joined", FILE_TIMEOUT)?;

    assert_success(&harness.run(&[
        "break-pane",
        "-d",
        "-s",
        "alpha:1.1",
        "-t",
        "alpha:2",
        "-n",
        "broken",
    ])?);
    assert_success(&harness.run(&[
        "send-keys",
        "-t",
        "alpha:2.0",
        &format!(
            "printf \"$RMUX_TRANSFER_MARK\" > {}",
            shell_quote(&break_path)
        ),
        "Enter",
    ])?);
    wait_for_file_contents(&break_path, "joined", FILE_TIMEOUT)?;

    assert_success(&harness.run(&["new-window", "-t", "alpha", "-d", "-n", "swap"])?);
    assert_success(&harness.run(&["split-window", "-v", "-t", "alpha:3.0"])?);
    assert_success(&harness.run(&["kill-pane", "-t", "alpha:3.0"])?);
    assert_success(&harness.run(&[
        "send-keys",
        "-t",
        "alpha:3.0",
        "export RMUX_TRANSFER_MARK=swapped",
        "Enter",
    ])?);
    assert_success(&harness.run(&["swap-pane", "-d", "-s", "alpha:2.0", "-t", "alpha:3.0"])?);
    assert_success(&harness.run(&[
        "send-keys",
        "-t",
        "alpha:2.0",
        &format!(
            "printf \"$RMUX_TRANSFER_MARK\" > {}",
            shell_quote(&swap_source_path)
        ),
        "Enter",
    ])?);
    assert_success(&harness.run(&[
        "send-keys",
        "-t",
        "alpha:3.0",
        &format!(
            "printf \"$RMUX_TRANSFER_MARK\" > {}",
            shell_quote(&swap_target_path)
        ),
        "Enter",
    ])?);
    wait_for_file_contents(&swap_source_path, "swapped", FILE_TIMEOUT)?;
    wait_for_file_contents(&swap_target_path, "joined", FILE_TIMEOUT)?;

    assert_success(&harness.run(&["new-window", "-t", "alpha", "-d", "-n", "relative"])?);
    assert_success(&harness.run(&["split-window", "-v", "-t", "alpha:4.0"])?);
    let relative = harness.run(&["swap-pane", "-U", "-t", "alpha:4.1"])?;
    assert_success(&relative);
    assert!(
        stderr(&relative).is_empty(),
        "relative swap-pane should stay silent on success"
    );

    terminate_child(daemon.child_mut())?;
    Ok(())
}

#[test]
fn cross_session_join_pane_renumbers_colliding_source_pane_index() -> Result<(), Box<dyn Error>> {
    let harness = CliHarness::new("cross-session-join-pane-renumber")?;
    let mut daemon = harness.start_hidden_daemon()?;

    assert_success(&harness.run(&["new-session", "-d", "-s", "alpha", "sleep 60"])?);
    assert_success(&harness.run(&["new-session", "-d", "-s", "beta", "sleep 60"])?);
    assert_success(&harness.run(&["split-window", "-d", "-t", "alpha:0.0", "sleep 60"])?);
    assert_success(&harness.run(&["join-pane", "-d", "-s", "alpha:0.0", "-t", "beta:0.0"])?);

    let listed = harness.run(&[
        "list-panes",
        "-t",
        "beta:0",
        "-F",
        "#{pane_index}:#{pane_id}",
    ])?;
    assert_eq!(listed.status.code(), Some(0));
    assert_eq!(stdout(&listed).lines().count(), 2);
    assert!(stdout(&listed).starts_with("0:%"));
    assert!(stdout(&listed).contains("\n1:%"));
    assert!(stderr(&listed).is_empty());

    terminate_child(daemon.child_mut())?;
    Ok(())
}

#[test]
fn source_file_join_and_move_renumber_destroyed_source() -> Result<(), Box<dyn Error>> {
    for command in ["join-pane", "move-pane"] {
        let harness = CliHarness::new(&format!("source-file-{command}-renumber"))?;
        let mut daemon = harness.start_hidden_daemon()?;
        create_renumber_transfer_source(&harness, "alpha")?;
        let config = harness.tmpdir().join(format!("{command}-renumber.conf"));
        fs::write(&config, format!("{command} -d -s alpha:1.0 -t alpha:0.0\n"))?;

        assert_success(
            &harness.run(&["source-file", config.to_str().expect("UTF-8 config path")])?,
        );
        assert_contiguous_windows(&harness, "alpha")?;
        terminate_child(daemon.child_mut())?;
    }
    Ok(())
}

#[test]
fn startup_join_and_move_renumber_destroyed_source() -> Result<(), Box<dyn Error>> {
    for command in ["join-pane", "move-pane"] {
        let harness = CliHarness::new(&format!("startup-{command}-renumber"))?;
        let config = harness.tmpdir().join(format!("startup-{command}.conf"));
        fs::write(
            &config,
            format!(
                "new-session -d -s alpha sleep 60\n\
                 new-window -d -t alpha:1 -n SOURCE sleep 60\n\
                 new-window -d -t alpha:2 -n SURVIVOR sleep 60\n\
                 set-option -t alpha renumber-windows on\n\
                 {command} -d -s alpha:1.0 -t alpha:0.0\n"
            ),
        )?;

        assert_success(&harness.run(&[
            "-f",
            config.to_str().expect("UTF-8 startup config path"),
            "new-session",
            "-d",
            "-s",
            "boot",
            "sleep",
            "60",
        ])?);
        assert_contiguous_windows(&harness, "alpha")?;
    }
    Ok(())
}

fn create_renumber_transfer_source(
    harness: &CliHarness,
    session: &str,
) -> Result<(), Box<dyn Error>> {
    assert_success(&harness.run(&["new-session", "-d", "-s", session, "sleep", "60"])?);
    assert_success(&harness.run(&[
        "new-window",
        "-d",
        "-t",
        &format!("{session}:1"),
        "-n",
        "SOURCE",
        "sleep",
        "60",
    ])?);
    assert_success(&harness.run(&[
        "new-window",
        "-d",
        "-t",
        &format!("{session}:2"),
        "-n",
        "SURVIVOR",
        "sleep",
        "60",
    ])?);
    assert_success(&harness.run(&["set-option", "-t", session, "renumber-windows", "on"])?);
    Ok(())
}

fn assert_contiguous_windows(harness: &CliHarness, session: &str) -> Result<(), Box<dyn Error>> {
    let listed = harness.run(&["list-windows", "-t", session, "-F", "#{window_index}"])?;
    assert_eq!(listed.status.code(), Some(0), "{}", stderr(&listed));
    assert_eq!(stdout(&listed), "0\n1\n");
    assert!(stderr(&listed).is_empty());
    Ok(())
}

fn wait_for_file_contents(
    path: &Path,
    expected: &str,
    timeout: Duration,
) -> Result<(), Box<dyn Error>> {
    let deadline = Instant::now() + timeout;

    while Instant::now() < deadline {
        match fs::read_to_string(path) {
            Ok(contents) if contents == expected => return Ok(()),
            Ok(_) | Err(_) => std::thread::sleep(Duration::from_millis(25)),
        }
    }

    Err(std::io::Error::other(format!(
        "timed out waiting for '{}' to contain '{}'",
        path.display(),
        expected
    ))
    .into())
}

fn shell_quote(path: &Path) -> String {
    let path = path.display().to_string();
    format!("'{}'", path.replace('\'', r"'\''"))
}