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 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 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(¤t_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(¤t_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(¤t_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 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 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}