rmux 0.7.1

A local terminal multiplexer with a tmux-style CLI, daemon runtime, Rust SDK, and ratatui integration.
use std::io::{self, ErrorKind, Write};

use rmux_core::formats::is_truthy;
use rmux_proto::{CommandOutput, ListClientsResponse, ListWindowsResponse};
use serde_json::{json, Map, Value};

use super::ExitFailure;

const FIELD_SEPARATOR: char = '\x1f';
const FIELD_SEPARATOR_STR: &str = "\x1f";
const ROW_SEPARATOR: char = '\x1e';
const ROW_SEPARATOR_STR: &str = "\x1e";

const LIST_CLIENTS_FIELDS: &[JsonField] = &[
    JsonField::string("client_name"),
    JsonField::string("client_session"),
    JsonField::string("client_tty"),
    JsonField::number("client_width"),
    JsonField::number("client_height"),
    JsonField::number("client_pid"),
    JsonField::bool("client_readonly"),
    JsonField::bool("client_control_mode"),
    JsonField::bool("client_utf8"),
];

const LIST_PANES_FIELDS: &[JsonField] = &[
    JsonField::string("session_name"),
    JsonField::number("window_index"),
    JsonField::number("pane_index"),
    JsonField::string("pane_id"),
    JsonField::number("pane_width"),
    JsonField::number("pane_height"),
    JsonField::bool("pane_active"),
    JsonField::bool("pane_dead"),
    JsonField::string("pane_current_command"),
    JsonField::string("pane_current_path"),
];

const LIST_SESSIONS_FIELDS: &[JsonField] = &[
    JsonField::string("session_id"),
    JsonField::string("session_name"),
    JsonField::number("session_windows"),
    JsonField::bool("session_attached"),
    JsonField::bool("session_grouped"),
    JsonField::string("session_group"),
    JsonField::number("session_created"),
];

pub(super) fn list_clients_json_format() -> String {
    json_format(LIST_CLIENTS_FIELDS)
}

pub(super) fn list_panes_json_format() -> String {
    json_format(LIST_PANES_FIELDS)
}

pub(super) fn list_sessions_json_format() -> String {
    json_format(LIST_SESSIONS_FIELDS)
}

fn json_format(fields: &[JsonField]) -> String {
    let mut format = fields
        .iter()
        .map(|field| format!("#{{{}}}", field.name))
        .collect::<Vec<_>>()
        .join(FIELD_SEPARATOR_STR);
    format.push_str(ROW_SEPARATOR_STR);
    format
}

#[derive(Clone, Copy)]
struct JsonField {
    name: &'static str,
    kind: JsonFieldKind,
}

impl JsonField {
    const fn bool(name: &'static str) -> Self {
        Self {
            name,
            kind: JsonFieldKind::Bool,
        }
    }

    const fn number(name: &'static str) -> Self {
        Self {
            name,
            kind: JsonFieldKind::Number,
        }
    }

    const fn string(name: &'static str) -> Self {
        Self {
            name,
            kind: JsonFieldKind::String,
        }
    }
}

#[derive(Clone, Copy)]
enum JsonFieldKind {
    Bool,
    Number,
    String,
}

pub(super) fn write_list_clients_json(response: &ListClientsResponse) -> Result<i32, ExitFailure> {
    write_delimited_output_as_json(
        response.command_output(),
        LIST_CLIENTS_FIELDS,
        "list-clients",
    )
}

pub(super) fn write_list_panes_json(output: &CommandOutput) -> Result<i32, ExitFailure> {
    write_delimited_output_as_json(output, LIST_PANES_FIELDS, "list-panes")
}

pub(super) fn write_list_sessions_json(output: &CommandOutput) -> Result<i32, ExitFailure> {
    write_delimited_output_as_json(output, LIST_SESSIONS_FIELDS, "list-sessions")
}

