rmux 0.8.0

A local terminal multiplexer with a tmux-style CLI, daemon runtime, Rust SDK, and ratatui integration.
use std::path::Path;

use rmux_proto::{PaneBroadcastInputRequest, Response};
use serde_json::{json, Value};

use crate::cli_args::{
    BroadcastKeysArgs, ExpectPaneArgs, FindPanesArgs, FindSessionsArgs, LocatorArgs,
};
use crate::cli_response::tmux_cli_error_message;

use super::super::{list_session_names, resolve_pane_target_spec, ExitFailure};
use super::common::{
    connect_cli, find_visible_text, matches_json, pane_snapshot, resolve_pane_ref,
    stable_pane_ref_for_slot, write_json, write_stdout_bytes, write_stdout_line, SCHEMA_VERSION,
};

const FIND_PANES_FIELD_SEPARATOR: char = '\u{1f}';
const FIND_PANES_RECORD_SEPARATOR: char = '\u{1e}';
const FIND_PANES_FORMAT: &str = "#{session_name}\u{1f}#{window_index}\u{1f}#{pane_index}\u{1f}#{pane_id}\u{1f}#{pane_title}\u{1f}#{pane_current_command}\u{1f}#{pane_current_path}\u{1e}";

pub(crate) fn run_locator(args: LocatorArgs, socket_path: &Path) -> Result<i32, ExitFailure> {
    let mut connection = connect_cli(socket_path)?;
    let target = resolve_pane_ref(&mut connection, args.target.as_ref(), "locator")?;
    let snapshot = pane_snapshot(&mut connection, target)?;
    let matches = find_visible_text(&snapshot, &args.get_by_text);
    if args.json {
        return write_json(&json!({
            "schema_version": SCHEMA_VERSION,
            "ok": true,
            "locator": "get-by-text",
            "matches": matches_json(&matches),
            "count": matches.len(),
        }));
    }
    let lines = matches
        .iter()
        .map(|found| format!("{}:{}:{}", found.row, found.col, found.text))
        .collect::<Vec<_>>()
        .join("\n");
    if lines.is_empty() {
        return write_stdout_bytes(b"");
    }
    write_stdout_line(&lines)
}

pub(crate) fn run_expect_pane(
    args: ExpectPaneArgs,
    socket_path: &Path,
) -> Result<i32, ExitFailure> {
    let mut connection = connect_cli(socket_path)?;
    let target = resolve_pane_ref(&mut connection, args.target.as_ref(), "expect-pane")?;
    let snapshot = pane_snapshot(&mut connection, target)?;
    let matches = find_visible_text(&snapshot, &args.get_by_text);
    let ok = if args.visible {
        !matches.is_empty()
    } else if args.hidden {
        matches.is_empty()
    } else {
        matches.len() == args.count.unwrap_or_default()
    };
    let assertion = if args.visible {
        "visible"
    } else if args.hidden {
        "hidden"
    } else {
        "count"
    };
    if args.json {
        let exit_code = if ok { 0 } else { 1 };
        write_json(&json!({
            "schema_version": SCHEMA_VERSION,
            "ok": ok,
            "assertion": assertion,
            "locator": "get-by-text",
            "expected_count": args.count,
            "count": matches.len(),
            "matches": matches_json(&matches),
        }))?;
        return Ok(exit_code);
    }
    if ok {
        return Ok(0);
    }
    Err(ExitFailure::new(
        1,
        format!(
            "expect-pane failed: get-by-text {:?} assertion {assertion} saw {} matches",
            args.get_by_text,
            matches.len()
        ),
    ))
}

pub(crate) fn run_find_panes(args: FindPanesArgs, socket_path: &Path) -> Result<i32, ExitFailure> {
    let mut connection = connect_cli(socket_path)?;
    let sessions = list_session_names(&mut connection)?;
    let mut panes = Vec::new();
    for session_name in sessions {
        let response = connection
            .list_panes_in_window(session_name, None, Some(FIND_PANES_FORMAT.to_owned()))
            .map_err(ExitFailure::from_client)?;
        let Response::ListPanes(response) = response else {
            continue;
        };
        let text = String::from_utf8_lossy(response.output.stdout());
        panes.extend(
            text.split(FIND_PANES_RECORD_SEPARATOR)
                .filter_map(parse_pane_row),
        );
    }
    panes.retain(|pane| pane_matches(pane, &args));
    if args.json {
        return write_json(&json!({
            "schema_version": SCHEMA_VERSION,
            "ok": true,
            "panes": Value::Array(
                panes
                    .iter()
                    .map(|pane| {
                        json!({
                            "session_name": pane.session_name,
                            "window_index": pane.window_index,
                            "pane_index": pane.pane_index,
                            "pane_id": pane.pane_id,
                            "title": pane.title,
                            "current_command": pane.current_command,
                            "cwd": pane.cwd,
                        })
                    })
                    .collect(),
            ),
        }));
    }
    let lines = panes
        .iter()
        .map(|pane| {
            format!(
                "{}\t{}:{}.{}\t{}\t{}\t{}",
                pane.pane_id,
                pane.session_name,
                pane.window_index,
                pane.pane_index,
                pane.title,
                pane.current_command,
                pane.cwd
            )
        })
        .collect::<Vec<_>>()
        .join("\n");
    if lines.is_empty() {
        return write_stdout_bytes(b"");
    }
    write_stdout_line(&lines)
}

