Skip to main content

muxr/
main.rs

1use std::fmt;
2use std::path::Path;
3use std::path::PathBuf;
4
5use muxr_core::EXTERNAL_LAYOUT_ARG;
6use muxr_core::SessionName;
7use owo_colors::OwoColorize;
8use rootcause::prelude::ResultExt;
9use rootcause::report;
10use strum::EnumIter;
11use strum::IntoEnumIterator;
12
13const SERVER_EXECUTABLE: &str = "muxr-server";
14
15#[derive(Clone, Debug, Eq, PartialEq)]
16enum Cmd {
17    Help,
18    Sessions,
19    Start {
20        session: SessionName,
21        external_layout: Option<PathBuf>,
22    },
23}
24
25#[derive(Clone, Copy, Debug, EnumIter, Eq, PartialEq)]
26enum SessionAction {
27    Attach,
28    Delete,
29}
30
31impl Cmd {
32    /// Parse muxr CLI arguments.
33    ///
34    /// # Errors
35    /// - The cmd is unknown, has unexpected extra arguments, or uses an invalid session name.
36    fn parse(args: &[String]) -> rootcause::Result<Self> {
37        if args.iter().any(|arg| arg == "--help") {
38            return Ok(Self::Help);
39        }
40
41        match args {
42            [] => Ok(Self::Sessions),
43            [cmd, rest @ ..] if cmd == "start" => Self::parse_start(rest),
44            [cmd, ..] => Err(report!("unknown muxr cmd {cmd:?}")),
45        }
46    }
47
48    fn parse_start(args: &[String]) -> rootcause::Result<Self> {
49        match args {
50            [] => Ok(Self::Start {
51                session: SessionName::default(),
52                external_layout: None,
53            }),
54            [layout_flag, layout] if layout_flag == EXTERNAL_LAYOUT_ARG => Ok(Self::Start {
55                session: SessionName::default(),
56                external_layout: Some(PathBuf::from(layout)),
57            }),
58            [layout_flag] if layout_flag == EXTERNAL_LAYOUT_ARG => {
59                Err(report!("missing muxr start layout").attach(format!("flag={EXTERNAL_LAYOUT_ARG}")))
60            }
61            [session] => Ok(Self::Start {
62                session: session.parse()?,
63                external_layout: None,
64            }),
65            [session, layout_flag, layout] if layout_flag == EXTERNAL_LAYOUT_ARG => Ok(Self::Start {
66                session: session.parse()?,
67                external_layout: Some(PathBuf::from(layout)),
68            }),
69            [session, layout_flag] if layout_flag == EXTERNAL_LAYOUT_ARG => {
70                Err(report!("missing muxr start layout").attach(format!("session={session:?}")))
71            }
72            _ => Err(report!("unexpected muxr start args").attach(format!("args={args:?}"))),
73        }
74    }
75
76    /// Execute the muxr CLI cmd.
77    ///
78    /// # Errors
79    /// - The home state path cannot be resolved.
80    fn execute(self) -> rootcause::Result<()> {
81        match self {
82            Self::Help => print!("{}", include_str!("../help.txt")),
83            Self::Sessions => self::run_session_picker()?,
84            Self::Start {
85                session,
86                external_layout,
87            } => {
88                let current_exe = std::env::current_exe().context("failed to resolve muxr executable")?;
89                let server_executable = self::server_executable_next_to(&current_exe)?;
90                let external_layout = match external_layout {
91                    Some(path) if path.is_relative() => Some(
92                        std::env::current_dir()
93                            .context("failed to resolve muxr cwd")?
94                            .join(path),
95                    ),
96                    Some(path) => Some(path),
97                    None => None,
98                };
99                muxr_client::start(&session, &server_executable, external_layout.as_deref())?;
100            }
101        }
102
103        Ok(())
104    }
105}
106
107impl fmt::Display for SessionAction {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        match self {
110            Self::Attach => write!(f, "{}", "Attach".green().bold()),
111            Self::Delete => write!(f, "{}", "Delete".red().bold()),
112        }
113    }
114}
115
116#[ytil_sys::main]
117fn main() -> rootcause::Result<()> {
118    let args = ytil_sys::cli::get();
119    let cmd = Cmd::parse(&args);
120
121    match cmd {
122        Ok(cmd) => cmd.execute(),
123        Err(err) => {
124            print!("{}", include_str!("../help.txt"));
125            Err(err)
126        }
127    }
128}
129
130fn run_session_picker() -> rootcause::Result<()> {
131    let sessions = muxr_client::list_sessions()?;
132    if sessions.is_empty() {
133        let current_exe = std::env::current_exe().context("failed to resolve muxr executable")?;
134        let server_executable = self::server_executable_next_to(&current_exe)?;
135        muxr_client::start(&SessionName::default(), &server_executable, None)?;
136        return Ok(());
137    }
138
139    let Some(selected) = ytil_tui::minimal_multi_select(
140        sessions,
141        muxr_client::ListedSession::display_text,
142        muxr_client::ListedSession::search_text,
143    )?
144    else {
145        println!("No sessions selected");
146        return Ok(());
147    };
148
149    let Some(action) = ytil_tui::minimal_select::<SessionAction>(SessionAction::iter().collect())? else {
150        println!("No action selected");
151        return Ok(());
152    };
153
154    let sessions = selected
155        .iter()
156        .map(|session| session.name().clone())
157        .collect::<Vec<_>>();
158    self::execute_session_action(action, &sessions)
159}
160
161fn execute_session_action(action: SessionAction, selected: &[SessionName]) -> rootcause::Result<()> {
162    match action {
163        SessionAction::Attach => {
164            let session = ytil_tui::require_single(selected, "sessions")?;
165            let current_exe = std::env::current_exe().context("failed to resolve muxr executable")?;
166            let server_executable = self::server_executable_next_to(&current_exe)?;
167            muxr_client::start(session, &server_executable, None)
168        }
169        SessionAction::Delete => self::delete_selected_sessions(selected, muxr_client::delete_session),
170    }
171}
172
173fn delete_selected_sessions<F>(selected: &[SessionName], mut delete_session: F) -> rootcause::Result<()>
174where
175    F: FnMut(&SessionName) -> rootcause::Result<muxr_client::SessionDeleteOutcome>,
176{
177    let mut failures = Vec::new();
178
179    for session in selected {
180        match delete_session(session) {
181            Ok(outcome) => println!("{}", self::delete_session_message(session, outcome)),
182            Err(error) => {
183                // Batch delete must attempt every selected session so one corrupt entry cannot block cleanup.
184                eprintln!("{}", self::delete_session_failure_message(session, &error));
185                failures.push(format!("{session}: {error}"));
186            }
187        }
188    }
189
190    if failures.is_empty() {
191        Ok(())
192    } else {
193        Err(report!("failed to delete selected muxr sessions").attach(failures.join("\n")))
194    }
195}
196
197fn delete_session_message(session: &SessionName, outcome: muxr_client::SessionDeleteOutcome) -> String {
198    let deleted = format!("{}", "Deleted".red().bold());
199    match outcome {
200        muxr_client::SessionDeleteOutcome::LiveDeleted => {
201            format!("{deleted} {session}; stopped live server and removed state")
202        }
203        muxr_client::SessionDeleteOutcome::LiveVanishedForced => {
204            format!("{deleted} {session}; live server vanished, force-removed selected session files")
205        }
206        muxr_client::SessionDeleteOutcome::StoppedRemoved => {
207            format!("{deleted} {session}; removed stopped session state")
208        }
209        muxr_client::SessionDeleteOutcome::UnknownForced => {
210            format!("{deleted} {session}; force-removed unknown session files")
211        }
212    }
213}
214
215fn delete_session_failure_message(session: &SessionName, error: impl fmt::Display) -> String {
216    let failed = format!("{}", "Failed".red().bold());
217    format!("{failed} to delete session {session}: {error}")
218}
219
220fn server_executable_next_to(current_exe: &Path) -> rootcause::Result<PathBuf> {
221    let Some(parent) = current_exe.parent().filter(|parent| !parent.as_os_str().is_empty()) else {
222        return Err(
223            report!("muxr executable has no parent dir").attach(format!("executable={}", current_exe.display()))
224        );
225    };
226    // Keep the attached client and long-lived server as separate processes: `muxr` can link picker/UI-only CLI deps,
227    // while `muxr-server` keeps session state, PTYs, and scrollback memory attributable to the server runtime alone.
228    Ok(parent.join(SERVER_EXECUTABLE))
229}
230
231#[cfg(test)]
232mod tests {
233    use rstest::rstest;
234
235    use super::*;
236
237    #[rstest]
238    #[case::start_without_session(&["start"], "default", None)]
239    #[case::start_with_session(&["start", "work"], "work", None)]
240    #[case::start_default_with_layout(
241        &["start", "--layout", "../.config/muxr/layouts/work.json"],
242        "default",
243        Some("../.config/muxr/layouts/work.json")
244    )]
245    #[case::start_session_with_layout(
246        &["start", "work", "--layout", ".config/muxr/layouts/work.json"],
247        "work",
248        Some(".config/muxr/layouts/work.json")
249    )]
250    fn test_parse_when_start_args_vary_returns_start_cmd(
251        #[case] raw: &[&str],
252        #[case] expected_session: &str,
253        #[case] expected_layout: Option<&str>,
254    ) -> rootcause::Result<()> {
255        assert2::assert!(let Cmd::Start {
256            session,
257            external_layout,
258        } = Cmd::parse(&args(raw))?);
259        pretty_assertions::assert_eq!(session.as_ref(), expected_session);
260        pretty_assertions::assert_eq!(external_layout.as_deref().and_then(Path::to_str), expected_layout);
261        Ok(())
262    }
263
264    #[rstest]
265    #[case::help_arg(&["--help"])]
266    #[case::help_among_args(&["start", "--help"])]
267    fn test_parse_when_help_requested_returns_help(#[case] raw: &[&str]) -> rootcause::Result<()> {
268        pretty_assertions::assert_eq!(Cmd::parse(&args(raw))?, Cmd::Help);
269        Ok(())
270    }
271
272    #[test]
273    fn test_parse_when_no_args_returns_session_picker() -> rootcause::Result<()> {
274        pretty_assertions::assert_eq!(Cmd::parse(&args(&[]))?, Cmd::Sessions);
275        Ok(())
276    }
277
278    #[test]
279    fn test_server_executable_next_to_returns_sibling_without_checking_existence() -> rootcause::Result<()> {
280        let tempdir = tempfile::tempdir()?;
281        let muxr = tempdir.path().join("muxr");
282        let runner = tempdir.path().join(SERVER_EXECUTABLE);
283
284        pretty_assertions::assert_eq!(server_executable_next_to(&muxr)?, runner);
285        Ok(())
286    }
287
288    #[rstest]
289    #[case::start_extra_args(&["start", "work", "extra"])]
290    #[case::start_missing_layout(&["start", "--layout"])]
291    #[case::start_session_missing_layout(&["start", "work", "--layout"])]
292    #[case::start_layout_extra_args(&["start", "work", "--layout", "work", "extra"])]
293    #[case::old_memory_cmd(&["memory"])]
294    #[case::unknown_start_flag(&["start", "--bogus"])]
295    #[case::old_attach_cmd(&["attach"])]
296    #[case::old_detach_cmd(&["detach"])]
297    #[case::old_server_cmd(&["server", "work"])]
298    #[case::unknown_cmd(&["bogus"])]
299    fn test_parse_when_args_are_invalid_returns_error(#[case] raw: &[&str]) {
300        assert2::assert!(Cmd::parse(&args(raw)).is_err());
301    }
302
303    #[rstest]
304    #[case::attach(SessionAction::Attach, format!("{}", "Attach".green().bold()))]
305    #[case::delete(SessionAction::Delete, format!("{}", "Delete".red().bold()))]
306    fn test_session_action_display_when_action_varies_matches_zj_style(
307        #[case] action: SessionAction,
308        #[case] expected: String,
309    ) {
310        pretty_assertions::assert_eq!(action.to_string(), expected);
311    }
312
313    #[test]
314    fn test_delete_selected_sessions_when_one_delete_fails_still_attempts_all() -> rootcause::Result<()> {
315        let selected = ["ok", "bad", "later"]
316            .into_iter()
317            .map(str::parse)
318            .collect::<rootcause::Result<Vec<SessionName>>>()?;
319        let mut attempted = Vec::new();
320
321        let result = delete_selected_sessions(&selected, |session| {
322            attempted.push(session.to_string());
323            if session.as_ref() == "bad" {
324                Err(report!("delete failed"))
325            } else {
326                Ok(muxr_client::SessionDeleteOutcome::StoppedRemoved)
327            }
328        });
329
330        assert2::assert!(result.is_err());
331        pretty_assertions::assert_eq!(attempted, vec!["ok", "bad", "later"]);
332        Ok(())
333    }
334
335    #[test]
336    fn test_execute_session_action_when_attach_has_multiple_sessions_returns_error() -> rootcause::Result<()> {
337        let sessions = vec![listed_session("work")?, listed_session("notes")?];
338
339        let error = execute_session_action(SessionAction::Attach, &sessions)
340            .expect_err("expected attach multi-selection error");
341
342        assert2::assert!(error.to_string().contains("expected exactly one selection"));
343        Ok(())
344    }
345
346    #[rstest]
347    #[case::live_deleted(
348        muxr_client::SessionDeleteOutcome::LiveDeleted,
349        format!("{} work; stopped live server and removed state", "Deleted".red().bold())
350    )]
351    #[case::live_vanished_forced(
352        muxr_client::SessionDeleteOutcome::LiveVanishedForced,
353        format!("{} work; live server vanished, force-removed selected session files", "Deleted".red().bold())
354    )]
355    #[case::stopped_removed(
356        muxr_client::SessionDeleteOutcome::StoppedRemoved,
357        format!("{} work; removed stopped session state", "Deleted".red().bold())
358    )]
359    #[case::unknown_forced(
360        muxr_client::SessionDeleteOutcome::UnknownForced,
361        format!("{} work; force-removed unknown session files", "Deleted".red().bold())
362    )]
363    fn test_delete_session_message_when_outcome_varies_reports_behavior(
364        #[case] outcome: muxr_client::SessionDeleteOutcome,
365        #[case] expected: String,
366    ) -> rootcause::Result<()> {
367        pretty_assertions::assert_eq!(delete_session_message(&listed_session("work")?, outcome), expected);
368        Ok(())
369    }
370
371    #[test]
372    fn test_delete_session_failure_message_colors_failure_prefix() -> rootcause::Result<()> {
373        let error = report!("delete failed");
374        let message = delete_session_failure_message(&listed_session("work")?, &error);
375
376        assert2::assert!(message.starts_with(&format!("{} to delete session work:", "Failed".red().bold())));
377        assert2::assert!(message.contains("delete failed"));
378        Ok(())
379    }
380
381    fn args(raw: &[&str]) -> Vec<String> {
382        raw.iter().map(ToString::to_string).collect()
383    }
384
385    fn listed_session(raw: &str) -> rootcause::Result<SessionName> {
386        raw.parse()
387    }
388}