use std::ffi::OsString;
use std::path::{Path, PathBuf};
#[path = "cli/capture_pane.rs"]
mod capture_pane;
#[path = "cli/client_commands.rs"]
mod client_commands;
#[path = "cli/command_inventory.rs"]
mod command_inventory;
#[path = "cli/command_runner.rs"]
mod command_runner;
#[path = "cli/config_commands.rs"]
mod config_commands;
#[path = "cli/diagnose.rs"]
mod diagnose;
#[path = "cli/dispatch.rs"]
mod dispatch;
#[path = "cli/error.rs"]
mod error;
#[path = "cli/key_commands.rs"]
mod key_commands;
#[path = "cli/pane_commands.rs"]
mod pane_commands;
#[path = "cli/server_commands.rs"]
mod server_commands;
#[path = "cli/session_commands.rs"]
mod session_commands;
#[path = "cli/shell_startup.rs"]
mod shell_startup;
#[path = "cli/startup.rs"]
mod startup;
#[path = "cli/target_resolution.rs"]
mod target_resolution;
#[path = "cli/terminal_size.rs"]
mod terminal_size;
#[path = "cli/top_level.rs"]
mod top_level;
#[path = "cli/window_commands.rs"]
mod window_commands;
use rmux_client::{connect, ensure_server_running_with_config, resolve_socket_path, Connection};
use crate::cli_args::parse;
use crate::cli_response::{expect_command_output, expect_command_success};
use client_commands::{attach_with_connection, run_switch_client_on_connection};
use client_commands::{
client_terminal_context_from_cli, optional_client_flags, run_control_mode, run_detach_client,
run_list_clients, run_refresh_client, run_suspend_client, run_switch_client,
};
#[cfg(test)]
use command_inventory::render_list_commands_line;
use command_runner::{
finish_command_success, unexpected_response, write_command_output, write_lines_output,
};
pub(crate) use command_runner::{
run_command, run_command_resolved, run_payload_command, run_payload_command_resolved,
};
use dispatch::dispatch_command_queue;
#[cfg(test)]
use dispatch::{command_has_start_server_flag, default_client_command};
pub(crate) use error::ExitFailure;
use shell_startup::run_shell_startup;
#[cfg(test)]
use shell_startup::{same_file_identity_for_paths, usable_shell_path};
#[cfg(test)]
use startup::ServerStartupConfig;
use startup::{run_foreground_server, startup_config_from_cli, StartupOptions};
use target_resolution::{
list_session_names, resolve_current_pane_target, resolve_current_session_target,
resolve_existing_window_target_or_current, resolve_pane_target_or_current,
resolve_pane_target_spec, resolve_session_listing_target, resolve_session_target_or_current,
resolve_session_target_spec, resolve_split_window_target_spec, resolve_target_spec,
resolve_window_index_target_or_current_session, resolve_window_target_or_current,
resolve_window_target_spec, response_name_for_target,
};
use terminal_size::{build_terminal_size, current_terminal_size};
use top_level::{
accept_compatibility_options, infer_client_utf8_from_env, top_level_parse_failure,
validate_top_level_invocation,
};
pub(crate) fn run<I, T>(args: I) -> Result<i32, ExitFailure>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
if let Some(error) = top_level_parse_failure(args.get(1..).unwrap_or(&[])) {
return Err(error);
}
if let Some(invocation) = diagnose::parse_invocation(args.get(1..).unwrap_or(&[]))? {
return diagnose::run(invocation);
}
let mut cli = match parse(args.clone()) {
Ok(cli) => cli,
Err(error) => return parse_failure_or_absent_server(&args, error),
};
cli.utf8 |= infer_client_utf8_from_env();
let command_was_provided = cli.command.is_some();
validate_top_level_invocation(&cli, command_was_provided)?;
accept_compatibility_options(&cli);
let startup_config = startup_config_from_cli(&cli);
let socket_path = resolve_socket_path(cli.socket_name(), cli.socket_path())
.map_err(ExitFailure::from_client)?;
if let Some(shell_command) = cli.shell_command.as_deref() {
return run_shell_startup(
&socket_path,
StartupOptions::new(cli.no_start_server, startup_config.auto_start.clone()),
shell_command,
cli.login_shell,
);
}
if cli.no_fork {
return run_foreground_server(&socket_path, &startup_config);
}
let startup = StartupOptions::new(cli.no_start_server, startup_config.auto_start);
if cli.control_mode != 0 {
return run_control_mode(&cli, &socket_path, startup);
}
let client_terminal = client_terminal_context_from_cli(&cli);
let commands = cli.into_command_queue();
dispatch_command_queue(commands, &socket_path, startup, client_terminal)
}
fn parse_failure_or_absent_server(
args: &[OsString],
error: clap::Error,
) -> Result<i32, ExitFailure> {
if !parse_failure_should_probe_server(args, &error) {
return Err(ExitFailure::from_clap(error));
}
let Some((socket_name, socket_path)) = recover_socket_selection(args.get(1..).unwrap_or(&[]))
else {
return Err(ExitFailure::from_clap(error));
};
let resolved = resolve_socket_path(socket_name.as_deref(), socket_path.as_deref())
.map_err(ExitFailure::from_client)?;
match connect(&resolved) {
Ok(_) => Err(ExitFailure::from_clap(error)),
Err(connect_error) => Err(ExitFailure::from_client_connect(&resolved, connect_error)),
}
}
fn parse_failure_should_probe_server(args: &[OsString], error: &clap::Error) -> bool {
match error.kind() {
clap::error::ErrorKind::InvalidSubcommand => true,
clap::error::ErrorKind::ValueValidation => {
first_command_token(args.get(1..).unwrap_or(&[])).as_deref() == Some("resize-pane")
}
_ => false,
}
}
fn recover_socket_selection(arguments: &[OsString]) -> Option<(Option<OsString>, Option<PathBuf>)> {
let mut socket_name = None;
let mut socket_path = None;
let mut index = 0;
while index < arguments.len() {
let argument = arguments[index].to_str()?;
if argument == "--" {
break;
}
if !argument.starts_with('-') || argument == "-" {
break;
}
match argument {
"-L" => {
index += 1;
socket_name = arguments.get(index).cloned();
}
"-S" => {
index += 1;
socket_path = arguments.get(index).cloned().map(PathBuf::from);
}
"-c" | "-f" | "-T" => {
index += 1;
}
value if value.starts_with("-L") && value.len() > 2 => {
socket_name = Some(OsString::from(&value[2..]));
}
value if value.starts_with("-S") && value.len() > 2 => {
socket_path = Some(PathBuf::from(&value[2..]));
}
_ => {}
}
index += 1;
}
Some((socket_name, socket_path))
}
fn first_command_token(arguments: &[OsString]) -> Option<String> {
let mut index = 0;
while index < arguments.len() {
let argument = arguments[index].to_str()?;
if argument == "--" {
return arguments.get(index + 1)?.to_str().map(str::to_owned);
}
if !argument.starts_with('-') || argument == "-" {
return Some(argument.to_owned());
}
if matches!(argument, "-c" | "-f" | "-L" | "-S" | "-T") {
index += 1;
}
index += 1;
}
None
}
fn connect_with_startserver(
socket_path: &Path,
startup: StartupOptions,
) -> Result<Connection, ExitFailure> {
if startup.no_start_server {
connect(socket_path).map_err(|error| ExitFailure::from_client_connect(socket_path, error))
} else {
ensure_server_running_with_config(socket_path, startup.config)
.map_err(ExitFailure::from_auto_start)
}
}
fn shell_command_text(command: Vec<String>) -> String {
if command.len() == 1 {
return command.into_iter().next().expect("single shell token");
}
command
.into_iter()
.map(shell_command_token)
.collect::<Vec<_>>()
.join(" ")
}
fn shell_command_token(token: String) -> String {
format!("'{}'", token.replace('\'', "'\\''"))
}
#[cfg(test)]
mod tests {
use super::{
command_has_start_server_flag, default_client_command, render_list_commands_line,
same_file_identity_for_paths, startup_config_from_cli, top_level_parse_failure,
usable_shell_path, ServerStartupConfig,
};
use crate::cli_args::{
parse as parse_cli, parse_target_spec, AttachSessionArgs, Command, ListSessionsArgs,
NewWindowArgs,
};
use std::ffi::OsString;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
static UNIQUE_TEST_ID: AtomicUsize = AtomicUsize::new(0);
fn args(values: &[&str]) -> Vec<OsString> {
values.iter().map(OsString::from).collect()
}
#[cfg(unix)]
fn unique_test_dir(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"rmux-cli-{label}-{}-{}",
std::process::id(),
UNIQUE_TEST_ID.fetch_add(1, Ordering::Relaxed)
))
}
#[test]
fn top_level_preparse_accepts_tmux_short_help() {
for values in [&["-h"][..], &["-Nh"][..], &["-hV"][..]] {
let error = top_level_parse_failure(&args(values)).expect("expected short help exit");
assert_eq!(error.exit_code(), 0);
assert!(!error.use_stderr());
assert_eq!(
error.message(),
"usage: rmux [-2CDhlNuVv] [-c shell-command] [-f file] [-L socket-name]\n [-S socket-path] [-T features] [command [flags]]"
);
}
}
#[test]
fn top_level_preparse_rejects_long_options_with_tmux_usage() {
assert_eq!(
top_level_parse_failure(&args(&["--help"]))
.expect("expected --help to fail before clap")
.message(),
"usage: rmux [-2CDlNuVv] [-c shell-command] [-f file] [-L socket-name]\n [-S socket-path] [-T features] [command [flags]]"
);
assert_eq!(
top_level_parse_failure(&args(&["--not-a-tmux-flag", "-h"]))
.expect("expected long top-level option to fail before clap")
.message(),
"usage: rmux [-2CDlNuVv] [-c shell-command] [-f file] [-L socket-name]\n [-S socket-path] [-T features] [command [flags]]"
);
assert!(top_level_parse_failure(&args(&["split-window", "-h"])).is_none());
}
#[test]
fn top_level_preparse_rejects_invalid_clusters_with_tmux_unknown_option() {
assert!(top_level_parse_failure(&args(&["-xh"]))
.expect("expected invalid cluster to fail before clap")
.message()
.contains("unknown option -- x"));
assert!(top_level_parse_failure(&args(&["-Nxh"]))
.expect("expected invalid cluster to fail before clap")
.message()
.contains("unknown option -- x"));
}
#[test]
fn top_level_preparse_leaves_version_first_clusters_for_clap() {
assert!(top_level_parse_failure(&args(&["-Vh"])).is_none());
assert!(top_level_parse_failure(&args(&["-lVh"])).is_none());
}
#[test]
fn top_level_preparse_does_not_parse_option_values_as_flags() {
assert!(top_level_parse_failure(&args(&["-L", "-h", "list-sessions",])).is_none());
assert!(top_level_parse_failure(&args(&["-Lhas-h", "list-sessions"])).is_none());
}
#[test]
fn start_server_inventory_matches_supported_frozen_commands() {
assert!(command_has_start_server_flag(&default_client_command()));
assert!(command_has_start_server_flag(&Command::StartServer));
assert!(command_has_start_server_flag(&Command::AttachSession(
AttachSessionArgs {
detach_other_clients: false,
skip_environment_update: false,
flags: Vec::new(),
read_only: false,
target: Some(parse_target_spec("alpha").expect("valid target")),
kill_other_clients: false,
working_directory: None,
}
)));
assert!(!command_has_start_server_flag(&Command::KillServer));
assert!(!command_has_start_server_flag(&Command::ListSessions(
ListSessionsArgs {
format: None,
filter: None,
sort_order: None,
reversed: false,
}
)));
assert!(!command_has_start_server_flag(&Command::NewWindow(
NewWindowArgs {
after: false,
before: false,
target: Some(parse_target_spec("alpha").expect("valid target")),
name: None,
detached: false,
start_directory: None,
environment: Vec::new(),
command: Vec::new(),
}
)));
}
#[test]
fn explicit_config_files_disable_quiet_startup_loading() {
let cli = parse_cli(["rmux", "-f", "one.conf", "-f", "two.conf"]).expect("cli parses");
let startup = startup_config_from_cli(&cli);
match startup.server {
ServerStartupConfig::Files { files, quiet, .. } => {
assert!(!quiet);
assert_eq!(
files,
vec![PathBuf::from("one.conf"), PathBuf::from("two.conf")]
);
}
ServerStartupConfig::Default { .. } => panic!("expected explicit config files"),
}
}
#[cfg(unix)]
#[test]
fn usable_shell_path_rejects_symlink_to_the_current_executable() {
use std::os::unix::fs::symlink;
let current_exe = std::env::current_exe().expect("current executable path");
let dir = unique_test_dir("shell-symlink");
fs::create_dir_all(&dir).expect("create temp dir");
let link = dir.join("rmux-shell-link");
symlink(¤t_exe, &link).expect("create symlink");
assert!(
!usable_shell_path(&link),
"shell startup must reject a differently named symlink to the current executable"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn usable_shell_path_rejects_hardlink_to_the_current_executable() {
let current_exe = std::env::current_exe().expect("current executable path");
let link = current_exe
.parent()
.expect("current executable has a parent directory")
.join(format!(
"rmux-shell-hardlink-{}-{}",
std::process::id(),
UNIQUE_TEST_ID.fetch_add(1, Ordering::Relaxed)
));
let _ = fs::remove_file(&link);
fs::hard_link(¤t_exe, &link).expect("create hardlink");
assert!(same_file_identity_for_paths(¤t_exe, &link));
assert!(
!usable_shell_path(&link),
"shell startup must reject a differently named hardlink to the current executable"
);
let _ = fs::remove_file(&link);
}
#[test]
fn list_commands_tmux_format_variables_expand() {
assert_eq!(
render_list_commands_line(
Some("#{command_list_name}|#{command_list_alias}|#{command_name}|#{command_alias}"),
"attach-session",
Some("attach"),
),
"attach-session|attach|attach-session|attach"
);
}
#[test]
fn list_commands_default_output_matches_tmux_signature_shape() {
assert_eq!(
render_list_commands_line(None, "attach-session", Some("attach")),
"attach-session (attach) [-dErx] [-c working-directory] [-f flags] [-t target-session]"
);
assert_eq!(
render_list_commands_line(None, "kill-server", None),
"kill-server "
);
}
#[test]
fn list_commands_usage_variable_expands_tmux_signature_suffix() {
assert_eq!(
render_list_commands_line(
Some("#{command_name}|#{command_list_usage}"),
"attach-session",
Some("attach"),
),
"attach-session|(attach) [-dErx] [-c working-directory] [-f flags] [-t target-session]"
);
}
}