use std::ffi::{OsStr, OsString};
use crate::error::ListingDecodeError;
use crate::formats::{FormatCodecError, FormatPlan, ListProfile};
use crate::internal::core::Core;
use crate::snapshot::{
ClientInfo, PaneProjection, SessionInfo, WindowProjection, hydrate_client_infos_from_stdout,
hydrate_pane_projections_from_stdout, hydrate_session_infos_from_stdout,
hydrate_window_projections_from_stdout, pane_projection_plan, window_projection_plan,
};
use crate::{Command, Error};
#[derive(Clone, Copy, Debug)]
pub(crate) enum Scope<'target> {
Server,
Target(&'target str),
SessionTarget(&'target str),
Unscoped,
}
impl<'target> Scope<'target> {
const fn target(self) -> Option<&'target str> {
match self {
Self::Target(target) | Self::SessionTarget(target) => Some(target),
Self::Server | Self::Unscoped => None,
}
}
fn apply(self, command: Command) -> Command {
match self {
Self::Server => command.arg("-a"),
Self::Target(target) => command.arg("-t").arg(OsString::from(target)),
Self::SessionTarget(target) => command.arg("-s").arg("-t").arg(OsString::from(target)),
Self::Unscoped => command,
}
}
}
pub(crate) trait Pushdown {
fn predicate(&self, field: &str) -> String;
}
macro_rules! pushdown_via_display {
($($type:ty),+ $(,)?) => {
$(impl Pushdown for $type {
fn predicate(&self, field: &str) -> String {
format!("#{{==:#{{{field}}},{self}}}")
}
})+
};
}
pushdown_via_display!(crate::SessionId, crate::WindowId, crate::PaneId, i32, u32,);
async fn list(
core: &Core,
list_command: &'static str,
scope: Scope<'_>,
filter: Option<&str>,
template: &str,
) -> Result<Vec<u8>, Error> {
let mut command = scope.apply(Command::new(list_command));
if let Some(filter) = filter {
command = command.arg("-f").arg(OsString::from(filter));
}
let result = core
.execute(command.arg("-F").arg(OsString::from(template)))
.await?;
if !result.success() {
let stderr = result.stderr_lossy().into_owned();
if scope.target().is_none() && stderr.trim_end() == crate::error::NO_CURRENT_TARGET {
return Ok(Vec::new());
}
return Err(Error::from_refused_result(
list_command,
&result,
scope.target().map(OsStr::new),
));
}
Ok(result.stdout().to_vec())
}
fn decode_error(list_command: &'static str) -> impl Fn(FormatCodecError) -> Error {
move |error| Error::DecodeListing {
list_command,
detail: ListingDecodeError::new(error),
}
}
pub(crate) async fn sessions(core: &Core, filter: Option<&str>) -> Result<Vec<SessionInfo>, Error> {
const LIST_COMMAND: &str = "list-sessions";
let version = core.capabilities().await?.tmux_version().clone();
let plan = FormatPlan::for_profile(ListProfile::Sessions, &version);
let stdout = list(core, LIST_COMMAND, Scope::Unscoped, filter, plan.template()).await?;
hydrate_session_infos_from_stdout(&plan, &stdout).map_err(decode_error(LIST_COMMAND))
}
pub(crate) async fn windows(
core: &Core,
scope: Scope<'_>,
filter: Option<&str>,
) -> Result<Vec<WindowProjection>, Error> {
const LIST_COMMAND: &str = "list-windows";
let version = core.capabilities().await?.tmux_version().clone();
let plan = window_projection_plan(&version).map_err(decode_error(LIST_COMMAND))?;
let stdout = list(core, LIST_COMMAND, scope, filter, plan.template()).await?;
hydrate_window_projections_from_stdout(core.configuration().identity(), &plan, &stdout)
.map_err(decode_error(LIST_COMMAND))
}
pub(crate) async fn window_for_pane(
core: &Core,
pane: &crate::PaneId,
) -> Result<Option<WindowProjection>, Error> {
const LIST_COMMAND: &str = "list-panes";
let version = core.capabilities().await?.tmux_version().clone();
let plan = window_projection_plan(&version).map_err(decode_error(LIST_COMMAND))?;
let target = pane.to_string();
let filter = pane.predicate("pane_id");
let stdout = match list(
core,
LIST_COMMAND,
Scope::Target(&target),
Some(&filter),
plan.template(),
)
.await
{
Err(error) if error.is_object_gone() => return Ok(None),
result => result?,
};
Ok(
hydrate_window_projections_from_stdout(core.configuration().identity(), &plan, &stdout)
.map_err(decode_error(LIST_COMMAND))?
.into_iter()
.next(),
)
}
pub(crate) async fn panes(
core: &Core,
scope: Scope<'_>,
filter: Option<&str>,
) -> Result<Vec<PaneProjection>, Error> {
const LIST_COMMAND: &str = "list-panes";
let version = core.capabilities().await?.tmux_version().clone();
let plan = pane_projection_plan(&version).map_err(decode_error(LIST_COMMAND))?;
let stdout = list(core, LIST_COMMAND, scope, filter, plan.template()).await?;
hydrate_pane_projections_from_stdout(core.configuration().identity(), &plan, &stdout)
.map_err(decode_error(LIST_COMMAND))
}
pub(crate) async fn clients(core: &Core, filter: Option<&str>) -> Result<Vec<ClientInfo>, Error> {
const LIST_COMMAND: &str = "list-clients";
let version = core.capabilities().await?.tmux_version().clone();
let plan = FormatPlan::for_profile(ListProfile::Clients, &version);
let stdout = list(core, LIST_COMMAND, Scope::Unscoped, filter, plan.template()).await?;
hydrate_client_infos_from_stdout(&plan, &stdout).map_err(decode_error(LIST_COMMAND))
}
const IN_CLIENT: &str = "display-message";
async fn in_client_context<T>(
core: &Core,
client: &OsStr,
plan: &FormatPlan,
hydrate: impl FnOnce(&FormatPlan, &[u8]) -> Result<Vec<T>, FormatCodecError>,
) -> Result<Option<T>, Error> {
let guarded = format!("#{{?session_id,{},}}", plan.template());
let result = core
.execute(
Command::new(IN_CLIENT)
.arg("-p")
.arg("-t")
.arg(client.to_os_string())
.arg(OsString::from(guarded)),
)
.await?;
if !result.success() {
let stderr = result.stderr_lossy();
if stderr.trim_end() == crate::error::NO_CURRENT_CLIENT {
return Ok(None);
}
return Err(Error::from_refused_result(IN_CLIENT, &result, None));
}
let stdout = result.stdout();
if stdout.strip_suffix(b"\n").unwrap_or(stdout).is_empty() {
return Ok(None);
}
Ok(hydrate(plan, stdout)
.map_err(decode_error(IN_CLIENT))?
.into_iter()
.next())
}
pub(crate) async fn client_session(
core: &Core,
client: &OsStr,
) -> Result<Option<SessionInfo>, Error> {
let version = core.capabilities().await?.tmux_version().clone();
let plan = FormatPlan::for_profile(ListProfile::Sessions, &version);
in_client_context(core, client, &plan, hydrate_session_infos_from_stdout).await
}
pub(crate) async fn client_window(
core: &Core,
client: &OsStr,
) -> Result<Option<WindowProjection>, Error> {
let version = core.capabilities().await?.tmux_version().clone();
let plan = window_projection_plan(&version).map_err(decode_error(IN_CLIENT))?;
in_client_context(core, client, &plan, |plan, stdout| {
hydrate_window_projections_from_stdout(core.configuration().identity(), plan, stdout)
})
.await
}
pub(crate) async fn client_pane(
core: &Core,
client: &OsStr,
) -> Result<Option<PaneProjection>, Error> {
let version = core.capabilities().await?.tmux_version().clone();
let plan = pane_projection_plan(&version).map_err(decode_error(IN_CLIENT))?;
in_client_context(core, client, &plan, |plan, stdout| {
hydrate_pane_projections_from_stdout(core.configuration().identity(), plan, stdout)
})
.await
}
async fn create_one<T>(
core: &Core,
command_name: &'static str,
build: impl FnOnce(&str) -> Command,
template: &str,
hydrate: impl FnOnce(&[u8]) -> Result<Vec<T>, Error>,
) -> Result<T, Error> {
let command = build(template);
let target = command.target().map(OsStr::to_os_string);
let result = core.execute(command).await?;
if !result.success() {
return Err(Error::from_refused_result(
command_name,
&result,
target.as_deref(),
));
}
if result.stdout().is_empty() {
let stderr = result.stderr_lossy();
return Err(Error::NoEffect {
command: command_name,
stderr: if stderr.trim().is_empty() {
"tmux gave no reason".to_owned()
} else {
stderr.into_owned()
},
});
}
hydrate(result.stdout())
.map_err(|error| error.after_effect(command_name))?
.into_iter()
.next()
.ok_or_else(|| {
Error::CommandFailed {
command: command_name,
exit_code: result.exit_code(),
stderr: String::from("tmux printed no object for a creating command"),
}
.after_effect(command_name)
})
}
pub(crate) async fn create_session(
core: &Core,
build: impl FnOnce(&str) -> Command,
) -> Result<SessionInfo, Error> {
let version = core.capabilities().await?.tmux_version().clone();
let plan = FormatPlan::for_profile(ListProfile::Sessions, &version);
let template = plan.template().to_owned();
create_one(core, "new-session", build, &template, |stdout| {
hydrate_session_infos_from_stdout(&plan, stdout).map_err(decode_error("new-session"))
})
.await
}
pub(crate) async fn create_window(
core: &Core,
build: impl FnOnce(&str) -> Command,
) -> Result<WindowProjection, Error> {
let version = core.capabilities().await?.tmux_version().clone();
let plan = window_projection_plan(&version).map_err(decode_error("new-window"))?;
let template = plan.template().to_owned();
let identity = core.configuration().identity();
create_one(core, "new-window", build, &template, |stdout| {
hydrate_window_projections_from_stdout(identity, &plan, stdout)
.map_err(decode_error("new-window"))
})
.await
}
pub(crate) async fn create_pane(
core: &Core,
build: impl FnOnce(&str) -> Command,
) -> Result<PaneProjection, Error> {
let version = core.capabilities().await?.tmux_version().clone();
let plan = pane_projection_plan(&version).map_err(decode_error("split-window"))?;
let template = plan.template().to_owned();
let identity = core.configuration().identity();
create_one(core, "split-window", build, &template, |stdout| {
hydrate_pane_projections_from_stdout(identity, &plan, stdout)
.map_err(decode_error("split-window"))
})
.await
}
pub(crate) async fn mutate(
core: &Core,
command_name: &'static str,
command: Command,
) -> Result<(), Error> {
let target = command.target().map(OsStr::to_os_string);
let result = core.execute(command).await?;
if result.success() {
return Ok(());
}
Err(mutation_failure(command_name, &result, target.as_deref()))
}
pub(crate) fn mutation_failure(
command_name: &'static str,
result: &crate::CommandResult,
target: Option<&OsStr>,
) -> Error {
Error::from_refused_result(command_name, result, target)
}
#[cfg(test)]
mod tests {
use std::os::unix::process::ExitStatusExt as _;
use std::process::ExitStatus;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::{client_session, create_session, mutation_failure};
use crate::command::{CommandRequest, CommandResult, ProcessStatus, RequestId};
use crate::formats::{FIELD_SEPARATOR, FormatPlan, ListProfile};
use crate::internal::core::Core;
use crate::internal::executor::{DispatchFuture, Executor, ShutdownFuture};
use crate::{Command, Error, ErrorKind};
struct CreationExecutor {
calls: AtomicUsize,
stdout: &'static [u8],
}
impl Executor for CreationExecutor {
fn execute(&self, request: CommandRequest) -> DispatchFuture {
let call = self.calls.fetch_add(1, Ordering::SeqCst);
let stdout = if call == 0 {
b"tmux 3.7b\n".to_vec()
} else {
assert_eq!(call, 1, "one probe and one creating command");
self.stdout.to_vec()
};
DispatchFuture::new(async move {
Ok(CommandResult::new(
request.request_id(),
request.summary().clone(),
ProcessStatus::from_exit_status(ExitStatus::from_raw(0)),
stdout,
Vec::new(),
))
})
}
fn shutdown(&self) -> ShutdownFuture {
ShutdownFuture::new(async { Ok(()) })
}
}
struct CapturingExecutor {
commands: std::sync::Mutex<Vec<String>>,
}
impl Executor for CapturingExecutor {
fn execute(&self, request: CommandRequest) -> DispatchFuture {
let summary = format!("{:?}", request.argv());
let probe = summary.contains("display-message") || summary.contains("list-");
self.commands
.lock()
.expect("the capture lock")
.push(summary);
let stdout = if probe {
Vec::new()
} else {
b"tmux 3.7b\n".to_vec()
};
DispatchFuture::new(async move {
Ok(CommandResult::new(
request.request_id(),
request.summary().clone(),
ProcessStatus::from_exit_status(ExitStatus::from_raw(0)),
stdout,
Vec::new(),
))
})
}
fn shutdown(&self) -> ShutdownFuture {
ShutdownFuture::new(async { Ok(()) })
}
}
fn darwin_strftime(template: &str) -> String {
let mut out = String::new();
let mut rest = template.chars();
while let Some(character) = rest.next() {
if character != '%' {
out.push(character);
continue;
}
match rest.next() {
Some('%') => out.push('%'),
Some(undefined) => out.push(undefined),
None => {}
}
}
out
}
#[tokio::test]
async fn a_client_lookup_template_survives_darwin_strftime() {
let executor = Arc::new(CapturingExecutor {
commands: std::sync::Mutex::new(Vec::new()),
});
let core = Core::from_executor_for_test(executor.clone());
let _ = client_session(&core, std::ffi::OsStr::new("/dev/pts/0")).await;
let commands = executor.commands.lock().expect("the capture lock").clone();
let dispatched = commands
.iter()
.find(|command| command.contains("#{"))
.expect("a lookup dispatches a format template");
if !dispatched.contains("display-message") {
return;
}
let field_separator = FIELD_SEPARATOR as char;
let expected = FormatPlan::for_profile(
ListProfile::Sessions,
core.capabilities().await.expect("a version").tmux_version(),
);
let wanted = expected.template().matches(field_separator).count();
assert!(wanted > 0, "a sessions template separates its fields");
assert!(
!dispatched.contains('%'),
"a lookup template must contain no `%`; sent {dispatched}",
);
let survived = darwin_strftime(dispatched).matches(field_separator).count();
assert_eq!(
survived, wanted,
"every field separator has to survive strftime; sent {dispatched}",
);
}
#[test]
fn sensitive_mutation_failure_withholds_tmux_output() {
let secret = "sentinel-mutation-secret";
let command = Command::new("set-option")
.arg("--")
.arg("mouse")
.sensitive_arg(secret);
let result = CommandResult::new(
RequestId::new(1),
command.summary(),
ProcessStatus::from_exit_status(ExitStatus::from_raw(1 << 8)),
Vec::new(),
format!("bad value: {secret}\n").into_bytes(),
);
let error = mutation_failure("set-option", &result, None);
assert!(matches!(&error, Error::CommandFailed { .. }));
let diagnostic = format!("{error:?} {error}");
assert!(!diagnostic.contains(secret), "{diagnostic}");
}
#[tokio::test]
async fn successful_creation_marks_decode_and_missing_object_failures() {
let executor = Arc::new(CreationExecutor {
calls: AtomicUsize::new(0),
stdout: b"malformed\n".as_slice(),
});
let core = Core::from_executor_for_test(executor.clone());
let error = create_session(&core, |_format| Command::new("new-session"))
.await
.expect_err("tmux succeeded but did not describe the created session");
assert_eq!(executor.calls.load(Ordering::SeqCst), 2);
assert_eq!(error.kind(), ErrorKind::PartialEffect);
assert!(matches!(
error,
Error::AfterEffect {
operation: "new-session",
..
}
));
}
#[tokio::test]
async fn creation_that_printed_nothing_is_a_refusal_not_a_partial_effect() {
let executor = Arc::new(CreationExecutor {
calls: AtomicUsize::new(0),
stdout: b"".as_slice(),
});
let core = Core::from_executor_for_test(executor.clone());
let error = create_session(&core, |_format| Command::new("new-session"))
.await
.expect_err("tmux succeeded but described no session");
assert_ne!(error.kind(), ErrorKind::PartialEffect);
assert!(matches!(
error,
Error::NoEffect {
command: "new-session",
..
}
));
}
}