Skip to main content

muxr_client/session/
list.rs

1use 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/// Current muxr session state as observed by the local session picker.
20#[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    /// Render this session for ANSI-aware TUI pickers.
54    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
85/// List valid persisted muxr sessions from the local muxr state root.
86///
87/// # Errors
88/// - `HOME` is unavailable.
89/// - The session root cannot be read for reasons other than being absent.
90pub 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
165/// Probe a muxr session socket without starting a nested Tokio runtime.
166///
167/// # Errors
168/// - The probe runtime IO fails after connecting to the socket.
169pub 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}