tmux_lib/
session.rs

1//! This module provides a few types and functions to handle Tmux sessions.
2//!
3//! The main use cases are running Tmux commands & parsing Tmux session
4//! information.
5
6use std::{path::PathBuf, str::FromStr};
7
8use nom::{
9    character::complete::{char, not_line_ending},
10    combinator::all_consuming,
11    IResult, Parser,
12};
13use serde::{Deserialize, Serialize};
14use smol::process::Command;
15
16use crate::{
17    error::{check_process_success, map_add_intent, Error},
18    pane::Pane,
19    pane_id::{parse::pane_id, PaneId},
20    parse::quoted_nonempty_string,
21    session_id::{parse::session_id, SessionId},
22    window::Window,
23    window_id::{parse::window_id, WindowId},
24    Result,
25};
26
27/// A Tmux session.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Session {
30    /// Session identifier, e.g. `$3`.
31    pub id: SessionId,
32    /// Name of the session.
33    pub name: String,
34    /// Working directory of the session.
35    pub dirpath: PathBuf,
36}
37
38impl FromStr for Session {
39    type Err = Error;
40
41    /// Parse a string containing tmux session status into a new `Session`.
42    ///
43    /// This returns a `Result<Session, Error>` as this call can obviously
44    /// fail if provided an invalid format.
45    ///
46    /// The expected format of the tmux status is
47    ///
48    /// ```text
49    /// $1:'pytorch':/Users/graelo/dl/pytorch
50    /// $2:'rust':/Users/graelo/rust
51    /// $3:'server: $~':/Users/graelo/swift
52    /// $4:'tmux-hacking':/Users/graelo/tmux
53    /// ```
54    ///
55    /// This status line is obtained with
56    ///
57    /// ```text
58    /// tmux list-sessions -F "#{session_id}:'#{session_name}':#{session_path}"
59    /// ```
60    ///
61    /// For definitions, look at `Session` type and the tmux man page for
62    /// definitions.
63    fn from_str(input: &str) -> std::result::Result<Self, Self::Err> {
64        let desc = "Session";
65        let intent = "##{session_id}:'##{session_name}':##{session_path}";
66
67        let (_, sess) = all_consuming(parse::session)
68            .parse(input)
69            .map_err(|e| map_add_intent(desc, intent, e))?;
70
71        Ok(sess)
72    }
73}
74
75pub(crate) mod parse {
76    use super::*;
77
78    pub(crate) fn session(input: &str) -> IResult<&str, Session> {
79        let (input, (id, _, name, _, dirpath)) = (
80            session_id,
81            char(':'),
82            quoted_nonempty_string,
83            char(':'),
84            not_line_ending,
85        )
86            .parse(input)?;
87
88        Ok((
89            input,
90            Session {
91                id,
92                name: name.to_string(),
93                dirpath: dirpath.into(),
94            },
95        ))
96    }
97}
98
99// ------------------------------
100// Ops
101// ------------------------------
102
103/// Return a list of all `Session` from the current tmux session.
104pub async fn available_sessions() -> Result<Vec<Session>> {
105    let args = vec![
106        "list-sessions",
107        "-F",
108        "#{session_id}:'#{session_name}':#{session_path}",
109    ];
110
111    let output = Command::new("tmux").args(&args).output().await?;
112    let buffer = String::from_utf8(output.stdout)?;
113
114    // Each call to `Session::parse` returns a `Result<Session, _>`. All results
115    // are collected into a Result<Vec<Session>, _>, thanks to `collect()`.
116    let result: Result<Vec<Session>> = buffer
117        .trim_end() // trim last '\n' as it would create an empty line
118        .split('\n')
119        .map(Session::from_str)
120        .collect();
121
122    result
123}
124
125/// Create a Tmux session (and thus a window & pane).
126///
127/// The new session attributes:
128///
129/// - the session name is taken from the passed `session`
130/// - the working directory is taken from the pane's working directory.
131///
132pub async fn new_session(
133    session: &Session,
134    window: &Window,
135    pane: &Pane,
136    pane_command: Option<&str>,
137) -> Result<(SessionId, WindowId, PaneId)> {
138    let mut args = vec![
139        "new-session",
140        "-d",
141        "-c",
142        pane.dirpath.to_str().unwrap(),
143        "-s",
144        &session.name,
145        "-n",
146        &window.name,
147        "-P",
148        "-F",
149        "#{session_id}:#{window_id}:#{pane_id}",
150    ];
151    if let Some(pane_command) = pane_command {
152        args.push(pane_command);
153    }
154
155    let output = Command::new("tmux").args(&args).output().await?;
156
157    // Check exit status before parsing to avoid confusing parse errors
158    // when tmux fails and returns empty/garbage stdout.
159    check_process_success(&output, "new-session")?;
160
161    let buffer = String::from_utf8(output.stdout)?;
162    let buffer = buffer.trim_end();
163
164    let desc = "new-session";
165    let intent = "##{session_id}:##{window_id}:##{pane_id}";
166    let (_, (new_session_id, _, new_window_id, _, new_pane_id)) =
167        all_consuming((session_id, char(':'), window_id, char(':'), pane_id))
168            .parse(buffer)
169            .map_err(|e| map_add_intent(desc, intent, e))?;
170
171    Ok((new_session_id, new_window_id, new_pane_id))
172}
173
174#[cfg(test)]
175mod tests {
176    use super::Session;
177    use super::SessionId;
178    use crate::Result;
179    use std::path::PathBuf;
180    use std::str::FromStr;
181
182    #[test]
183    fn parse_list_sessions() {
184        let output = [
185            "$1:'pytorch':/Users/graelo/ml/pytorch",
186            "$2:'rust':/Users/graelo/rust",
187            "$3:'server: $':/Users/graelo/swift",
188            "$4:'tmux-hacking':/Users/graelo/tmux",
189        ];
190        let sessions: Result<Vec<Session>> =
191            output.iter().map(|&line| Session::from_str(line)).collect();
192        let sessions = sessions.expect("Could not parse tmux sessions");
193
194        let expected = vec![
195            Session {
196                id: SessionId::from_str("$1").unwrap(),
197                name: String::from("pytorch"),
198                dirpath: PathBuf::from("/Users/graelo/ml/pytorch"),
199            },
200            Session {
201                id: SessionId::from_str("$2").unwrap(),
202                name: String::from("rust"),
203                dirpath: PathBuf::from("/Users/graelo/rust"),
204            },
205            Session {
206                id: SessionId::from_str("$3").unwrap(),
207                name: String::from("server: $"),
208                dirpath: PathBuf::from("/Users/graelo/swift"),
209            },
210            Session {
211                id: SessionId::from_str("$4").unwrap(),
212                name: String::from("tmux-hacking"),
213                dirpath: PathBuf::from("/Users/graelo/tmux"),
214            },
215        ];
216
217        assert_eq!(sessions, expected);
218    }
219
220    #[test]
221    fn parse_session_with_large_id() {
222        let input = "$999:'large-id-session':/home/user/projects";
223        let session = Session::from_str(input).expect("Should parse session with large id");
224
225        assert_eq!(session.id, SessionId::from_str("$999").unwrap());
226        assert_eq!(session.name, "large-id-session");
227        assert_eq!(session.dirpath, PathBuf::from("/home/user/projects"));
228    }
229
230    #[test]
231    fn parse_session_with_spaces_in_path() {
232        let input = "$5:'dev':/Users/user/My Projects/rust";
233        let session = Session::from_str(input).expect("Should parse session with spaces in path");
234
235        assert_eq!(session.name, "dev");
236        assert_eq!(
237            session.dirpath,
238            PathBuf::from("/Users/user/My Projects/rust")
239        );
240    }
241
242    #[test]
243    fn parse_session_with_unicode_in_name() {
244        let input = "$6:'项目-日本語':/home/user/code";
245        let session = Session::from_str(input).expect("Should parse session with unicode name");
246
247        assert_eq!(session.name, "项目-日本語");
248    }
249
250    #[test]
251    fn parse_session_fails_on_missing_id() {
252        let input = "'session-name':/path/to/dir";
253        let result = Session::from_str(input);
254
255        assert!(result.is_err());
256    }
257
258    #[test]
259    fn parse_session_fails_on_missing_name_quotes() {
260        let input = "$1:session-name:/path/to/dir";
261        let result = Session::from_str(input);
262
263        assert!(result.is_err());
264    }
265
266    #[test]
267    fn parse_session_fails_on_empty_name() {
268        let input = "$1:'':/path/to/dir";
269        let result = Session::from_str(input);
270
271        assert!(result.is_err());
272    }
273
274    #[test]
275    fn parse_session_fails_on_malformed_id() {
276        let input = "@1:'session':/path"; // @ is window prefix, not session
277        let result = Session::from_str(input);
278
279        assert!(result.is_err());
280    }
281
282    #[test]
283    fn parse_session_with_colon_in_path() {
284        // Paths can contain colons (e.g., Windows-style paths or special paths)
285        let input = "$7:'test':/path/with:colon/here";
286        let session = Session::from_str(input).expect("Should parse session with colon in path");
287
288        assert_eq!(session.dirpath, PathBuf::from("/path/with:colon/here"));
289    }
290}