rmux-server 0.1.1

Tokio daemon and request dispatcher for the RMUX terminal multiplexer.
Documentation
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;

use super::RequestHandler;
use rmux_proto::{
    CapturePaneRequest, LoadBufferRequest, NewSessionRequest, PaneTarget, Request, Response,
    SaveBufferRequest, SendKeysRequest, SetBufferRequest, ShowBufferRequest, TerminalSize,
};
use tokio::time::sleep;

static UNIQUE_ID: AtomicUsize = AtomicUsize::new(0);

fn session_name(value: &str) -> rmux_proto::SessionName {
    rmux_proto::SessionName::new(value).expect("valid session name")
}

fn capture_pane_request(
    target: PaneTarget,
    start: Option<i64>,
    end: Option<i64>,
    print: bool,
    buffer_name: Option<&str>,
) -> CapturePaneRequest {
    CapturePaneRequest {
        target,
        start,
        end,
        print,
        buffer_name: buffer_name.map(str::to_owned),
        alternate: false,
        escape_ansi: false,
        escape_sequences: false,
        join_wrapped: false,
        use_mode_screen: false,
        preserve_trailing_spaces: false,
        do_not_trim_spaces: false,
        pending_input: false,
        quiet: false,
        start_is_absolute: false,
        end_is_absolute: false,
    }
}

fn set_buffer_request(name: &str, content: &[u8]) -> SetBufferRequest {
    SetBufferRequest {
        name: Some(name.to_owned()),
        content: content.to_vec(),
        append: false,
        new_name: None,
        set_clipboard: false,
    }
}

fn load_buffer_request(
    path: &std::path::Path,
    cwd: Option<std::path::PathBuf>,
    name: &str,
) -> LoadBufferRequest {
    LoadBufferRequest {
        path: path.display().to_string(),
        cwd,
        name: Some(name.to_owned()),
        set_clipboard: false,
    }
}

fn save_buffer_request(
    path: &std::path::Path,
    cwd: Option<std::path::PathBuf>,
    name: &str,
) -> SaveBufferRequest {
    SaveBufferRequest {
        path: path.display().to_string(),
        cwd,
        name: Some(name.to_owned()),
        append: false,
    }
}

async fn create_session(handler: &RequestHandler, name: &str) {
    let response = handler
        .handle(Request::NewSession(NewSessionRequest {
            session_name: session_name(name),
            detached: true,
            size: Some(TerminalSize { cols: 80, rows: 24 }),
            environment: None,
        }))
        .await;

    assert!(matches!(response, Response::NewSession(_)));
}

async fn send_marker(handler: &RequestHandler, target: PaneTarget, marker: &str) {
    let response = handler
        .handle(Request::SendKeys(SendKeysRequest {
            target,
            keys: vec![marker_print_command(marker), "Enter".to_owned()],
        }))
        .await;

    assert!(matches!(response, Response::SendKeys(_)));
}

#[cfg(unix)]
fn marker_print_command(marker: &str) -> String {
    format!("printf '{marker}\\n'")
}

#[cfg(windows)]
fn marker_print_command(marker: &str) -> String {
    format!("echo {marker}")
}