pub(super) fn filter_delimited_json_output(
    output: &CommandOutput,
    command_name: &'static str,
) -> Result<CommandOutput, ExitFailure> {
    let stdout = stdout_string(output.stdout(), command_name)?;
    let mut filtered = String::new();
    for record in split_delimited_records(&stdout) {
        let Some((filter_value, rendered_record)) = record.split_once(FIELD_SEPARATOR) else {
            return Err(ExitFailure::new(
                1,
                format!("{command_name} filter output missing separator"),
            ));
        };
        if is_truthy(filter_value) {
            filtered.push_str(rendered_record);
            filtered.push(ROW_SEPARATOR);
            filtered.push('\n');
        }
    }
    Ok(CommandOutput::from_stdout(filtered.into_bytes()))
}

pub(super) fn write_list_windows_json(response: &ListWindowsResponse) -> Result<i32, ExitFailure> {
    let rows = response
        .windows
        .iter()
        .map(|window| {
            json!({
                "session_name": window.target.session_name().to_string(),
                "window_index": window.target.window_index(),
                "window_id": window.window_id,
                "window_name": window.name.as_deref().unwrap_or(""),
                "window_panes": window.pane_count,
                "window_width": window.size.cols,
                "window_height": window.size.rows,
                "window_layout": window.layout.to_string(),
                "window_active": window.active,
                "window_last_flag": window.last,
            })
        })
        .collect::<Vec<_>>();

    write_json_value(&Value::Array(rows), "list-windows")
}

fn write_delimited_output_as_json(
    output: &CommandOutput,
    fields: &[JsonField],
    command_name: &'static str,
) -> Result<i32, ExitFailure> {
    let stdout = stdout_string(output.stdout(), command_name)?;
    let rows = parse_delimited_rows(&stdout, fields, command_name)?;
    write_json_value(&Value::Array(rows), command_name)
}

fn parse_delimited_rows(
    stdout: &str,
    fields: &[JsonField],
    command_name: &'static str,
) -> Result<Vec<Value>, ExitFailure> {
    if !stdout.contains(ROW_SEPARATOR) {
        return stdout
            .lines()
            .filter(|line| !line.is_empty())
            .map(|line| parse_delimited_row(line, fields, command_name))
            .collect();
    }

    split_delimited_records(stdout)
        .map(|record| parse_delimited_row(record, fields, command_name))
        .collect()
}

fn split_delimited_records(stdout: &str) -> impl Iterator<Item = &str> {
    stdout
        .split(ROW_SEPARATOR)
        .enumerate()
        .filter_map(|(index, record)| {
            let record = if index == 0 {
                record
            } else {
                record.strip_prefix('\n').unwrap_or(record)
            };
            (!record.is_empty()).then_some(record)
        })
}

fn parse_delimited_row(
    line: &str,
    fields: &[JsonField],
    command_name: &'static str,
) -> Result<Value, ExitFailure> {
    let parts = line.split(FIELD_SEPARATOR).collect::<Vec<_>>();
    if parts.len() != fields.len() {
        return Err(ExitFailure::new(
            1,
            format!(
                "{command_name} --json expected {} fields, got {}",
                fields.len(),
                parts.len()
            ),
        ));
    }

    let mut object = Map::with_capacity(fields.len());
    for (field, value) in fields.iter().zip(parts) {
        object.insert(
            field.name.to_owned(),
            field_value(field, value, command_name)?,
        );
    }
    Ok(Value::Object(object))
}

fn field_value(
    field: &JsonField,
    value: &str,
    command_name: &'static str,
) -> Result<Value, ExitFailure> {
    if value.contains(FIELD_SEPARATOR_STR) || value.contains(ROW_SEPARATOR_STR) {
        return Err(ExitFailure::new(
            1,
            format!(
                "{command_name} --json field '{}' contains an internal separator",
                field.name
            ),
        ));
    }

    match field.kind {
        JsonFieldKind::String => Ok(Value::String(value.to_owned())),
        JsonFieldKind::Bool => parse_bool(value, field.name, command_name).map(Value::Bool),
        JsonFieldKind::Number => parse_number(value, field.name, command_name),
    }
}

