use std::ffi::OsString;
use std::io::{self, ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::process::Command as ProcessCommand;
use rmux_client::resolve_socket_path;
use super::ExitFailure;
#[cfg(windows)]
#[path = "diagnose_windows.rs"]
mod diagnose_windows;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DiagnoseFormat {
Human,
Json,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct DiagnoseInvocation {
format: DiagnoseFormat,
socket_name: Option<OsString>,
socket_path: Option<PathBuf>,
config_files: Vec<PathBuf>,
terminal_features: Vec<String>,
assume_256_colors: bool,
utf8: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DiagnoseReport {
version: String,
os_name: String,
os_arch: String,
os_version: String,
terminal_host: String,
term: String,
term_program: String,
shell: String,
config_mode: String,
config_paths: Vec<String>,
socket_path: String,
conpty: String,
terminal_features: Vec<String>,
osc52: String,
}
pub(super) fn parse_invocation(
arguments: &[OsString],
) -> Result<Option<DiagnoseInvocation>, ExitFailure> {
let Some((command_index, prefix)) = split_top_level_prefix(arguments) else {
return Ok(None);
};
let Some(command) = arguments
.get(command_index)
.and_then(|value| value.to_str())
else {
return Ok(None);
};
if command != "diagnose" {
return Ok(None);
}
let format = parse_diagnose_format(&arguments[command_index + 1..])?;
Ok(Some(DiagnoseInvocation {
format,
socket_name: prefix.socket_name,
socket_path: prefix.socket_path,
config_files: prefix.config_files,
terminal_features: prefix.terminal_features,
assume_256_colors: prefix.assume_256_colors,
utf8: prefix.utf8,
}))
}
pub(super) fn run(invocation: DiagnoseInvocation) -> Result<i32, ExitFailure> {
let report = DiagnoseReport::collect(&invocation)?;
let output = match invocation.format {
DiagnoseFormat::Human => report.render_human(),
DiagnoseFormat::Json => report.render_json(),
};
write_stdout(&output)
}
#[derive(Default)]
struct TopLevelPrefix {
socket_name: Option<OsString>,
socket_path: Option<PathBuf>,
config_files: Vec<PathBuf>,
terminal_features: Vec<String>,
assume_256_colors: bool,
utf8: bool,
}
fn split_top_level_prefix(arguments: &[OsString]) -> Option<(usize, TopLevelPrefix)> {
let mut prefix = TopLevelPrefix::default();
let mut index = 0;
while let Some(argument) = arguments.get(index) {
let value = argument.to_str()?;
if value == "--" {
return Some((index + 1, prefix));
}
if !value.starts_with('-') || value == "-" {
return Some((index, prefix));
}
match value {
"-2" => prefix.assume_256_colors = true,
"-u" => prefix.utf8 = true,
"-D" | "-N" | "-l" => {}
"-C" | "-v" => {}
"-L" => {
index += 1;
prefix.socket_name = arguments.get(index).cloned();
}
"-S" => {
index += 1;
prefix.socket_path = arguments.get(index).map(PathBuf::from);
}
"-f" => {
index += 1;
if let Some(path) = arguments.get(index) {
prefix.config_files.push(PathBuf::from(path));
}
}
"-T" => {
index += 1;
if let Some(features) = arguments.get(index).and_then(|value| value.to_str()) {
push_terminal_features(&mut prefix.terminal_features, features);
}
}
_ if value.starts_with("-L") && value.len() > 2 => {
prefix.socket_name = Some(OsString::from(&value[2..]));
}
_ if value.starts_with("-S") && value.len() > 2 => {
prefix.socket_path = Some(PathBuf::from(&value[2..]));
}
_ if value.starts_with("-f") && value.len() > 2 => {
prefix.config_files.push(PathBuf::from(&value[2..]));
}
_ if value.starts_with("-T") && value.len() > 2 => {
push_terminal_features(&mut prefix.terminal_features, &value[2..]);
}
_ if is_short_flag_cluster(value, "2CDNluv") => {
prefix.assume_256_colors |= value.contains('2');
prefix.utf8 |= value.contains('u');
}
_ => return Some((index, prefix)),
}
index += 1;
}
None
}
fn is_short_flag_cluster(value: &str, allowed: &str) -> bool {
value.len() > 2
&& value.starts_with('-')
&& !value.starts_with("--")
&& value.chars().skip(1).all(|flag| allowed.contains(flag))
}
fn parse_diagnose_format(arguments: &[OsString]) -> Result<DiagnoseFormat, ExitFailure> {
let mut format = None;
for argument in arguments {
match argument.to_str() {
Some("--human") => set_format(&mut format, DiagnoseFormat::Human)?,
Some("--json") => set_format(&mut format, DiagnoseFormat::Json)?,
Some("--help") => {
return Err(ExitFailure::new_stdout(
0,
"usage: rmux diagnose [--human|--json]",
));
}
Some(other) => {
return Err(ExitFailure::new(
1,
format!("rmux diagnose: unknown argument '{other}'"),
));
}
None => {
return Err(ExitFailure::new(
1,
"rmux diagnose: arguments must be valid UTF-8",
));
}
}
}
Ok(format.unwrap_or(DiagnoseFormat::Human))
}
fn set_format(
current: &mut Option<DiagnoseFormat>,
next: DiagnoseFormat,
) -> Result<(), ExitFailure> {
if current.is_some_and(|current| current != next) {
return Err(ExitFailure::new(
1,
"rmux diagnose: choose only one of --human or --json",
));
}
*current = Some(next);
Ok(())
}
impl DiagnoseReport {
fn collect(invocation: &DiagnoseInvocation) -> Result<Self, ExitFailure> {
let socket_path = resolve_socket_path(
invocation.socket_name.as_deref(),
invocation.socket_path.as_deref(),
)
.map_err(ExitFailure::from_client)?;
let mut terminal_features = invocation.terminal_features.clone();
if invocation.assume_256_colors {
push_unique(&mut terminal_features, "256".to_owned());
}
let term = env_value("TERM");
let term_program = env_value("TERM_PROGRAM");
let terminal_host = detect_terminal_host(&term, &term_program);
let config_paths = if invocation.config_files.is_empty() {
default_config_paths()
} else {
invocation.config_files.clone()
};
let config_paths = config_paths
.iter()
.map(|path| redact_path(path))
.collect::<Vec<_>>();
let osc52 = if terminal_looks_clipboard_capable(&term, &term_program, &terminal_features) {
"available-when-requested"
} else {
"not-advertised"
};
Ok(Self {
version: env!("CARGO_PKG_VERSION").to_owned(),
os_name: std::env::consts::OS.to_owned(),
os_arch: std::env::consts::ARCH.to_owned(),
os_version: os_version(),
terminal_host,
term,
term_program,
shell: detected_shell(),
config_mode: if invocation.config_files.is_empty() {
"default".to_owned()
} else {
"custom".to_owned()
},
config_paths,
socket_path: redact_path(&socket_path),
conpty: conpty_status().to_owned(),
terminal_features,
osc52: osc52.to_owned(),
})
}
fn render_human(&self) -> String {
let mut output = String::new();
output.push_str("rmux diagnose\n");
output.push_str(&format!("version: {}\n", self.version));
output.push_str(&format!("os: {} ({})\n", self.os_name, self.os_arch));
output.push_str(&format!("os_version: {}\n", self.os_version));
output.push_str(&format!("terminal_host: {}\n", self.terminal_host));
output.push_str(&format!("term: {}\n", self.term));
output.push_str(&format!("term_program: {}\n", self.term_program));
output.push_str(&format!("shell: {}\n", self.shell));
output.push_str(&format!("socket_path: {}\n", self.socket_path));
output.push_str(&format!("config_mode: {}\n", self.config_mode));
output.push_str("config_paths:\n");
for path in &self.config_paths {
output.push_str(&format!(" - {path}\n"));
}
output.push_str("capabilities:\n");
output.push_str(&format!(" conpty: {}\n", self.conpty));
output.push_str(&format!(" osc52: {}\n", self.osc52));
output.push_str(&format!(
" terminal_features: {}\n",
render_feature_list(&self.terminal_features)
));
output.push_str("privacy: environment values are summarized or redacted\n");
output
}
fn render_json(&self) -> String {
format!(
concat!(
"{{\n",
" \"version\": {},\n",
" \"os\": {{\"name\": {}, \"arch\": {}, \"version\": {}}},\n",
" \"terminal\": {{\"host\": {}, \"term\": {}, \"term_program\": {}}},\n",
" \"shell\": {},\n",
" \"socket_path\": {},\n",
" \"config\": {{\"mode\": {}, \"paths\": {}}},\n",
" \"capabilities\": {{\"conpty\": {}, \"osc52\": {}, \"terminal_features\": {}}},\n",
" \"privacy\": {{\"environment_values\": \"summarized-or-redacted\"}}\n",
"}}\n"
),
json_string(&self.version),
json_string(&self.os_name),
json_string(&self.os_arch),
json_string(&self.os_version),
json_string(&self.terminal_host),
json_string(&self.term),
json_string(&self.term_program),
json_string(&self.shell),
json_string(&self.socket_path),
json_string(&self.config_mode),
json_array(&self.config_paths),
json_string(&self.conpty),
json_string(&self.osc52),
json_array(&self.terminal_features),
)
}
}
fn push_terminal_features(features: &mut Vec<String>, raw: &str) {
for feature in raw
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
{
push_unique(features, feature.to_owned());
}
}
fn push_unique(values: &mut Vec<String>, value: String) {
if !values.contains(&value) {
values.push(value);
}
}
fn env_value(name: &str) -> String {
std::env::var(name)
.ok()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "unset".to_owned())
}
fn detect_terminal_host(term: &str, term_program: &str) -> String {
if std::env::var_os("WT_SESSION").is_some() {
return "windows-terminal".to_owned();
}
if term_program != "unset" {
return term_program.to_owned();
}
if term != "unset" {
return term.to_owned();
}
"unknown".to_owned()
}
fn detected_shell() -> String {
#[cfg(windows)]
{
diagnose_windows::detected_pane_shell()
}
#[cfg(not(windows))]
{
env_value("SHELL")
}
}
fn os_version() -> String {
#[cfg(windows)]
{
command_output("cmd", &["/C", "ver"])
}
#[cfg(not(windows))]
{
command_output("uname", &["-sr"])
}
}
fn command_output(program: &str, args: &[&str]) -> String {
ProcessCommand::new(program)
.args(args)
.output()
.ok()
.and_then(|output| {
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
})
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "unknown".to_owned())
}
fn conpty_status() -> &'static str {
#[cfg(windows)]
{
"available"
}
#[cfg(not(windows))]
{
"not-applicable"
}
}
fn terminal_looks_clipboard_capable(
term: &str,
term_program: &str,
terminal_features: &[String],
) -> bool {
terminal_features
.iter()
.any(|feature| feature.eq_ignore_ascii_case("clipboard"))
|| term.starts_with("xterm")
|| term.starts_with("tmux")
|| term.contains("mintty")
|| term.starts_with("foot")
|| term.starts_with("iterm")
|| term_program.eq_ignore_ascii_case("iTerm.app")
|| term_program.eq_ignore_ascii_case("mintty")
}
fn default_config_paths() -> Vec<PathBuf> {
#[cfg(windows)]
{
windows_default_config_paths()
}
#[cfg(not(windows))]
{
unix_default_config_paths()
}
}
#[cfg(not(windows))]
fn unix_default_config_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
push_unique_path(&mut paths, PathBuf::from("/etc/rmux.conf"));
if let Some(home) = nonempty_env_os("HOME") {
let home = PathBuf::from(home);
push_unique_path(&mut paths, home.join(".rmux.conf"));
}
if let Some(xdg_config_home) = nonempty_env_os("XDG_CONFIG_HOME") {
push_unique_path(
&mut paths,
PathBuf::from(xdg_config_home)
.join("rmux")
.join("rmux.conf"),
);
}
if let Some(home) = nonempty_env_os("HOME") {
let home = PathBuf::from(home);
push_unique_path(
&mut paths,
home.join(".config").join("rmux").join("rmux.conf"),
);
}
paths
}
#[cfg(windows)]
fn windows_default_config_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(xdg_config_home) = nonempty_env_os("XDG_CONFIG_HOME") {
push_unique_path(
&mut paths,
PathBuf::from(xdg_config_home)
.join("rmux")
.join("rmux.conf"),
);
}
if let Some(userprofile) = nonempty_env_os("USERPROFILE") {
let userprofile = PathBuf::from(userprofile);
push_unique_path(&mut paths, userprofile.join(".rmux.conf"));
}
if let Some(appdata) = nonempty_env_os("APPDATA") {
push_unique_path(
&mut paths,
PathBuf::from(appdata).join("rmux").join("rmux.conf"),
);
}
if let Some(config_file) = nonempty_env_os("RMUX_CONFIG_FILE") {
push_unique_path(&mut paths, PathBuf::from(config_file));
}
paths
}
fn nonempty_env_os(name: &str) -> Option<OsString> {
std::env::var_os(name).filter(|value| !value.is_empty())
}
fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
if !paths.contains(&path) {
paths.push(path);
}
}
fn redact_path(path: &Path) -> String {
for name in ["HOME", "USERPROFILE"] {
let Some(home) = nonempty_env_os(name).map(PathBuf::from) else {
continue;
};
if let Ok(rest) = path.strip_prefix(&home) {
if rest.as_os_str().is_empty() {
return "~".to_owned();
}
return format!("~/{}", rest.display());
}
}
path.display().to_string()
}
fn render_feature_list(features: &[String]) -> String {
if features.is_empty() {
"none".to_owned()
} else {
features.join(",")
}
}
fn json_array(values: &[String]) -> String {
let mut output = String::from("[");
for (index, value) in values.iter().enumerate() {
if index != 0 {
output.push_str(", ");
}
output.push_str(&json_string(value));
}
output.push(']');
output
}
fn json_string(value: &str) -> String {
let mut output = String::from("\"");
for ch in value.chars() {
match ch {
'"' => output.push_str("\\\""),
'\\' => output.push_str("\\\\"),
'\n' => output.push_str("\\n"),
'\r' => output.push_str("\\r"),
'\t' => output.push_str("\\t"),
ch if ch.is_control() => {
output.push_str(&format!("\\u{:04x}", ch as u32));
}
ch => output.push(ch),
}
}
output.push('"');
output
}
fn write_stdout(output: &str) -> Result<i32, ExitFailure> {
match io::stdout().lock().write_all(output.as_bytes()) {
Ok(()) => Ok(0),
Err(error) if error.kind() == ErrorKind::BrokenPipe => Ok(0),
Err(error) => Err(ExitFailure::new(1, error.to_string())),
}
}
#[cfg(test)]
#[path = "diagnose_tests.rs"]
mod tests;