libtmux 0.1.0-alpha.2

Async typed tmux client and object model
Documentation
//! Options and hooks against real tmux.

#![cfg(feature = "test-support")]
// Helpers outside a test function are not covered by clippy.toml's
// in-test exemptions, and these files have them.
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

use libtmux::test::TestServer;
use libtmux::{NewWindowOptions, TmuxText};

fn bytes(value: Option<TmuxText>) -> Vec<u8> {
    value.expect("tmux reports a value").as_bytes().to_vec()
}

#[tokio::test]
async fn option_values_survive_bytes_that_tmux_would_quote_for_display() {
    let guard = TestServer::builder().start().await.expect("tmux starts");
    let server = guard.server();

    // tmux renders each of these differently in its listing form: bare with
    // backslash escapes, double quotes, or single quotes. Reading through
    // `show-options -v` returns the stored bytes whatever the display form.
    for value in [
        "plain",
        "a b  c",
        "has\"quote",
        "has\\back",
        "has;semi",
        "has'single",
        "tab\tsep",
    ] {
        server
            .set_option("@probe", value)
            .await
            .expect("option is set");
        assert_eq!(
            bytes(server.get_option("@probe").await.expect("option is read")),
            value.as_bytes(),
            "{value:?} round-trips exactly",
        );
    }

    guard.shutdown().await.expect("tmux fixture shuts down");
}

#[tokio::test]
async fn an_unknown_option_is_an_error_while_an_unset_one_is_absent() {
    let guard = TestServer::builder().start().await.expect("tmux starts");
    let server = guard.server();

    // A user option exists only while it is set, so an unset one is absent
    // rather than an error, even though tmux itself calls it unknown.
    assert!(
        server
            .get_option("@absent")
            .await
            .expect("an unset user option is absent")
            .is_none(),
    );

    // A built-in name tmux does not have is a caller mistake.
    let error = server
        .get_option("no-such-built-in")
        .await
        .expect_err("an unknown built-in name is refused");
    assert!(matches!(error, libtmux::Error::CommandFailed { .. }));

    // A known option with no value at this scope reports absence instead.
    assert!(
        server
            .get_global_option("after-kill-pane[0]")
            .await
            .expect("a known hook name is accepted")
            .is_none(),
    );

    guard.shutdown().await.expect("tmux fixture shuts down");
}

#[tokio::test]
async fn options_are_scoped_to_the_object_that_set_them() {
    let guard = TestServer::builder().start().await.expect("tmux starts");
    let server = guard.server();

    let session = server.new_session("scoped").await.expect("session created");
    let window = session
        .new_window(NewWindowOptions::new("scoped").command("sleep 300"))
        .await
        .expect("window created");
    let pane = window
        .try_panes()
        .await
        .expect("panes list")
        .into_iter()
        .next()
        .expect("one pane");

    server.set_option("@where", "server").await.expect("set");
    session.set_option("@where", "session").await.expect("set");
    window.set_option("@where", "window").await.expect("set");
    pane.set_option("@where", "pane").await.expect("set");

    assert_eq!(
        bytes(server.get_option("@where").await.expect("read")),
        b"server"
    );
    assert_eq!(
        bytes(session.get_option("@where").await.expect("read")),
        b"session"
    );
    assert_eq!(
        bytes(window.get_option("@where").await.expect("read")),
        b"window"
    );
    assert_eq!(
        bytes(pane.get_option("@where").await.expect("read")),
        b"pane"
    );

    // Unsetting one scope leaves the others alone.
    window.unset_option("@where").await.expect("unset");
    assert!(window.get_option("@where").await.expect("read").is_none());
    assert_eq!(
        bytes(session.get_option("@where").await.expect("read")),
        b"session"
    );
    assert_eq!(
        bytes(pane.get_option("@where").await.expect("read")),
        b"pane"
    );

    guard.shutdown().await.expect("tmux fixture shuts down");
}

#[tokio::test]
async fn appending_extends_a_value_rather_than_replacing_it() {
    let guard = TestServer::builder().start().await.expect("tmux starts");
    let server = guard.server();
    let session = server
        .new_session("appending")
        .await
        .expect("session created");

    session.set_option("@parts", "one").await.expect("set");
    session
        .append_option("@parts", "-two")
        .await
        .expect("append");

    assert_eq!(
        bytes(session.get_option("@parts").await.expect("read")),
        b"one-two",
    );

    guard.shutdown().await.expect("tmux fixture shuts down");
}