async fn wait_for_capture(handler: &RequestHandler, target: PaneTarget, marker: &str) -> Vec<u8> {
    for _ in 0..100 {
        let response = handler
            .handle(Request::CapturePane(capture_pane_request(
                target.clone(),
                None,
                None,
                true,
                None,
            )))
            .await;

        let output = response
            .command_output()
            .expect("capture-pane -p returns command output");
        if String::from_utf8_lossy(output.stdout()).contains(marker) {
            return output.stdout().to_vec();
        }

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

    panic!("capture output never contained marker {marker}");
}

#[tokio::test]
async fn capture_pane_prints_transcript_without_creating_buffer() {
    let handler = RequestHandler::new();
    let target = PaneTarget::with_window(session_name("alpha"), 0, 0);
    let marker = "handler_capture_print_marker";

    create_session(&handler, "alpha").await;
    send_marker(&handler, target.clone(), marker).await;

    let output = wait_for_capture(&handler, target, marker).await;
    assert!(String::from_utf8_lossy(&output).contains(marker));

    let show = handler
        .handle(Request::ShowBuffer(ShowBufferRequest { name: None }))
        .await;
    assert!(matches!(show, Response::Error(_)));
}

#[tokio::test]
async fn capture_pane_writes_named_buffer() {
    let handler = RequestHandler::new();
    let target = PaneTarget::with_window(session_name("alpha"), 0, 0);
    let marker = "handler_capture_buffer_marker";

    create_session(&handler, "alpha").await;
    send_marker(&handler, target.clone(), marker).await;
    wait_for_capture(&handler, target.clone(), marker).await;

    let capture = handler
        .handle(Request::CapturePane(capture_pane_request(
            target,
            None,
            None,
            false,
            Some("capture-buffer"),
        )))
        .await;
    match capture {
        Response::CapturePane(response) => {
            assert_eq!(response.buffer_name.as_deref(), Some("capture-buffer"));
            assert!(response.command_output().is_none());
        }
        other => panic!("expected capture response, got {other:?}"),
    }

    let show = handler
        .handle(Request::ShowBuffer(ShowBufferRequest {
            name: Some("capture-buffer".to_owned()),
        }))
        .await;
    let output = show.command_output().expect("show-buffer returns output");
    assert!(String::from_utf8_lossy(output.stdout()).contains(marker));
}

#[tokio::test]
async fn load_buffer_reads_server_file() {
    let handler = RequestHandler::new();
    let path = temp_path("load-success");
    std::fs::write(&path, b"loaded data").expect("write input");

    let response = handler
        .handle(Request::LoadBuffer(load_buffer_request(
            &path, None, "loaded",
        )))
        .await;
    match response {
        Response::LoadBuffer(response) => assert_eq!(response.buffer_name, "loaded"),
        other => panic!("expected load-buffer response, got {other:?}"),
    }

    let show = handler
        .handle(Request::ShowBuffer(ShowBufferRequest {
            name: Some("loaded".to_owned()),
        }))
        .await;
    assert_eq!(
        show.command_output()
            .expect("show-buffer returns output")
            .stdout(),
        b"loaded data"
    );

    let _ = std::fs::remove_file(path);
}

#[tokio::test]
async fn load_buffer_failure_does_not_mutate_existing_buffer() {
    let handler = RequestHandler::new();
    let missing_path = temp_path("load-missing");

    handler
        .handle(Request::SetBuffer(set_buffer_request(
            "stable",
            b"original",
        )))
        .await;

    let response = handler
        .handle(Request::LoadBuffer(load_buffer_request(
            &missing_path,
            None,
            "stable",
        )))
        .await;
    assert!(matches!(response, Response::Error(_)));

    let show = handler
        .handle(Request::ShowBuffer(ShowBufferRequest {
            name: Some("stable".to_owned()),
        }))
        .await;
    assert_eq!(
        show.command_output()
            .expect("show-buffer returns output")
            .stdout(),
        b"original"
    );
}

#[tokio::test]
async fn load_buffer_resolves_relative_path_against_request_cwd() {
    let handler = RequestHandler::new();
    let root = temp_path("load-relative-root");
    let nested_dir = root.join("nested");
    std::fs::create_dir_all(&nested_dir).expect("create nested dir");
    std::fs::write(nested_dir.join("input.txt"), b"relative data").expect("write input");

    let response = handler
        .handle(Request::LoadBuffer(load_buffer_request(
            &std::path::Path::new("nested").join("input.txt"),
            Some(root.clone()),
            "loaded",
        )))
        .await;
    match response {
        Response::LoadBuffer(response) => assert_eq!(response.buffer_name, "loaded"),
        other => panic!("expected load-buffer response, got {other:?}"),
    }

    let show = handler
        .handle(Request::ShowBuffer(ShowBufferRequest {
            name: Some("loaded".to_owned()),
        }))
        .await;
    assert_eq!(
        show.command_output()
            .expect("show-buffer returns output")
            .stdout(),
        b"relative data"
    );

    let _ = std::fs::remove_dir_all(root);
}

#[tokio::test]
async fn save_buffer_writes_server_file() {
    let handler = RequestHandler::new();
    let path = temp_path("save-success");

    handler
        .handle(Request::SetBuffer(set_buffer_request("saved", b"save me")))
        .await;

    let response = handler
        .handle(Request::SaveBuffer(save_buffer_request(
            &path, None, "saved",
        )))
        .await;
    match response {
        Response::SaveBuffer(response) => assert_eq!(response.buffer_name, "saved"),
        other => panic!("expected save-buffer response, got {other:?}"),
    }
    assert_eq!(std::fs::read(&path).expect("read saved file"), b"save me");

    let _ = std::fs::remove_file(path);
}

#[tokio::test]
async fn save_buffer_resolves_relative_path_against_request_cwd() {
    let handler = RequestHandler::new();
    let root = temp_path("save-relative-root");
    let nested_dir = root.join("nested");
    std::fs::create_dir_all(&nested_dir).expect("create nested dir");

    handler
        .handle(Request::SetBuffer(set_buffer_request(
            "saved",
            b"relative save",
        )))
        .await;

    let response = handler
        .handle(Request::SaveBuffer(save_buffer_request(
            &std::path::Path::new("nested").join("output.txt"),
            Some(root.clone()),
            "saved",
        )))
        .await;
    match response {
        Response::SaveBuffer(response) => assert_eq!(response.buffer_name, "saved"),
        other => panic!("expected save-buffer response, got {other:?}"),
    }
    assert_eq!(
        std::fs::read(nested_dir.join("output.txt")).expect("read saved file"),
        b"relative save"
    );

    let _ = std::fs::remove_dir_all(root);
}

#[tokio::test]
async fn save_buffer_failure_does_not_mutate_existing_buffer() {
    let handler = RequestHandler::new();
    let path = temp_path("missing-parent").join("out.txt");

    handler
        .handle(Request::SetBuffer(set_buffer_request(
            "stable",
            b"original",
        )))
        .await;

    let response = handler
        .handle(Request::SaveBuffer(save_buffer_request(
            &path, None, "stable",
        )))
        .await;
    assert!(matches!(response, Response::Error(_)));

    let show = handler
        .handle(Request::ShowBuffer(ShowBufferRequest {
            name: Some("stable".to_owned()),
        }))
        .await;
    assert_eq!(
        show.command_output()
            .expect("show-buffer returns output")
            .stdout(),
        b"original"
    );
}

fn temp_path(label: &str) -> std::path::PathBuf {
    let unique_id = UNIQUE_ID.fetch_add(1, Ordering::Relaxed);
    std::env::temp_dir().join(format!(
        "rmux-handler-{label}-{}-{unique_id}",
        std::process::id()
    ))
}