muxr_client/session/
list.rs1use std::fmt;
2use std::fs;
3use std::io;
4use std::path::Path;
5use std::time::Duration;
6use std::time::SystemTime;
7
8use crossterm::style::Stylize;
9use jiff::Zoned;
10use muxr_core::ClientRequest;
11use muxr_core::ServerEvent;
12use muxr_core::SessionName;
13use muxr_core::SessionPaths;
14use muxr_transport::ClientConnection;
15use rootcause::prelude::ResultExt;
16
17pub const SESSION_PROBE_TIMEOUT: Duration = Duration::from_millis(250);
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub enum SessionState {
22 Live,
23 Stopped,
24 Unknown,
25}
26
27impl fmt::Display for SessionState {
28 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29 match self {
30 Self::Live => f.write_str("live"),
31 Self::Stopped => f.write_str("stopped"),
32 Self::Unknown => f.write_str("unknown"),
33 }
34 }
35}
36
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct ListedSession {
39 created_at: Option<SystemTime>,
40 name: SessionName,
41 state: SessionState,
42}
43
44impl ListedSession {
45 pub const fn name(&self) -> &SessionName {
46 &self.name
47 }
48
49 pub const fn state(&self) -> SessionState {
50 self.state
51 }
52
53 pub fn display_text(&self) -> String {
55 let state = match self.state {
56 SessionState::Live => self.state.to_string().green().bold().to_string(),
57 SessionState::Stopped => self.state.to_string().yellow().dim().to_string(),
58 SessionState::Unknown => self.state.to_string().red().bold().to_string(),
59 };
60 format!("{} [{state}] {}", self.name, self::created_at_text(self.created_at))
61 }
62
63 pub fn search_text(&self) -> String {
64 format!(
65 "{} {} {}",
66 self.name,
67 self.state,
68 self::created_at_text(self.created_at)
69 )
70 }
71}
72
73impl fmt::Display for ListedSession {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 write!(
76 f,
77 "{} [{}] {}",
78 self.name,
79 self.state,
80 self::created_at_text(self.created_at)
81 )
82 }
83}
84
85pub fn list_sessions() -> rootcause::Result<Vec<ListedSession>> {
91 self::list_sessions_from_root(&SessionPaths::sessions_root_from_home()?)
92}
93
94pub fn session_state(paths: &SessionPaths) -> rootcause::Result<SessionState> {
95 if !paths.socket.exists() {
96 if paths.pid.exists() {
97 return Ok(SessionState::Unknown);
98 }
99 return Ok(SessionState::Stopped);
100 }
101
102 tokio::runtime::Runtime::new()
103 .context("failed to build muxr tokio runtime")?
104 .block_on(self::session_state_async(paths))
105}
106
107fn list_sessions_from_root(root: &Path) -> rootcause::Result<Vec<ListedSession>> {
108 let entries = match fs::read_dir(root) {
109 Ok(entries) => entries,
110 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
111 Err(error) => return Err(error).context("failed to read muxr sessions root")?,
112 };
113
114 let mut sessions = Vec::new();
115 for entry in entries {
116 let entry = entry.context("failed to read muxr session dir entry")?;
117 if !entry
118 .file_type()
119 .context("failed to inspect muxr session dir entry")?
120 .is_dir()
121 {
122 continue;
123 }
124 let raw_name = entry.file_name();
125 let Some(raw_name) = raw_name.to_str() else {
126 continue;
127 };
128 let Ok(name) = raw_name.parse() else {
129 continue;
130 };
131 let paths = SessionPaths::from_sessions_root_path(root, &name)?;
132 let created_at = entry
133 .metadata()
134 .context("failed to inspect muxr session dir metadata")?
135 .created()
136 .ok();
137 sessions.push(ListedSession {
138 created_at,
139 name,
140 state: self::session_state(&paths)?,
141 });
142 }
143
144 sessions.sort_by(|left, right| {
145 right
146 .created_at
147 .cmp(&left.created_at)
148 .then_with(|| left.name.as_ref().cmp(right.name.as_ref()))
149 });
150 Ok(sessions)
151}
152
153fn created_at_text(created_at: Option<SystemTime>) -> String {
154 created_at.map_or_else(
155 || "unknown".to_owned(),
156 |created_at| {
157 Zoned::try_from(created_at).map_or_else(
158 |_| "unknown".to_owned(),
159 |created_at| created_at.strftime("%d-%m-%Y %H:%M:%S").to_string(),
160 )
161 },
162 )
163}
164
165pub async fn session_state_async(paths: &SessionPaths) -> rootcause::Result<SessionState> {
170 let Ok(Ok(mut connection)) =
171 tokio::time::timeout(SESSION_PROBE_TIMEOUT, ClientConnection::connect(&paths.socket)).await
172 else {
173 return Ok(SessionState::Unknown);
174 };
175
176 match tokio::time::timeout(SESSION_PROBE_TIMEOUT, connection.send_request(&ClientRequest::Ping)).await {
177 Ok(Ok(())) => {}
178 Ok(Err(_)) | Err(_) => return Ok(SessionState::Unknown),
179 }
180
181 if matches!(
182 tokio::time::timeout(SESSION_PROBE_TIMEOUT, connection.recv_event()).await,
183 Ok(Ok(Some(ServerEvent::Pong)))
184 ) {
185 Ok(SessionState::Live)
186 } else {
187 Ok(SessionState::Unknown)
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use std::fs;
194
195 use rstest::rstest;
196 use test_that::prelude::*;
197
198 use super::*;
199
200 #[test]
201 fn test_list_sessions_from_root_when_entries_exist_returns_valid_sessions_sorted_by_recent() -> rootcause::Result<()>
202 {
203 let root = tempfile::tempdir().context("failed to create muxr sessions test root")?;
204 fs::create_dir(root.path().join("older")).context("failed to create older session")?;
205 std::thread::sleep(Duration::from_millis(5));
206 fs::create_dir(root.path().join("newer")).context("failed to create newer session")?;
207 fs::create_dir(root.path().join("bad name")).context("failed to create invalid session")?;
208 fs::write(root.path().join("file"), b"ignored").context("failed to create ignored file")?;
209
210 let sessions = self::list_sessions_from_root(root.path())?;
211
212 let rendered = sessions.iter().map(ToString::to_string).collect::<Vec<_>>();
213 let expected = sessions
214 .iter()
215 .map(|session| {
216 format!(
217 "{} [stopped] {}",
218 session.name,
219 self::created_at_text(session.created_at)
220 )
221 })
222 .collect::<Vec<_>>();
223 assert_that!(
224 sessions
225 .iter()
226 .map(|session| session.name.to_string())
227 .collect::<Vec<_>>(),
228 eq(vec!["newer", "older"])
229 );
230 assert_that!(rendered, eq(expected));
231 Ok(())
232 }
233
234 #[rstest]
235 #[case::live(SessionState::Live, format!("work [{}]", "live".green().bold()))]
236 #[case::stopped(SessionState::Stopped, format!("work [{}]", "stopped".yellow().dim()))]
237 #[case::unknown(SessionState::Unknown, format!("work [{}]", "unknown".red().bold()))]
238 fn test_listed_session_display_text_when_state_varies_colors_state(
239 #[case] state: SessionState,
240 #[case] expected: String,
241 ) -> rootcause::Result<()> {
242 let created_at = SystemTime::now();
243 let session = ListedSession {
244 created_at: Some(created_at),
245 name: "work".parse()?,
246 state,
247 };
248
249 assert_that!(
250 session.display_text(),
251 eq(format!("{expected} {}", self::created_at_text(Some(created_at))))
252 );
253 Ok(())
254 }
255
256 #[test]
257 fn test_listed_session_display_text_when_created_at_missing_reports_unknown() -> rootcause::Result<()> {
258 let session = ListedSession {
259 created_at: None,
260 name: "work".parse()?,
261 state: SessionState::Stopped,
262 };
263
264 assert_that!(
265 session.display_text(),
266 eq(format!("work [{}] unknown", "stopped".yellow().dim()))
267 );
268 Ok(())
269 }
270}