#[tokio::test]
async fn option_names_lists_what_is_set_without_guessing_at_values() {
    let guard = TestServer::builder().start().await.expect("tmux starts");
    let server = guard.server();

    server
        .set_option("@listed", "a value with spaces")
        .await
        .expect("option is set");

    let names = server.option_names().await.expect("names are listed");
    assert!(names.iter().any(|name| name == "@listed"));
    // Array options appear once per index, exactly as tmux writes them.
    assert!(
        names.iter().any(|name| name.starts_with("command-alias[")),
        "array options keep their index",
    );

    guard.shutdown().await.expect("tmux fixture shuts down");
}

#[tokio::test]
async fn a_hook_is_stored_as_an_indexed_option() {
    let guard = TestServer::builder().start().await.expect("tmux starts");
    let server = guard.server();

    server
        .set_hook("after-new-window", "display-message hooked")
        .await
        .expect("hook is set");

    // Hooks live in the option tables, so the same reader sees them.
    assert_eq!(
        bytes(
            server
                .get_global_option("after-new-window[0]")
                .await
                .expect("hook is read"),
        ),
        b"display-message hooked",
    );

    server
        .unset_hook("after-new-window")
        .await
        .expect("hook is removed");
    assert!(
        server
            .get_global_option("after-new-window[0]")
            .await
            .expect("hook is read")
            .is_none(),
    );

    guard.shutdown().await.expect("tmux fixture shuts down");
}

#[tokio::test]
async fn option_values_decode_into_flags_and_numbers() {
    let guard = TestServer::builder().start().await.expect("tmux starts");
    let server = guard.server();
    // Reading the global session table needs a session to exist, because tmux
    // resolves it against the current one.
    server.new_session("typed").await.expect("session");

    // tmux's own flag options read as flags.
    let status = server
        .get_global_option("status")
        .await
        .expect("read")
        .expect("status is set");
    assert_eq!(status.as_flag(), Some(true));

    // Numeric options parse without the caller checking UTF-8 first.
    // history-limit lives in the session table, not the server one.
    let limit = server
        .get_global_option("history-limit")
        .await
        .expect("read")
        .expect("history-limit is set");
    assert!(limit.parse::<u32>().is_some_and(|value| value > 0));

    // A value that is neither is reported as neither, rather than guessed at.
    server.set_option("@prose", "sometimes").await.expect("set");
    let prose = server
        .get_option("@prose")
        .await
        .expect("read")
        .expect("the option is set");
    assert_eq!(prose.as_flag(), None);
    assert_eq!(prose.parse::<u32>(), None);

    guard.shutdown().await.expect("tmux fixture shuts down");
}

#[tokio::test]
async fn options_decode_by_declared_kind_rather_than_by_shape() {
    use libtmux::{OptionKind, OptionValue, option_schema};

    let guard = TestServer::builder().start().await.expect("tmux starts");
    let server = guard.server();
    server.new_session("typed").await.expect("session");

    // The schema comes from tmux's own table, so a flag is a flag even though
    // its value is the text "off", and a number is a number.
    assert_eq!(
        option_schema("mouse").map(OptionSchemaKind::kind),
        Some(OptionKind::Flag),
    );
    assert_eq!(
        server.typed_global_option("mouse").await.expect("read"),
        Some(OptionValue::Flag(false)),
    );
    assert!(matches!(
        server
            .typed_global_option("history-limit")
            .await
            .expect("read"),
        Some(OptionValue::Number(limit)) if limit > 0,
    ));

    // `status` looks like a flag and is not: tmux accepts on, off, and 2
    // through 5 for several status lines. Guessing from the value would have
    // called it a flag, which is why the schema comes from tmux's own table.
    assert_eq!(
        option_schema("status").map(OptionSchemaKind::kind),
        Some(OptionKind::Choice),
    );
    assert!(matches!(
        server.typed_global_option("status").await.expect("read"),
        Some(OptionValue::Text(_)),
    ));

    // A user option has no declared type, so it stays text whatever it holds.
    assert_eq!(option_schema("@mine"), None);
    server.set_option("@mine", "on").await.expect("set");
    assert!(matches!(
        server.typed_option("@mine").await.expect("read"),
        Some(OptionValue::Text(_)),
    ));

    guard.shutdown().await.expect("tmux fixture shuts down");
}

use libtmux::OptionSchema as OptionSchemaKind;