rmux-server 0.1.2

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

use std::error::Error;
use std::io;
use std::path::Path;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

mod common;

use common::{send_request, session_name, start_server, TestHarness};
use rmux_proto::{
    HasSessionRequest, HookLifecycle, HookName, ListPanesRequest, ListSessionsRequest,
    NewSessionRequest, NewWindowRequest, Request, Response, ScopeSelector, SendKeysRequest,
    SetEnvironmentRequest, SetHookRequest, SetOptionMode, SetOptionRequest, ShowEnvironmentRequest,
    ShowOptionsRequest, SplitWindowRequest, SplitWindowTarget, TerminalSize, WindowTarget,
};

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

#[tokio::test]
async fn list_sessions_uses_shared_formatter_through_real_socket() -> Result<(), Box<dyn Error>> {
    let harness = TestHarness::new("listing-list-sessions");
    let handle = start_server(&harness).await?;
    let alpha = session_name("alpha");
    let beta = session_name("beta");

    for (session_name, size) in [
        (
            beta.clone(),
            TerminalSize {
                cols: 120,
                rows: 40,
            },
        ),
        (alpha.clone(), TerminalSize { cols: 80, rows: 24 }),
    ] {
        let created = send_request(
            harness.socket_path(),
            &Request::NewSession(NewSessionRequest {
                session_name,
                detached: true,
                size: Some(size),

                environment: None,
            }),
        )
        .await?;
        assert!(matches!(created, Response::NewSession(_)));
    }

    let new_window = send_request(
        harness.socket_path(),
        &Request::NewWindow(NewWindowRequest {
            target: alpha,
            name: Some("logs".to_owned()),
            detached: true,
            start_directory: None,
            environment: None,
            command: None,
            target_window_index: None,
            insert_at_target: false,
        }),
    )
    .await?;
    assert!(matches!(new_window, Response::NewWindow(_)));

    let listed = send_request(
        harness.socket_path(),
        &Request::ListSessions(ListSessionsRequest {
            format: Some(
                "#{session_name}:#{session_windows}:#{session_attached}:#{session_width}x#{session_height}"
                    .to_owned(),
            ),
            filter: None,
            sort_order: None,
            reversed: false,
            }),
    )
    .await?;

    let output = listed
        .command_output()
        .expect("list-sessions returns command output");
    assert_eq!(
        std::str::from_utf8(output.stdout()).expect("list-sessions output is utf-8"),
        "alpha:2:0:x\nbeta:1:0:x\n"
    );

    handle.shutdown().await?;
    Ok(())
}

#[tokio::test]
async fn list_panes_uses_shared_formatter_through_real_socket() -> Result<(), Box<dyn Error>> {
    let harness = TestHarness::new("listing-list-panes");
    let handle = start_server(&harness).await?;
    let alpha = session_name("alpha");

    let created = send_request(
        harness.socket_path(),
        &Request::NewSession(NewSessionRequest {
            session_name: alpha.clone(),
            detached: true,
            size: Some(TerminalSize { cols: 80, rows: 24 }),
            environment: None,
        }),
    )
    .await?;
    assert!(matches!(created, Response::NewSession(_)));

    let split = send_request(
        harness.socket_path(),
        &Request::SplitWindow(SplitWindowRequest {
            target: SplitWindowTarget::Session(alpha.clone()),
            direction: rmux_proto::SplitDirection::Vertical,
            before: false,
            environment: None,
        }),
    )
    .await?;
    assert!(matches!(split, Response::SplitWindow(_)));

    let new_window = send_request(
        harness.socket_path(),
        &Request::NewWindow(NewWindowRequest {
            target: alpha.clone(),
            name: Some("logs".to_owned()),
            detached: true,
            start_directory: None,
            environment: None,
            command: None,
            target_window_index: None,
            insert_at_target: false,
        }),
    )
    .await?;
    assert!(matches!(new_window, Response::NewWindow(_)));

    let listed = send_request(
        harness.socket_path(),
        &Request::ListPanes(ListPanesRequest {
            target: alpha,
            format: Some(
                "#{session_name}:#{window_index}:#{pane_index}:#{pane_id}:#{pane_active}"
                    .to_owned(),
            ),
            target_window_index: None,
        }),
    )
    .await?;

    let output = listed
        .command_output()
        .expect("list-panes returns command output");
    assert_eq!(
        std::str::from_utf8(output.stdout()).expect("list-panes output is utf-8"),
        "alpha:0:0:%0:0\nalpha:0:1:%1:1\nalpha:1:0:%2:1\n"
    );

    handle.shutdown().await?;
    Ok(())
}

