1use 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
12const SERVER_READY_TIMEOUT: Duration = Duration::from_secs(5);
14
15const SERVER_READY_POLL_INTERVAL: Duration = Duration::from_millis(50);
17
18pub 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_server_ready().await
37}
38
39async 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
73pub 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
82pub 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
101pub 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
115fn 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
130pub 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}