//! 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, stderr: Vec, } trait TmuxCommandRunner { fn run(&self, command: TmuxCommand<'_>) -> Result; } struct LiveTmuxCommandRunner; impl TmuxCommandRunner for LiveTmuxCommandRunner { fn run(&self, command: TmuxCommand<'_>) -> Result { 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 { Available(T), Unsupported, Unavailable, Error(String), } impl TmuxQueryResult { pub fn into_option(self) -> Option { match self { Self::Available(value) => Some(value), Self::Unsupported | Self::Unavailable | Self::Error(_) => None, } } } pub fn query_version() -> TmuxQueryResult { query_version_with(&LiveTmuxCommandRunner) } fn query_version_with(runner: &dyn TmuxCommandRunner) -> TmuxQueryResult { parse_value(runner.run(TmuxCommand::Version)) } pub fn query_option(option: &str) -> TmuxQueryResult { query_option_with(&LiveTmuxCommandRunner, option) } fn query_option_with(runner: &dyn TmuxCommandRunner, option: &str) -> TmuxQueryResult { 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 { query_control_mode_with(&LiveTmuxCommandRunner) } fn query_control_mode_with(runner: &dyn TmuxCommandRunner) -> TmuxQueryResult { 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) -> TmuxQueryResult { 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, calls: RefCell>, } 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 { 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::>(), args); let actual: HashMap> = cmd .get_envs() .map(|(key, value)| (key.to_owned(), value.map(OsStr::to_owned))) .collect(); let expected: HashMap> = 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 ); } }