fn parse_bool(
    value: &str,
    field_name: &'static str,
    command_name: &'static str,
) -> Result<bool, ExitFailure> {
    match value {
        "1" | "true" | "yes" => Ok(true),
        "0" | "false" | "no" | "" => Ok(false),
        other => Err(ExitFailure::new(
            1,
            format!("{command_name} --json field '{field_name}' is not boolean: {other}"),
        )),
    }
}

fn parse_number(
    value: &str,
    field_name: &'static str,
    command_name: &'static str,
) -> Result<Value, ExitFailure> {
    if value.is_empty() {
        return Ok(Value::Null);
    }
    let number = value.parse::<i64>().map_err(|error| {
        ExitFailure::new(
            1,
            format!("{command_name} --json field '{field_name}' is not numeric: {error}"),
        )
    })?;
    Ok(Value::Number(number.into()))
}

pub(super) fn write_json_object(
    value: &serde_json::Value,
    command_name: &'static str,
) -> Result<i32, ExitFailure> {
    write_json_value(value, command_name)
}

pub(super) fn stdout_string(
    bytes: &[u8],
    command_name: &'static str,
) -> Result<String, ExitFailure> {
    String::from_utf8(bytes.to_vec()).map_err(|error| {
        ExitFailure::new(
            1,
            format!("{command_name} --json received non-UTF-8 output: {error}"),
        )
    })
}

fn write_json_value(value: &Value, command_name: &'static str) -> Result<i32, ExitFailure> {
    let mut stdout = io::stdout().lock();
    match serde_json::to_writer(&mut stdout, value) {
        Ok(()) => Ok(0),
        Err(error) if error.is_io() && error.io_error_kind() == Some(ErrorKind::BrokenPipe) => {
            Ok(0)
        }
        Err(error) => Err(ExitFailure::new(
            1,
            format!("failed to write {command_name} JSON: {error}"),
        )),
    }?;
    match stdout.write_all(b"\n") {
        Ok(()) => Ok(0),
        Err(error) if error.kind() == ErrorKind::BrokenPipe => Ok(0),
        Err(error) => Err(ExitFailure::new(
            1,
            format!("failed to write {command_name} JSON: {error}"),
        )),
    }
}

#[cfg(test)]
mod tests {
    use super::{
        list_sessions_json_format, parse_delimited_rows, LIST_PANES_FIELDS, LIST_SESSIONS_FIELDS,
    };

    #[test]
    fn list_sessions_format_uses_internal_separator() {
        assert_eq!(
            list_sessions_json_format(),
            "#{session_id}\x1f#{session_name}\x1f#{session_windows}\x1f#{session_attached}\x1f#{session_grouped}\x1f#{session_group}\x1f#{session_created}\x1e"
        );
    }

    #[test]
    fn parses_typed_rows() {
        let rows = parse_delimited_rows(
            "$1\x1fdemo\x1f2\x1f1\x1f0\x1f\x1f123\x1e\n",
            LIST_SESSIONS_FIELDS,
            "list-sessions",
        )
        .expect("row parses");

        assert_eq!(rows[0]["session_name"], "demo");
        assert_eq!(rows[0]["session_windows"], 2);
        assert_eq!(rows[0]["session_attached"], true);
        assert_eq!(rows[0]["session_grouped"], false);
    }

    #[test]
    fn parses_string_fields_containing_newlines() {
        let rows = parse_delimited_rows(
            "$1\x1fdemo\nwith-newline\x1f2\x1f1\x1f0\x1f\x1f123\x1e\n",
            LIST_SESSIONS_FIELDS,
            "list-sessions",
        )
        .expect("row parses");

        assert_eq!(rows[0]["session_name"], "demo\nwith-newline");
    }

    #[test]
    fn rejects_malformed_rows() {
        let error = parse_delimited_rows("demo\x1f0\x1e\n", LIST_PANES_FIELDS, "list-panes")
            .expect_err("field count mismatch should fail");

        assert!(error.message().contains("expected 10 fields"));
    }
}