tmux-lib 0.5.0

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

use std::{path::PathBuf, str::FromStr};

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

use crate::{
    Result,
    error::{Error, check_process_success, map_add_intent},
    pane::Pane,
    pane_id::{PaneId, parse::pane_id},
    parse::quoted_nonempty_string,
    session_id::{SessionId, parse::session_id},
    window::Window,
    window_id::{WindowId, parse::window_id},
};

/// A Tmux session.
///
/// ```
/// use std::str::FromStr;
/// use tmux_lib::session::Session;
///
/// let line = "$1:'pytorch':/Users/graelo/ml/pytorch";
/// let session = Session::from_str(line).unwrap();
///
/// assert_eq!(session.id.as_str(), "$1");
/// assert_eq!(session.name, "pytorch");
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Session {
    /// Session identifier, e.g. `$3`.
    pub id: SessionId,
    /// Name of the session.
    pub name: String,
    /// Working directory of the session.
    pub dirpath: PathBuf,
}

impl FromStr for Session {
    type Err = Error;

    /// Parse a string containing tmux session status into a new `Session`.
    ///
    /// This returns a `Result<Session, Error>` as this call can obviously
    /// fail if provided an invalid format.
    ///
    /// The expected format of the tmux status is
    ///
    /// ```text
    /// $1:'pytorch':/Users/graelo/dl/pytorch
    /// $2:'rust':/Users/graelo/rust
    /// $3:'server: $~':/Users/graelo/swift
    /// $4:'tmux-hacking':/Users/graelo/tmux
    /// ```
    ///
    /// This status line is obtained with
    ///
    /// ```text
    /// tmux list-sessions -F "#{session_id}:'#{session_name}':#{session_path}"
    /// ```
    ///
    /// For definitions, look at `Session` type and the tmux man page for
    /// definitions.
    fn from_str(input: &str) -> std::result::Result<Self, Self::Err> {
        let desc = "Session";
        let intent = "##{session_id}:'##{session_name}':##{session_path}";

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

        Ok(sess)
    }
}

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

    pub(crate) fn session(input: &str) -> IResult<&str, Session> {
        let (input, (id, _, name, _, dirpath)) = (
            session_id,
            char(':'),
            quoted_nonempty_string,
            char(':'),
            not_line_ending,
        )
            .parse(input)?;

        Ok((
            input,
            Session {
                id,
                name: name.to_string(),
                dirpath: dirpath.into(),
            },
        ))
    }
}

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

/// Return a list of all `Session` from the current tmux session.
pub async fn available_sessions() -> Result<Vec<Session>> {
    let args = vec![
        "list-sessions",
        "-F",
        "#{session_id}:'#{session_name}':#{session_path}",
    ];

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

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

    result
}

/// Create a Tmux session (and thus a window & pane).
///
/// The new session attributes:
///
/// - the session name is taken from the passed `session`
/// - the working directory is taken from the pane's working directory.
///
pub async fn new_session(
    session: &Session,
    window: &Window,
    pane: &Pane,
    pane_command: Option<&str>,
) -> Result<(SessionId, WindowId, PaneId)> {
    let mut args = vec![
        "new-session",
        "-d",
        "-c",
        pane.dirpath.to_str().unwrap(),
        "-s",
        &session.name,
        "-n",
        &window.name,
        "-P",
        "-F",
        "#{session_id}:#{window_id}:#{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, "new-session")?;

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

    let desc = "new-session";
    let intent = "##{session_id}:##{window_id}:##{pane_id}";
    let (_, (new_session_id, _, new_window_id, _, new_pane_id)) =
        all_consuming((session_id, char(':'), window_id, char(':'), pane_id))
            .parse(buffer)
            .map_err(|e| map_add_intent(desc, intent, e))?;

    Ok((new_session_id, new_window_id, new_pane_id))
}

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

    #[test]
    fn parse_list_sessions() {
        let output = [
            "$1:'pytorch':/Users/graelo/ml/pytorch",
            "$2:'rust':/Users/graelo/rust",
            "$3:'server: $':/Users/graelo/swift",
            "$4:'tmux-hacking':/Users/graelo/tmux",
        ];
        let sessions: Result<Vec<Session>> =
            output.iter().map(|&line| Session::from_str(line)).collect();
        let sessions = sessions.expect("Could not parse tmux sessions");

        let expected = vec![
            Session {
                id: SessionId::from_str("$1").unwrap(),
                name: String::from("pytorch"),
                dirpath: PathBuf::from("/Users/graelo/ml/pytorch"),
            },
            Session {
                id: SessionId::from_str("$2").unwrap(),
                name: String::from("rust"),
                dirpath: PathBuf::from("/Users/graelo/rust"),
            },
            Session {
                id: SessionId::from_str("$3").unwrap(),
                name: String::from("server: $"),
                dirpath: PathBuf::from("/Users/graelo/swift"),
            },
            Session {
                id: SessionId::from_str("$4").unwrap(),
                name: String::from("tmux-hacking"),
                dirpath: PathBuf::from("/Users/graelo/tmux"),
            },
        ];

        assert_eq!(sessions, expected);
    }

    #[test]
    fn parse_session_with_large_id() {
        let input = "$999:'large-id-session':/home/user/projects";
        let session = Session::from_str(input).expect("Should parse session with large id");

        assert_eq!(session.id, SessionId::from_str("$999").unwrap());
        assert_eq!(session.name, "large-id-session");
        assert_eq!(session.dirpath, PathBuf::from("/home/user/projects"));
    }

    #[test]
    fn parse_session_with_spaces_in_path() {
        let input = "$5:'dev':/Users/user/My Projects/rust";
        let session = Session::from_str(input).expect("Should parse session with spaces in path");

        assert_eq!(session.name, "dev");
        assert_eq!(
            session.dirpath,
            PathBuf::from("/Users/user/My Projects/rust")
        );
    }

    #[test]
    fn parse_session_with_unicode_in_name() {
        let input = "$6:'项目-日本語':/home/user/code";
        let session = Session::from_str(input).expect("Should parse session with unicode name");

        assert_eq!(session.name, "项目-日本語");
    }

    #[test]
    fn parse_session_fails_on_missing_id() {
        let input = "'session-name':/path/to/dir";
        let result = Session::from_str(input);

        assert!(result.is_err());
    }

    #[test]
    fn parse_session_fails_on_missing_name_quotes() {
        let input = "$1:session-name:/path/to/dir";
        let result = Session::from_str(input);

        assert!(result.is_err());
    }

    #[test]
    fn parse_session_fails_on_empty_name() {
        let input = "$1:'':/path/to/dir";
        let result = Session::from_str(input);

        assert!(result.is_err());
    }

    #[test]
    fn parse_session_fails_on_malformed_id() {
        let input = "@1:'session':/path"; // @ is window prefix, not session
        let result = Session::from_str(input);

        assert!(result.is_err());
    }

    #[test]
    fn parse_session_with_colon_in_path() {
        // Paths can contain colons (e.g., Windows-style paths or special paths)
        let input = "$7:'test':/path/with:colon/here";
        let session = Session::from_str(input).expect("Should parse session with colon in path");

        assert_eq!(session.dirpath, PathBuf::from("/path/with:colon/here"));
    }
}