libtmux 0.1.0-alpha.1

Async typed tmux client and object model
Documentation
//! Reading and writing tmux options and hooks.
//!
//! Options are read one at a time through `show-options -v`, which prints the
//! stored bytes verbatim. The listing form is not used for values: tmux
//! renders them with `args_escape`, which picks bare-with-backslashes, double
//! quotes, or single quotes depending on content, so re-parsing it would be
//! guesswork. Names are read from the listing because a name is plain ASCII.
//!
//! Hooks live in the same option tables in supported tmux releases, so they
//! share this path. A hook is an array option, which is why its name carries
//! an index.

use std::ffi::OsString;

use crate::formats::TmuxText;
use crate::internal::core::Core;
use crate::{Command, Error};

/// Which option table an operation reads or writes.
#[derive(Clone, Copy, Debug)]
pub(crate) enum Scope<'target> {
    /// Server options, tmux's `-s`.
    Server,
    /// Global session options, tmux's `-g`.
    GlobalSession,
    /// Global window options, tmux's `-w -g`.
    GlobalWindow,
    /// One session's options.
    Session(&'target str),
    /// One window's options, tmux's `-w`.
    Window(&'target str),
    /// One pane's options, tmux's `-p`.
    Pane(&'target str),
}

impl Scope<'_> {
    /// Apply this scope's flags to an option command.
    fn apply(self, command: Command) -> Command {
        match self {
            Self::Server => command.arg("-s"),
            Self::GlobalSession => command.arg("-g"),
            Self::GlobalWindow => command.arg("-w").arg("-g"),
            Self::Session(target) => command.arg("-t").arg(OsString::from(target)),
            Self::Window(target) => command.arg("-w").arg("-t").arg(OsString::from(target)),
            Self::Pane(target) => command.arg("-p").arg("-t").arg(OsString::from(target)),
        }
    }
}

/// Read one option's exact stored value.
///
/// Absence is reported two different ways because tmux stores two kinds of
/// option. A built-in option always exists, so an unset one prints nothing and
/// exits zero. A user option, whose name begins with `@`, exists only while it
/// is set, so an unset one is simply unknown and tmux fails. Both become
/// `None`; the name shape decides which rule applies, so no error text is
/// parsed.
///
/// An option deliberately set to the empty string cannot be told apart from an
/// unset one, because tmux prints nothing for either.
pub(crate) async fn get(
    core: &Core,
    scope: Scope<'_>,
    name: &str,
) -> Result<Option<TmuxText>, Error> {
    let command = scope
        .apply(Command::new("show-options"))
        .arg("-v")
        .arg(OsString::from(name));
    let result = core.execute(command).await?;

    if !result.success() {
        // A user option that is not set is not merely empty, it is unknown.
        // For a built-in name, failure means the caller named something tmux
        // does not have.
        if name.starts_with('@') {
            return Ok(None);
        }

        return Err(Error::CommandFailed {
            command: "show-options",
            exit_code: result.exit_code(),
            stderr: result.stderr_lossy().into_owned(),
        });
    }

    let stdout = result.stdout();
    let value = stdout.strip_suffix(b"\n").unwrap_or(stdout);
    if value.is_empty() {
        return Ok(None);
    }

    Ok(Some(TmuxText::from(value.to_vec())))
}

/// List the option names present at one scope.
///
/// Array options repeat once per index, so a name may carry an `[n]` suffix
/// exactly as tmux writes it.
pub(crate) async fn names(core: &Core, scope: Scope<'_>) -> Result<Vec<String>, Error> {
    let result = core
        .execute(scope.apply(Command::new("show-options")))
        .await?;
    if !result.success() {
        return Err(Error::CommandFailed {
            command: "show-options",
            exit_code: result.exit_code(),
            stderr: result.stderr_lossy().into_owned(),
        });
    }

    Ok(result
        .stdout_lossy()
        .lines()
        // Only the name is taken. The rest of the line is tmux's display form,
        // which this module deliberately never re-parses.
        .filter_map(|line| line.split_whitespace().next())
        .map(ToOwned::to_owned)
        .collect())
}

/// Set one option to an exact value.
pub(crate) async fn set(
    core: &Core,
    scope: Scope<'_>,
    name: &str,
    value: impl Into<OsString>,
    append: bool,
) -> Result<(), Error> {
    let mut command = scope.apply(Command::new("set-option"));
    if append {
        command = command.arg("-a");
    }

    run(
        core,
        command
            .arg(OsString::from(name))
            .sensitive_arg(value.into()),
    )
    .await
}

/// Remove one option, restoring whatever it inherits.
pub(crate) async fn unset(core: &Core, scope: Scope<'_>, name: &str) -> Result<(), Error> {
    run(
        core,
        scope
            .apply(Command::new("set-option"))
            .arg("-u")
            .arg(OsString::from(name)),
    )
    .await
}

/// Set one hook to a tmux command.
pub(crate) async fn set_hook(
    core: &Core,
    scope: Scope<'_>,
    name: &str,
    command_text: impl Into<OsString>,
) -> Result<(), Error> {
    run(
        core,
        scope
            .apply(Command::new("set-hook"))
            .arg(OsString::from(name))
            .sensitive_arg(command_text.into()),
    )
    .await
}

/// Remove one hook.
pub(crate) async fn unset_hook(core: &Core, scope: Scope<'_>, name: &str) -> Result<(), Error> {
    run(
        core,
        scope
            .apply(Command::new("set-hook"))
            .arg("-u")
            .arg(OsString::from(name)),
    )
    .await
}

/// Run an option mutation, requiring tmux to accept it.
async fn run(core: &Core, command: Command) -> Result<(), Error> {
    let result = core.execute(command).await?;
    if result.success() {
        return Ok(());
    }

    Err(Error::CommandFailed {
        command: "set-option",
        exit_code: result.exit_code(),
        stderr: result.stderr_lossy().into_owned(),
    })
}