Skip to main content

muxr/cmds/
sessions.rs

1//! Session management commands for `muxr`.
2
3use std::fmt;
4
5use muxr_core::SessionName;
6use owo_colors::OwoColorize;
7use rootcause::report;
8use strum::EnumIter;
9use strum::IntoEnumIterator;
10
11#[derive(Clone, Copy, Debug, EnumIter, Eq, PartialEq)]
12enum SessionAction {
13    Attach,
14    Delete,
15}
16
17impl fmt::Display for SessionAction {
18    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19        match self {
20            Self::Attach => write!(f, "{}", "Attach".green().bold()),
21            Self::Delete => write!(f, "{}", "Delete".red().bold()),
22        }
23    }
24}
25
26pub fn run() -> rootcause::Result<()> {
27    let sessions = muxr_client::list_sessions()?;
28    if sessions.is_empty() {
29        super::start::run(&SessionName::default(), None)?;
30        return Ok(());
31    }
32
33    let Some(selected) = ytil_tui::minimal_multi_select(
34        sessions,
35        muxr_client::ListedSession::display_text,
36        muxr_client::ListedSession::search_text,
37    )?
38    else {
39        println!("No sessions selected");
40        return Ok(());
41    };
42
43    let Some(action) = ytil_tui::minimal_select::<SessionAction>(SessionAction::iter().collect())? else {
44        println!("No action selected");
45        return Ok(());
46    };
47
48    let sessions = selected
49        .iter()
50        .map(|session| session.name().clone())
51        .collect::<Vec<_>>();
52    self::execute_session_action(action, &sessions)
53}
54
55fn execute_session_action(action: SessionAction, selected: &[SessionName]) -> rootcause::Result<()> {
56    match action {
57        SessionAction::Attach => {
58            let session = ytil_tui::require_single(selected, "sessions")?;
59            super::start::run(session, None)
60        }
61        SessionAction::Delete => self::delete_selected_sessions(selected, muxr_client::delete_session),
62    }
63}
64
65fn delete_selected_sessions<F>(selected: &[SessionName], mut delete_session: F) -> rootcause::Result<()>
66where
67    F: FnMut(&SessionName) -> rootcause::Result<muxr_client::SessionDeleteOutcome>,
68{
69    let mut failures = Vec::new();
70
71    for session in selected {
72        match delete_session(session) {
73            Ok(outcome) => println!("{}", self::delete_session_message(session, outcome)),
74            Err(error) => {
75                // Batch delete must attempt every selected session so one corrupt entry cannot block cleanup.
76                eprintln!("{}", self::delete_session_failure_message(session, &error));
77                failures.push(format!("{session}: {error}"));
78            }
79        }
80    }
81
82    if failures.is_empty() {
83        Ok(())
84    } else {
85        Err(report!("failed to delete selected muxr sessions").attach(failures.join("\n")))
86    }
87}
88
89fn delete_session_message(session: &SessionName, outcome: muxr_client::SessionDeleteOutcome) -> String {
90    let deleted = format!("{}", "Deleted".red().bold());
91    match outcome {
92        muxr_client::SessionDeleteOutcome::LiveDeleted => {
93            format!("{deleted} {session}; stopped live server and removed state")
94        }
95        muxr_client::SessionDeleteOutcome::LiveVanishedForced => {
96            format!("{deleted} {session}; live server vanished, force-removed selected session files")
97        }
98        muxr_client::SessionDeleteOutcome::StoppedRemoved => {
99            format!("{deleted} {session}; removed stopped session state")
100        }
101        muxr_client::SessionDeleteOutcome::UnknownForced => {
102            format!("{deleted} {session}; force-removed unknown session files")
103        }
104    }
105}
106
107fn delete_session_failure_message(session: &SessionName, error: impl fmt::Display) -> String {
108    let failed = format!("{}", "Failed".red().bold());
109    format!("{failed} to delete session {session}: {error}")
110}
111
112#[cfg(test)]
113mod tests {
114    use rstest::rstest;
115    use test_that::prelude::*;
116
117    use super::*;
118
119    #[rstest]
120    #[case::attach(SessionAction::Attach, format!("{}", "Attach".green().bold()))]
121    #[case::delete(SessionAction::Delete, format!("{}", "Delete".red().bold()))]
122    fn test_session_action_display_when_action_varies_matches_zj_style(
123        #[case] action: SessionAction,
124        #[case] expected: String,
125    ) {
126        assert_that!(action.to_string(), eq(expected));
127    }
128
129    #[test]
130    fn test_delete_selected_sessions_when_one_delete_fails_still_attempts_all() -> rootcause::Result<()> {
131        let selected = ["ok", "bad", "later"]
132            .into_iter()
133            .map(str::parse)
134            .collect::<rootcause::Result<Vec<SessionName>>>()?;
135        let mut attempted = Vec::new();
136
137        let result = delete_selected_sessions(&selected, |session| {
138            attempted.push(session.to_string());
139            if session.as_ref() == "bad" {
140                Err(report!("delete failed"))
141            } else {
142                Ok(muxr_client::SessionDeleteOutcome::StoppedRemoved)
143            }
144        });
145
146        assert_that!(result, err(anything()));
147        assert_that!(attempted, eq(vec!["ok", "bad", "later"]));
148        Ok(())
149    }
150
151    #[test]
152    fn test_execute_session_action_when_attach_has_multiple_sessions_returns_error() -> rootcause::Result<()> {
153        let sessions = vec![listed_session("work")?, listed_session("notes")?];
154
155        let error = execute_session_action(SessionAction::Attach, &sessions)
156            .expect_err("expected attach multi-selection error");
157
158        assert_that!(error.to_string(), contains_substring("expected exactly one selection"));
159        Ok(())
160    }
161
162    #[rstest]
163    #[case::live_deleted(
164        muxr_client::SessionDeleteOutcome::LiveDeleted,
165        format!("{} work; stopped live server and removed state", "Deleted".red().bold())
166    )]
167    #[case::live_vanished_forced(
168        muxr_client::SessionDeleteOutcome::LiveVanishedForced,
169        format!("{} work; live server vanished, force-removed selected session files", "Deleted".red().bold())
170    )]
171    #[case::stopped_removed(
172        muxr_client::SessionDeleteOutcome::StoppedRemoved,
173        format!("{} work; removed stopped session state", "Deleted".red().bold())
174    )]
175    #[case::unknown_forced(
176        muxr_client::SessionDeleteOutcome::UnknownForced,
177        format!("{} work; force-removed unknown session files", "Deleted".red().bold())
178    )]
179    fn test_delete_session_message_when_outcome_varies_reports_behavior(
180        #[case] outcome: muxr_client::SessionDeleteOutcome,
181        #[case] expected: String,
182    ) -> rootcause::Result<()> {
183        assert_that!(delete_session_message(&listed_session("work")?, outcome), eq(expected));
184        Ok(())
185    }
186
187    #[test]
188    fn test_delete_session_failure_message_colors_failure_prefix() -> rootcause::Result<()> {
189        let error = report!("delete failed");
190        let message = delete_session_failure_message(&listed_session("work")?, &error);
191
192        assert_that!(
193            message,
194            starts_with(format!("{} to delete session work:", "Failed".red().bold()))
195        );
196        assert_that!(message, contains_substring("delete failed"));
197        Ok(())
198    }
199
200    fn listed_session(raw: &str) -> rootcause::Result<SessionName> {
201        raw.parse()
202    }
203}