rmux 0.7.1

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

use rmux_proto::{PaneSnapshotCell, PaneSnapshotResponse};
use serde_json::{json, Value};

use crate::cli_args::{PaneSnapshotArgs, SnapshotRegion};

use super::super::ExitFailure;
use super::common::{
    check_disabled, connect_cli, pane_snapshot, resolve_pane_ref, visible_line_from_cells,
    write_json, write_stdout_line, SCHEMA_VERSION,
};

pub(crate) fn run_pane_snapshot(
    args: PaneSnapshotArgs,
    socket_path: &Path,
) -> Result<i32, ExitFailure> {
    check_disabled("RMUX_DISABLE_PANE_SNAPSHOT", "pane-snapshot")?;
    let mut connection = connect_cli(socket_path)?;
    let target = resolve_pane_ref(&mut connection, args.target.as_ref(), "pane-snapshot")?;
    let snapshot = pane_snapshot(&mut connection, target)?;
    let view = SnapshotView::new(&snapshot, args.region)?;
    if args.json {
        return write_json(&snapshot_json(&snapshot, &view, args.style));
    }
    write_stdout_line(&view.lines.join("\n"))
}

struct SnapshotView {
    row: u16,
    col: u16,
    rows: u16,
    cols: u16,
    lines: Vec<String>,
    cells: Vec<Vec<PaneSnapshotCell>>,
}

impl SnapshotView {
    fn new(
        snapshot: &PaneSnapshotResponse,
        region: Option<SnapshotRegion>,
    ) -> Result<Self, ExitFailure> {
        let region = region.unwrap_or(SnapshotRegion {
            row: 0,
            col: 0,
            rows: snapshot.rows,
            cols: snapshot.cols,
        });
        validate_region(snapshot, region)?;
        let mut lines = Vec::with_capacity(usize::from(region.rows));
        let mut cells = Vec::with_capacity(usize::from(region.rows));
        let snapshot_cols = usize::from(snapshot.cols);
        for row_offset in 0..usize::from(region.rows) {
            let row = usize::from(region.row) + row_offset;
            let col = usize::from(region.col);
            let end_col = col + usize::from(region.cols);
            let start = row * snapshot_cols + col;
            let end = row * snapshot_cols + end_col;
            let row_cells = snapshot.cells[start..end].to_vec();
            lines.push(visible_line_from_cells(&row_cells));
            cells.push(row_cells);
        }
        Ok(Self {
            row: region.row,
            col: region.col,
            rows: region.rows,
            cols: region.cols,
            lines,
            cells,
        })
    }
}

fn validate_region(
    snapshot: &PaneSnapshotResponse,
    region: SnapshotRegion,
) -> Result<(), ExitFailure> {
    let expected_cells = usize::from(snapshot.rows).saturating_mul(usize::from(snapshot.cols));
    if snapshot.cells.len() < expected_cells {
        return Err(ExitFailure::new(
            1,
            "pane-snapshot response has an incomplete cell grid",
        ));
    }
    let row_end = u32::from(region.row) + u32::from(region.rows);
    let col_end = u32::from(region.col) + u32::from(region.cols);
    if row_end > u32::from(snapshot.rows) || col_end > u32::from(snapshot.cols) {
        return Err(ExitFailure::new(
            1,
            "pane-snapshot --region exceeds pane bounds",
        ));
    }
    Ok(())
}

fn snapshot_json(
    snapshot: &PaneSnapshotResponse,
    view: &SnapshotView,
    include_style: bool,
) -> Value {
    let mut payload = json!({
        "schema_version": SCHEMA_VERSION,
        "ok": true,
        "rows": snapshot.rows,
        "cols": snapshot.cols,
        "revision": snapshot.revision,
        "cursor": {
            "row": snapshot.cursor.row,
            "col": snapshot.cursor.col,
            "visible": snapshot.cursor.visible,
            "style": snapshot.cursor.style,
        },
        "region": {
            "row": view.row,
            "col": view.col,
            "rows": view.rows,
            "cols": view.cols,
        },
        "text": view.lines.join("\n"),
        "lines": view.lines,
    });
    if include_style {
        payload["cells"] = Value::Array(
            view.cells
                .iter()
                .map(|row| {
                    Value::Array(
                        row.iter()
                            .map(|cell| {
                                json!({
                                    "text": cell.text,
                                    "width": cell.width,
                                    "padding": cell.padding,
                                    "attributes": cell.attributes,
                                    "fg": cell.fg,
                                    "bg": cell.bg,
                                    "us": cell.us,
                                    "link": cell.link,
                                })
                            })
                            .collect(),
                    )
                })
                .collect(),
        );
    }
    payload
}

#[cfg(test)]
mod tests {
    use rmux_proto::{PaneSnapshotCell, PaneSnapshotCursor, PaneSnapshotResponse};

    use crate::cli_args::SnapshotRegion;

    use super::SnapshotView;

    fn cell(text: &str, width: u8, padding: bool) -> PaneSnapshotCell {
        PaneSnapshotCell {
            text: text.to_owned(),
            width,
            padding,
            attributes: 0,
            fg: 0,
            bg: 0,
            us: 0,
            link: 0,
        }
    }

    fn snapshot() -> PaneSnapshotResponse {
        PaneSnapshotResponse {
            cols: 4,
            rows: 1,
            cells: vec![
                cell("A", 1, false),
                cell("", 2, false),
                cell(" ", 0, true),
                cell("B", 1, false),
            ],
            cursor: PaneSnapshotCursor {
                row: 0,
                col: 0,
                visible: false,
                style: 0,
            },
            revision: 1,
        }
    }

    #[test]
    fn region_text_uses_terminal_cell_columns_not_character_offsets() {
        let snapshot = snapshot();
        let view = SnapshotView::new(
            &snapshot,
            Some(SnapshotRegion {
                row: 0,
                col: 1,
                rows: 1,
                cols: 3,
            }),
        )
        .expect("region is valid");

        assert_eq!(view.lines, vec!["界B"]);
    }

    #[test]
    fn region_starting_on_wide_padding_does_not_leak_owner_glyph() {
        let snapshot = snapshot();
        let view = SnapshotView::new(
            &snapshot,
            Some(SnapshotRegion {
                row: 0,
                col: 2,
                rows: 1,
                cols: 2,
            }),
        )
        .expect("region is valid");

        assert_eq!(view.lines, vec!["B"]);
    }

    #[test]
    fn incomplete_snapshot_grid_is_rejected_before_slicing() {
        let mut snapshot = snapshot();
        snapshot.cells.pop();

        let error = match SnapshotView::new(&snapshot, None) {
            Ok(_) => panic!("grid is incomplete"),
            Err(error) => error,
        };

        assert!(
            error
                .message()
                .contains("pane-snapshot response has an incomplete cell grid"),
            "{}",
            error.message()
        );
    }
}