pub(crate) fn run_find_sessions(
    args: FindSessionsArgs,
    socket_path: &Path,
) -> Result<i32, ExitFailure> {
    let mut connection = connect_cli(socket_path)?;
    let mut sessions = list_session_names(&mut connection)?
        .into_iter()
        .map(|name| name.to_string())
        .collect::<Vec<_>>();
    sessions.retain(|name| {
        args.name.as_deref().is_none_or(|expected| name == expected)
            && args
                .name_prefix
                .as_deref()
                .is_none_or(|prefix| name.starts_with(prefix))
    });
    if args.json {
        return write_json(&json!({
            "schema_version": SCHEMA_VERSION,
            "ok": true,
            "sessions": Value::Array(
                sessions
                    .iter()
                    .map(|name| json!({ "session_name": name }))
                    .collect(),
            ),
        }));
    }
    let lines = sessions.join("\n");
    if lines.is_empty() {
        return write_stdout_bytes(b"");
    }
    write_stdout_line(&lines)
}

pub(crate) fn run_broadcast_keys(
    args: BroadcastKeysArgs,
    socket_path: &Path,
) -> Result<i32, ExitFailure> {
    let mut connection = connect_cli(socket_path)?;
    let mut targets = Vec::with_capacity(args.targets.len());
    for target in &args.targets {
        let slot = resolve_pane_target_spec(&mut connection, target)?;
        targets.push(stable_pane_ref_for_slot(
            &mut connection,
            &slot,
            "broadcast-keys",
        )?);
    }
    match connection
        .pane_broadcast_input(PaneBroadcastInputRequest {
            targets,
            keys: args.keys,
            literal: args.literal,
        })
        .map_err(ExitFailure::from_client)?
    {
        Response::PaneBroadcastInput(response) if response.failures.is_empty() => Ok(0),
        Response::PaneBroadcastInput(response) => Err(ExitFailure::new(
            1,
            format!(
                "broadcast-keys failed for {} of {} targets",
                response.failures.len(),
                response.successes.len() + response.failures.len()
            ),
        )),
        Response::Error(error) => Err(ExitFailure::new(
            1,
            tmux_cli_error_message("broadcast-keys", &error.error),
        )),
        other => Err(ExitFailure::new(
            1,
            format!(
                "protocol error: unexpected '{}' response for broadcast-keys",
                other.command_name()
            ),
        )),
    }
}

struct PaneRow {
    session_name: String,
    window_index: String,
    pane_index: String,
    pane_id: String,
    title: String,
    current_command: String,
    cwd: String,
}

fn parse_pane_row(line: &str) -> Option<PaneRow> {
    let line = line.trim_matches(['\r', '\n']);
    if line.is_empty() {
        return None;
    }
    let fields = line.split(FIND_PANES_FIELD_SEPARATOR).collect::<Vec<_>>();
    let [session_name, window_index, pane_index, pane_id, title, current_command, cwd] =
        fields.as_slice()
    else {
        return None;
    };
    Some(PaneRow {
        session_name: (*session_name).to_owned(),
        window_index: (*window_index).to_owned(),
        pane_index: (*pane_index).to_owned(),
        pane_id: (*pane_id).to_owned(),
        title: (*title).to_owned(),
        current_command: (*current_command).to_owned(),
        cwd: (*cwd).to_owned(),
    })
}

fn pane_matches(pane: &PaneRow, args: &FindPanesArgs) -> bool {
    args.title
        .as_deref()
        .is_none_or(|title| pane.title == title)
        && args
            .title_prefix
            .as_deref()
            .is_none_or(|prefix| pane.title.starts_with(prefix))
        && args
            .current_command
            .as_deref()
            .is_none_or(|command| pane.current_command == command)
        && args.cwd.as_deref().is_none_or(|cwd| pane.cwd == cwd)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_pane_row_ignores_record_newlines() {
        let line = "\nalpha\u{1f}0\u{1f}2\u{1f}%1\u{1f}title\u{1f}bash\u{1f}/tmp/project\n";

        let row = parse_pane_row(line).expect("row parses");

        assert_eq!(row.session_name, "alpha");
        assert_eq!(row.window_index, "0");
        assert_eq!(row.pane_index, "2");
        assert_eq!(row.pane_id, "%1");
        assert_eq!(row.title, "title");
        assert_eq!(row.current_command, "bash");
        assert_eq!(row.cwd, "/tmp/project");
    }
}