274 lines
9 KiB
Rust
274 lines
9 KiB
Rust
|
|
//! Shared tmux command protocol and result parsing.
|
||
|
|
|
||
|
|
use std::process::{Command, Stdio};
|
||
|
|
|
||
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
|
|
enum TmuxCommand<'a> {
|
||
|
|
Version,
|
||
|
|
OptionValue(&'a str),
|
||
|
|
OptionSupport(&'a str),
|
||
|
|
ControlMode,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
|
|
struct TmuxCommandOutput {
|
||
|
|
status_success: bool,
|
||
|
|
stdout: Vec<u8>,
|
||
|
|
stderr: Vec<u8>,
|
||
|
|
}
|
||
|
|
|
||
|
|
trait TmuxCommandRunner {
|
||
|
|
fn run(&self, command: TmuxCommand<'_>) -> Result<TmuxCommandOutput, String>;
|
||
|
|
}
|
||
|
|
|
||
|
|
struct LiveTmuxCommandRunner;
|
||
|
|
|
||
|
|
impl TmuxCommandRunner for LiveTmuxCommandRunner {
|
||
|
|
fn run(&self, command: TmuxCommand<'_>) -> Result<TmuxCommandOutput, String> {
|
||
|
|
let output = build_tmux_command(command)
|
||
|
|
.output()
|
||
|
|
.map_err(|error| format!("failed to run tmux: {error}"))?;
|
||
|
|
Ok(TmuxCommandOutput {
|
||
|
|
status_success: output.status.success(),
|
||
|
|
stdout: output.stdout,
|
||
|
|
stderr: output.stderr,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
|
|
pub enum TmuxQueryResult<T> {
|
||
|
|
Available(T),
|
||
|
|
Unsupported,
|
||
|
|
Unavailable,
|
||
|
|
Error(String),
|
||
|
|
}
|
||
|
|
|
||
|
|
impl<T> TmuxQueryResult<T> {
|
||
|
|
pub fn into_option(self) -> Option<T> {
|
||
|
|
match self {
|
||
|
|
Self::Available(value) => Some(value),
|
||
|
|
Self::Unsupported | Self::Unavailable | Self::Error(_) => None,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn query_version() -> TmuxQueryResult<String> {
|
||
|
|
query_version_with(&LiveTmuxCommandRunner)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn query_version_with(runner: &dyn TmuxCommandRunner) -> TmuxQueryResult<String> {
|
||
|
|
parse_value(runner.run(TmuxCommand::Version))
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn query_option(option: &str) -> TmuxQueryResult<String> {
|
||
|
|
query_option_with(&LiveTmuxCommandRunner, option)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn query_option_with(runner: &dyn TmuxCommandRunner, option: &str) -> TmuxQueryResult<String> {
|
||
|
|
parse_value(runner.run(TmuxCommand::OptionValue(option)))
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn query_option_support(option: &str) -> TmuxQueryResult<()> {
|
||
|
|
query_option_support_with(&LiveTmuxCommandRunner, option)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn query_option_support_with(runner: &dyn TmuxCommandRunner, option: &str) -> TmuxQueryResult<()> {
|
||
|
|
match runner.run(TmuxCommand::OptionSupport(option)) {
|
||
|
|
Ok(output) if output.status_success => TmuxQueryResult::Available(()),
|
||
|
|
Ok(output) if stderr_identifies_unknown_option(&output.stderr, option) => {
|
||
|
|
TmuxQueryResult::Unsupported
|
||
|
|
}
|
||
|
|
Ok(_) => TmuxQueryResult::Unavailable,
|
||
|
|
Err(error) => TmuxQueryResult::Error(error),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn query_control_mode() -> TmuxQueryResult<bool> {
|
||
|
|
query_control_mode_with(&LiveTmuxCommandRunner)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn query_control_mode_with(runner: &dyn TmuxCommandRunner) -> TmuxQueryResult<bool> {
|
||
|
|
match runner.run(TmuxCommand::ControlMode) {
|
||
|
|
Ok(output) if output.status_success => TmuxQueryResult::Available(
|
||
|
|
String::from_utf8_lossy(&output.stdout).contains("control-mode"),
|
||
|
|
),
|
||
|
|
Ok(_) => TmuxQueryResult::Unavailable,
|
||
|
|
Err(error) => TmuxQueryResult::Error(error),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn build_tmux_command(command: TmuxCommand<'_>) -> Command {
|
||
|
|
let mut cmd = Command::new("tmux");
|
||
|
|
match command {
|
||
|
|
TmuxCommand::Version => {
|
||
|
|
cmd.arg("-V").stdout(Stdio::piped()).stderr(Stdio::null());
|
||
|
|
}
|
||
|
|
TmuxCommand::OptionValue(option) => {
|
||
|
|
cmd.args(["show-option", "-gqv", option])
|
||
|
|
.stdout(Stdio::piped())
|
||
|
|
.stderr(Stdio::null());
|
||
|
|
}
|
||
|
|
TmuxCommand::OptionSupport(option) => {
|
||
|
|
cmd.args(["show-option", "-gv", option])
|
||
|
|
.stdout(Stdio::null())
|
||
|
|
.stderr(Stdio::piped());
|
||
|
|
}
|
||
|
|
TmuxCommand::ControlMode => {
|
||
|
|
cmd.args(["display-message", "-p", "#{client_flags}"])
|
||
|
|
.stdout(Stdio::piped())
|
||
|
|
.stderr(Stdio::null());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
cmd.stdin(Stdio::null()).envs(xai_tty_utils::pager_env());
|
||
|
|
xai_tty_utils::detach_std_command(&mut cmd);
|
||
|
|
cmd
|
||
|
|
}
|
||
|
|
|
||
|
|
fn parse_value(output: Result<TmuxCommandOutput, String>) -> TmuxQueryResult<String> {
|
||
|
|
match output {
|
||
|
|
Ok(output) if output.status_success => {
|
||
|
|
let value = String::from_utf8_lossy(&output.stdout).trim().to_owned();
|
||
|
|
if value.is_empty() {
|
||
|
|
TmuxQueryResult::Unavailable
|
||
|
|
} else {
|
||
|
|
TmuxQueryResult::Available(value)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Ok(_) => TmuxQueryResult::Unavailable,
|
||
|
|
Err(error) => TmuxQueryResult::Error(error),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn stderr_identifies_unknown_option(stderr: &[u8], option: &str) -> bool {
|
||
|
|
let invalid = format!("invalid option: {option}");
|
||
|
|
let unknown = format!("unknown option: {option}");
|
||
|
|
String::from_utf8_lossy(stderr)
|
||
|
|
.lines()
|
||
|
|
.any(|line| matches!(line.trim(), value if value == invalid || value == unknown))
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use std::cell::RefCell;
|
||
|
|
use std::collections::HashMap;
|
||
|
|
use std::ffi::{OsStr, OsString};
|
||
|
|
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
struct FakeRunner {
|
||
|
|
output: Result<TmuxCommandOutput, String>,
|
||
|
|
calls: RefCell<Vec<String>>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl FakeRunner {
|
||
|
|
fn output(status_success: bool, stdout: &[u8], stderr: &[u8]) -> Self {
|
||
|
|
Self {
|
||
|
|
output: Ok(TmuxCommandOutput {
|
||
|
|
status_success,
|
||
|
|
stdout: stdout.to_vec(),
|
||
|
|
stderr: stderr.to_vec(),
|
||
|
|
}),
|
||
|
|
calls: RefCell::new(Vec::new()),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl TmuxCommandRunner for FakeRunner {
|
||
|
|
fn run(&self, command: TmuxCommand<'_>) -> Result<TmuxCommandOutput, String> {
|
||
|
|
self.calls.borrow_mut().push(match command {
|
||
|
|
TmuxCommand::Version => "version".to_owned(),
|
||
|
|
TmuxCommand::OptionValue(option) => format!("value:{option}"),
|
||
|
|
TmuxCommand::OptionSupport(option) => format!("support:{option}"),
|
||
|
|
TmuxCommand::ControlMode => "control-mode".to_owned(),
|
||
|
|
});
|
||
|
|
self.output.clone()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn command_protocol_uses_exact_argv_and_pager_env() {
|
||
|
|
let cases = [
|
||
|
|
(TmuxCommand::Version, vec!["-V"]),
|
||
|
|
(
|
||
|
|
TmuxCommand::OptionValue("set-clipboard"),
|
||
|
|
vec!["show-option", "-gqv", "set-clipboard"],
|
||
|
|
),
|
||
|
|
(
|
||
|
|
TmuxCommand::OptionSupport("allow-passthrough"),
|
||
|
|
vec!["show-option", "-gv", "allow-passthrough"],
|
||
|
|
),
|
||
|
|
(
|
||
|
|
TmuxCommand::ControlMode,
|
||
|
|
vec!["display-message", "-p", "#{client_flags}"],
|
||
|
|
),
|
||
|
|
];
|
||
|
|
for (request, args) in cases {
|
||
|
|
let cmd = build_tmux_command(request);
|
||
|
|
assert_eq!(cmd.get_program(), OsStr::new("tmux"));
|
||
|
|
assert_eq!(cmd.get_args().collect::<Vec<_>>(), args);
|
||
|
|
let actual: HashMap<OsString, Option<OsString>> = cmd
|
||
|
|
.get_envs()
|
||
|
|
.map(|(key, value)| (key.to_owned(), value.map(OsStr::to_owned)))
|
||
|
|
.collect();
|
||
|
|
let expected: HashMap<OsString, Option<OsString>> = xai_tty_utils::pager_env()
|
||
|
|
.into_iter()
|
||
|
|
.map(|(key, value)| (key.into(), Some(value.into())))
|
||
|
|
.collect();
|
||
|
|
assert_eq!(actual, expected);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn value_and_control_queries_use_the_injected_runner() {
|
||
|
|
let runner = FakeRunner::output(true, b" on\n", b"");
|
||
|
|
assert_eq!(
|
||
|
|
query_option_with(&runner, "set-clipboard"),
|
||
|
|
TmuxQueryResult::Available("on".to_owned())
|
||
|
|
);
|
||
|
|
assert_eq!(runner.calls.into_inner(), ["value:set-clipboard"]);
|
||
|
|
|
||
|
|
let runner = FakeRunner::output(true, b"control-mode,utf8\n", b"");
|
||
|
|
assert_eq!(
|
||
|
|
query_control_mode_with(&runner),
|
||
|
|
TmuxQueryResult::Available(true)
|
||
|
|
);
|
||
|
|
assert_eq!(runner.calls.into_inner(), ["control-mode"]);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn nonzero_and_execution_failure_remain_fail_open_facts() {
|
||
|
|
let runner = FakeRunner::output(false, b"on", b"server unavailable");
|
||
|
|
assert_eq!(
|
||
|
|
query_option_with(&runner, "set-clipboard"),
|
||
|
|
TmuxQueryResult::Unavailable
|
||
|
|
);
|
||
|
|
let runner = FakeRunner {
|
||
|
|
output: Err("spawn failed".to_owned()),
|
||
|
|
calls: RefCell::new(Vec::new()),
|
||
|
|
};
|
||
|
|
assert_eq!(
|
||
|
|
query_version_with(&runner),
|
||
|
|
TmuxQueryResult::Error("spawn failed".to_owned())
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn support_query_accepts_both_known_spellings_only() {
|
||
|
|
for stderr in [
|
||
|
|
b"invalid option: allow-passthrough\n".as_slice(),
|
||
|
|
b"unknown option: allow-passthrough\n".as_slice(),
|
||
|
|
] {
|
||
|
|
let runner = FakeRunner::output(false, b"", stderr);
|
||
|
|
assert_eq!(
|
||
|
|
query_option_support_with(&runner, "allow-passthrough"),
|
||
|
|
TmuxQueryResult::Unsupported
|
||
|
|
);
|
||
|
|
}
|
||
|
|
let runner = FakeRunner::output(false, b"", b"no server running\n");
|
||
|
|
assert_eq!(
|
||
|
|
query_option_support_with(&runner, "allow-passthrough"),
|
||
|
|
TmuxQueryResult::Unavailable
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|