Skip to main content

tmux_lib/
server.rs

1//! Server management.
2
3use std::{collections::HashMap, time::Duration};
4
5use smol::{Timer, future, process::Command};
6
7use crate::{
8    Result,
9    error::{Error, check_empty_process_output},
10};
11
12/// Maximum time to wait for the server to become ready.
13const SERVER_READY_TIMEOUT: Duration = Duration::from_secs(5);
14
15/// Delay between readiness checks.
16const SERVER_READY_POLL_INTERVAL: Duration = Duration::from_millis(50);
17
18// ------------------------------
19// Ops
20// ------------------------------
21
22/// Start the Tmux server if needed, creating a session named `"[placeholder]"` in order to keep the server
23/// running.
24///
25/// This function waits for the server to be fully ready before returning, ensuring
26/// subsequent commands can be executed immediately.
27///
28/// It is ok-ish to already have an existing session named `"[placeholder]"`.
29pub async fn start(initial_session_name: &str) -> Result<()> {
30    let args = vec!["new-session", "-d", "-s", initial_session_name];
31
32    let output = Command::new("tmux").args(&args).output().await?;
33    check_empty_process_output(&output, "new-session")?;
34
35    // Wait for the server to be fully ready to accept commands.
36    wait_for_server_ready().await
37}
38
39/// Wait for the tmux server to be ready to accept commands.
40///
41/// This polls the server using `tmux list-sessions` until it succeeds or times out.
42async fn wait_for_server_ready() -> Result<()> {
43    let poll = async {
44        loop {
45            let output = Command::new("tmux")
46                .args(["list-sessions", "-F", "#{session_name}"])
47                .output()
48                .await?;
49
50            if output.status.success() {
51                return Ok(());
52            }
53
54            Timer::after(SERVER_READY_POLL_INTERVAL).await;
55        }
56    };
57
58    let timeout = async {
59        Timer::after(SERVER_READY_TIMEOUT).await;
60        Err(Error::UnexpectedTmuxOutput {
61            intent: "wait-for-server-ready",
62            stdout: String::new(),
63            stderr: format!(
64                "server did not become ready within {:?}",
65                SERVER_READY_TIMEOUT
66            ),
67        })
68    };
69
70    future::or(poll, timeout).await
71}
72
73/// Remove the session named `"[placeholder]"` used to keep the server alive.
74pub async fn kill_session(name: &str) -> Result<()> {
75    let exact_name = format!("={name}");
76    let args = vec!["kill-session", "-t", &exact_name];
77
78    let output = Command::new("tmux").args(&args).output().await?;
79    check_empty_process_output(&output, "kill-session")
80}
81
82/// Return the value of a Tmux option. For instance, this can be used to get Tmux's default
83/// command.
84pub async fn show_option(option_name: &str, global: bool) -> Result<Option<String>> {
85    let mut args = vec!["show-options", "-w", "-q"];
86    if global {
87        args.push("-g");
88    }
89    args.push(option_name);
90
91    let output = Command::new("tmux").args(&args).output().await?;
92    let buffer = String::from_utf8(output.stdout)?;
93    let buffer = buffer.trim_end();
94
95    if buffer.is_empty() {
96        return Ok(None);
97    }
98    Ok(Some(buffer.to_string()))
99}
100
101/// Return all Tmux options as a `HashMap`.
102pub async fn show_options(global: bool) -> Result<HashMap<String, String>> {
103    let args = if global {
104        vec!["show-options", "-g"]
105    } else {
106        vec!["show-options"]
107    };
108
109    let output = Command::new("tmux").args(&args).output().await?;
110    let buffer = String::from_utf8(output.stdout)?;
111
112    Ok(parse_options(&buffer))
113}
114
115/// Parse the output of `tmux show-options` into a `HashMap`.
116///
117/// Lines without a space (bare flags) are skipped. Values that are empty or
118/// equal to `''` are filtered out.
119fn parse_options(buffer: &str) -> HashMap<String, String> {
120    buffer
121        .trim_end()
122        .split('\n')
123        .filter_map(|s| s.split_once(' '))
124        .map(|(k, v)| (k, v.trim_start()))
125        .filter(|(_, v)| !v.is_empty() && v != &"''")
126        .map(|(k, v)| (k.to_string(), v.to_string()))
127        .collect()
128}
129
130/// Return the `"default-command"` used to start a pane, falling back to `"default shell"` if none.
131///
132/// In case of bash, a `-l` flag is added.
133pub async fn default_command() -> Result<String> {
134    let all_options = show_options(true).await?;
135
136    let default_shell = all_options
137        .get("default-shell")
138        .ok_or(Error::TmuxConfig("no default-shell"))
139        .map(|cmd| cmd.to_owned())
140        .map(|cmd| {
141            if cmd.ends_with("bash") {
142                format!("-l {cmd}")
143            } else {
144                cmd
145            }
146        })?;
147
148    all_options
149        .get("default-command")
150        .or(Some(&default_shell))
151        .ok_or(Error::TmuxConfig("no default-command nor default-shell"))
152        .map(|cmd| cmd.to_owned())
153}
154
155#[cfg(test)]
156mod tests {
157    use super::parse_options;
158
159    #[test]
160    fn parse_options_typical_output() {
161        let input = "default-shell /bin/zsh\nstatus on\nhistory-limit 10000\n";
162        let opts = parse_options(input);
163
164        assert_eq!(opts.get("default-shell").unwrap(), "/bin/zsh");
165        assert_eq!(opts.get("status").unwrap(), "on");
166        assert_eq!(opts.get("history-limit").unwrap(), "10000");
167    }
168
169    #[test]
170    fn parse_options_skips_bare_flags() {
171        let input = "destroy-unattached\ndefault-shell /bin/zsh\nsilence-action\n";
172        let opts = parse_options(input);
173
174        assert_eq!(opts.len(), 1);
175        assert_eq!(opts.get("default-shell").unwrap(), "/bin/zsh");
176        assert!(!opts.contains_key("destroy-unattached"));
177        assert!(!opts.contains_key("silence-action"));
178    }
179
180    #[test]
181    fn parse_options_filters_empty_values() {
182        let input = "default-command ''\ndefault-shell /bin/zsh\n";
183        let opts = parse_options(input);
184
185        assert!(!opts.contains_key("default-command"));
186        assert_eq!(opts.get("default-shell").unwrap(), "/bin/zsh");
187    }
188
189    #[test]
190    fn parse_options_empty_input() {
191        let opts = parse_options("");
192        assert!(opts.is_empty());
193    }
194
195    #[test]
196    fn parse_options_value_with_spaces() {
197        let input = "status-left [#S] #H\nstatus on\n";
198        let opts = parse_options(input);
199
200        assert_eq!(opts.get("status-left").unwrap(), "[#S] #H");
201        assert_eq!(opts.get("status").unwrap(), "on");
202    }
203
204    #[test]
205    fn parse_options_trims_spaces_between_key_and_value() {
206        let input = "key   value-with-extra-spaces\n";
207        let opts = parse_options(input);
208
209        assert_eq!(opts.get("key").unwrap(), "value-with-extra-spaces");
210    }
211}