tmux-lib 0.5.0

Tmux helper functions.
Documentation
//! This module provides a few types and functions to handle Tmux Panes.
//!
//! The main use cases are running Tmux commands & parsing Tmux panes
//! information.

use std::path::PathBuf;
use std::str::FromStr;

use nom::{
    IResult, Parser,
    character::complete::{char, digit1, not_line_ending},
    combinator::{all_consuming, map_res},
};
use serde::{Deserialize, Serialize};
use smol::process::Command;

use crate::{
    Result,
    error::{Error, check_empty_process_output, check_process_success, map_add_intent},
    pane_id::{PaneId, parse::pane_id},
    parse::{boolean, quoted_nonempty_string, quoted_string},
    window_id::WindowId,
};

/// A Tmux pane.
///
/// ```
/// use std::str::FromStr;
/// use tmux_lib::pane::Pane;
///
/// let line = "%20:0:false:'rmbp':'nvim':/Users/graelo/code/rust/tmux-backup";
/// let pane = Pane::from_str(line).unwrap();
///
/// assert_eq!(pane.id.as_str(), "%20");
/// assert_eq!(pane.index, 0);
/// assert!(!pane.is_active);
/// assert_eq!(pane.command, "nvim");
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Pane {
    /// Pane identifier, e.g. `%37`.
    pub id: PaneId,
    /// Describes the Pane index in the Window
    pub index: u16,
    /// Describes if the pane is currently active (focused).
    pub is_active: bool,
    /// Title of the Pane (usually defaults to the hostname)
    pub title: String,
    /// Current dirpath of the Pane
    pub dirpath: PathBuf,
    /// Current command executed in the Pane
    pub command: String,
}

impl FromStr for Pane {
    type Err = Error;

    /// Parse a string containing tmux panes status into a new `Pane`.
    ///
    /// This returns a `Result<Pane, Error>` as this call can obviously
    /// fail if provided an invalid format.
    ///
    /// The expected format of the tmux status is
    ///
    /// ```text
    /// %20:0:false:'rmbp':'nvim':/Users/graelo/code/rust/tmux-backup
    /// %21:1:true:'rmbp':'tmux':/Users/graelo/code/rust/tmux-backup
    /// %27:2:false:'rmbp':'man man':/Users/graelo/code/rust/tmux-backup
    /// ```
    ///
    /// This status line is obtained with
    ///
    /// ```text
    /// tmux list-panes -F "#{pane_id}:#{pane_index}:#{?pane_active,true,false}:'#{pane_title}':'#{pane_current_command}':#{pane_current_path}"
    /// ```
    ///
    /// For definitions, look at `Pane` type and the tmux man page for
    /// definitions.
    fn from_str(input: &str) -> std::result::Result<Self, Self::Err> {
        let desc = "Pane";
        let intent = "##{pane_id}:##{pane_index}:##{?pane_active,true,false}:'##{pane_title}':'##{pane_current_command}':##{pane_current_path}";

        let (_, pane) = all_consuming(parse::pane)
            .parse(input)
            .map_err(|e| map_add_intent(desc, intent, e))?;

        Ok(pane)
    }
}

impl Pane {
    /// Return the entire Pane content as a `Vec<u8>`.
    ///
    /// # Note
    ///
    /// The output contains the escape codes, joined lines with trailing spaces. This output is
    /// processed by the function `tmux_lib::utils::cleanup_captured_buffer`.
    ///
    pub async fn capture(&self) -> Result<Vec<u8>> {
        let args = vec![
            "capture-pane",
            "-t",
            self.id.as_str(),
            "-J", // preserves trailing spaces & joins any wrapped lines
            "-e", // include escape sequences for text & background
            "-p", // output goes to stdout
            "-S", // starting line number
            "-",  // start of history
            "-E", // ending line number
            "-",  // end of history
        ];

        let output = Command::new("tmux").args(&args).output().await?;

        Ok(output.stdout)
    }
}

pub(crate) mod parse {
    use super::*;

    pub(crate) fn pane(input: &str) -> IResult<&str, Pane> {
        let (input, (id, _, index, _, is_active, _, title, _, command, _, dirpath)) = (
            pane_id,
            char(':'),
            map_res(digit1, str::parse),
            char(':'),
            boolean,
            char(':'),
            quoted_string,
            char(':'),
            quoted_nonempty_string,
            char(':'),
            not_line_ending,
        )
            .parse(input)?;

        Ok((
            input,
            Pane {
                id,
                index,
                is_active,
                title: title.into(),
                dirpath: dirpath.into(),
                command: command.into(),
            },
        ))
    }
}

// ------------------------------
// Ops
// ------------------------------

/// Return a list of all `Pane` from all sessions.
pub async fn available_panes() -> Result<Vec<Pane>> {
    let args = vec![
        "list-panes",
        "-a",
        "-F",
        "#{pane_id}\
        :#{pane_index}\
        :#{?pane_active,true,false}\
        :'#{pane_title}'\
        :'#{pane_current_command}'\
        :#{pane_current_path}",
    ];

    let output = Command::new("tmux").args(&args).output().await?;
    let buffer = String::from_utf8(output.stdout)?;

    // Each call to `Pane::parse` returns a `Result<Pane, _>`. All results
    // are collected into a Result<Vec<Pane>, _>, thanks to `collect()`.
    let result: Result<Vec<Pane>> = buffer
        .trim_end() // trim last '\n' as it would create an empty line
        .split('\n')
        .map(Pane::from_str)
        .collect();

    result
}