#[tokio::test]
async fn rename_session_round_trips_and_migrates_session_scoped_state() -> Result<(), Box<dyn Error>>
{
    let harness = TestHarness::new("listing-rename-session");
    let handle = start_server(&harness).await?;
    let alpha = session_name("alpha");
    let gamma = session_name("gamma");
    let hook_path = std::env::temp_dir().join(format!(
        "rmux-rename-hook-{}-{}.txt",
        std::process::id(),
        SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system clock should be after the unix epoch")
            .as_nanos()
    ));
    let _ = std::fs::remove_file(&hook_path);

    let created = send_request(
        harness.socket_path(),
        &Request::NewSession(NewSessionRequest {
            session_name: alpha.clone(),
            detached: true,
            size: Some(TerminalSize { cols: 80, rows: 24 }),
            environment: None,
        }),
    )
    .await?;
    assert!(matches!(created, Response::NewSession(_)));

    assert!(matches!(
        send_request(
            harness.socket_path(),
            &Request::SetEnvironment(SetEnvironmentRequest {
                scope: ScopeSelector::Session(alpha.clone()),
                name: "TERM".to_owned(),
                value: "screen".to_owned(),
                mode: None,
                hidden: false,
                format: false,
            }),
        )
        .await?,
        Response::SetEnvironment(_)
    ));
    assert!(matches!(
        send_request(
            harness.socket_path(),
            &Request::SetOption(SetOptionRequest {
                scope: ScopeSelector::Window(WindowTarget::new(alpha.clone())),
                option: rmux_proto::OptionName::PaneBorderStyle,
                value: "red".to_owned(),
                mode: SetOptionMode::Replace,
            }),
        )
        .await?,
        Response::SetOption(_)
    ));
    assert!(matches!(
        send_request(
            harness.socket_path(),
            &Request::SetHook(SetHookRequest {
                scope: ScopeSelector::Session(alpha.clone()),
                hook: HookName::AfterSendKeys,
                command: format!("printf renamed-hook > {}", hook_path.display()),
                lifecycle: HookLifecycle::Persistent,
            }),
        )
        .await?,
        Response::SetHook(_)
    ));

    let renamed = send_request(
        harness.socket_path(),
        &Request::RenameSession(rmux_proto::RenameSessionRequest {
            target: alpha.clone(),
            new_name: gamma.clone(),
        }),
    )
    .await?;
    assert_eq!(
        renamed,
        Response::RenameSession(rmux_proto::RenameSessionResponse {
            session_name: gamma.clone(),
        })
    );

    assert_eq!(
        send_request(
            harness.socket_path(),
            &Request::HasSession(HasSessionRequest {
                target: alpha.clone(),
            }),
        )
        .await?,
        Response::HasSession(rmux_proto::HasSessionResponse { exists: false })
    );
    assert_eq!(
        send_request(
            harness.socket_path(),
            &Request::HasSession(HasSessionRequest {
                target: gamma.clone(),
            }),
        )
        .await?,
        Response::HasSession(rmux_proto::HasSessionResponse { exists: true })
    );

    let environment = send_request(
        harness.socket_path(),
        &Request::ShowEnvironment(ShowEnvironmentRequest {
            scope: ScopeSelector::Session(gamma.clone()),
            name: None,
            hidden: false,
            shell_format: false,
        }),
    )
    .await?;
    let environment_output = environment
        .command_output()
        .expect("show-environment returns command output");
    assert_eq!(
        std::str::from_utf8(environment_output.stdout()).expect("environment output is utf-8"),
        "TERM=screen\n"
    );

    let options = send_request(
        harness.socket_path(),
        &Request::ShowOptions(ShowOptionsRequest {
            scope: rmux_proto::OptionScopeSelector::Window(WindowTarget::new(gamma.clone())),
            name: None,
            value_only: false,
        }),
    )
    .await?;
    let options_output = options
        .command_output()
        .expect("show-options returns command output");
    assert!(std::str::from_utf8(options_output.stdout())
        .expect("options output is utf-8")
        .contains("pane-border-style red"));

    let sent = send_request(
        harness.socket_path(),
        &Request::SendKeys(SendKeysRequest {
            target: rmux_proto::PaneTarget::with_window(gamma.clone(), 0, 0),
            keys: vec!["printf noop".to_owned(), "Enter".to_owned()],
        }),
    )
    .await?;
    assert!(matches!(sent, Response::SendKeys(_)));
    wait_for_file_contents(&hook_path, "renamed-hook").await?;

    handle.shutdown().await?;
    let _ = std::fs::remove_file(&hook_path);
    Ok(())
}

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

    while Instant::now() < deadline {
        match std::fs::read_to_string(path) {
            Ok(contents) if contents == expected => return Ok(()),
            Ok(_) => {}
            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
            Err(error) => return Err(error.into()),
        }

        tokio::time::sleep(Duration::from_millis(20)).await;
    }

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