Skip to main content

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