/// Create a new pane (horizontal split) in the window with `window_id`, and return the new
/// pane id.
pub async fn new_pane(
    reference_pane: &Pane,
    pane_command: Option<&str>,
    window_id: &WindowId,
) -> Result<PaneId> {
    let mut args = vec![
        "split-window",
        "-h",
        "-c",
        reference_pane.dirpath.to_str().unwrap(),
        "-t",
        window_id.as_str(),
        "-P",
        "-F",
        "#{pane_id}",
    ];
    if let Some(pane_command) = pane_command {
        args.push(pane_command);
    }

    let output = Command::new("tmux").args(&args).output().await?;

    // Check exit status before parsing to avoid confusing parse errors
    // when tmux fails and returns empty/garbage stdout.
    check_process_success(&output, "split-window")?;

    let buffer = String::from_utf8(output.stdout)?;

    let new_id = PaneId::from_str(buffer.trim_end())?;
    Ok(new_id)
}

/// Select (make active) the pane with `pane_id`.
pub async fn select_pane(pane_id: &PaneId) -> Result<()> {
    let args = vec!["select-pane", "-t", pane_id.as_str()];

    let output = Command::new("tmux").args(&args).output().await?;
    check_empty_process_output(&output, "select-pane")
}

#[cfg(test)]
mod tests {
    use super::Pane;
    use super::PaneId;
    use crate::Result;
    use std::path::PathBuf;
    use std::str::FromStr;

    #[test]
    fn parse_list_panes() {
        let output = [
            "%20:0:false:'rmbp':'nvim':/Users/graelo/code/rust/tmux-backup",
            "%21:1:true:'graelo@server: ~':'tmux':/Users/graelo/code/rust/tmux-backup",
            "%27:2:false:'rmbp':'man man':/Users/graelo/code/rust/tmux-backup",
        ];
        let panes: Result<Vec<Pane>> = output.iter().map(|&line| Pane::from_str(line)).collect();
        let panes = panes.expect("Could not parse tmux panes");

        let expected = vec![
            Pane {
                id: PaneId::from_str("%20").unwrap(),
                index: 0,
                is_active: false,
                title: String::from("rmbp"),
                dirpath: PathBuf::from_str("/Users/graelo/code/rust/tmux-backup").unwrap(),
                command: String::from("nvim"),
            },
            Pane {
                id: PaneId(String::from("%21")),
                index: 1,
                is_active: true,
                title: String::from("graelo@server: ~"),
                dirpath: PathBuf::from_str("/Users/graelo/code/rust/tmux-backup").unwrap(),
                command: String::from("tmux"),
            },
            Pane {
                id: PaneId(String::from("%27")),
                index: 2,
                is_active: false,
                title: String::from("rmbp"),
                dirpath: PathBuf::from_str("/Users/graelo/code/rust/tmux-backup").unwrap(),
                command: String::from("man man"),
            },
        ];

        assert_eq!(panes, expected);
    }

    #[test]
    fn parse_pane_with_empty_title() {
        let line = "%20:0:false:'':'nvim':/Users/graelo/code/rust/tmux-backup";
        let pane = Pane::from_str(line).expect("Could not parse pane with empty title");

        let expected = Pane {
            id: PaneId::from_str("%20").unwrap(),
            index: 0,
            is_active: false,
            title: String::from(""),
            dirpath: PathBuf::from_str("/Users/graelo/code/rust/tmux-backup").unwrap(),
            command: String::from("nvim"),
        };

        assert_eq!(pane, expected);
    }

    #[test]
    fn parse_pane_with_large_index() {
        let line = "%999:99:true:'host':'zsh':/home/user";
        let pane = Pane::from_str(line).expect("Should parse pane with large index");

        assert_eq!(pane.id, PaneId::from_str("%999").unwrap());
        assert_eq!(pane.index, 99);
        assert!(pane.is_active);
    }

    #[test]
    fn parse_pane_with_spaces_in_path() {
        let line = "%1:0:false:'title':'vim':/Users/user/My Documents/project";
        let pane = Pane::from_str(line).expect("Should parse pane with spaces in path");

        assert_eq!(
            pane.dirpath,
            PathBuf::from("/Users/user/My Documents/project")
        );
    }

    #[test]
    fn parse_pane_with_unicode_title() {
        let line = "%1:0:true:'日本語タイトル':'bash':/home/user";
        let pane = Pane::from_str(line).expect("Should parse pane with unicode title");

        assert_eq!(pane.title, "日本語タイトル");
    }

    #[test]
    fn parse_pane_with_complex_command() {
        let line = "%1:0:false:'host':'python -m http.server 8080':/tmp";
        let pane = Pane::from_str(line).expect("Should parse pane with complex command");

        assert_eq!(pane.command, "python -m http.server 8080");
    }

    #[test]
    fn parse_pane_fails_on_missing_id() {
        let line = "0:false:'title':'cmd':/path";
        let result = Pane::from_str(line);

        assert!(result.is_err());
    }

    #[test]
    fn parse_pane_fails_on_invalid_boolean() {
        let line = "%1:0:yes:'title':'cmd':/path";
        let result = Pane::from_str(line);

        assert!(result.is_err());
    }

    #[test]
    fn parse_pane_fails_on_empty_command() {
        // Command must be nonempty (uses quoted_nonempty_string)
        let line = "%1:0:true:'title':'':/path";
        let result = Pane::from_str(line);

        assert!(result.is_err());
    }

    #[test]
    fn parse_pane_fails_on_missing_path() {
        let line = "%1:0:true:'title':'cmd'";
        let result = Pane::from_str(line);

        assert!(result.is_err());
    }

    #[test]
    fn parse_pane_fails_on_wrong_id_prefix() {
        // % is for pane, @ is for window, $ is for session
        let line = "@1:0:true:'title':'cmd':/path";
        let result = Pane::from_str(line);

        assert!(result.is_err());
    }
}