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 use test_that::prelude::*;
235
236 use super::*;
237
238 #[rstest]
239 #[case::start_without_session(&["start"], "default", None)]
240 #[case::start_with_session(&["start", "work"], "work", None)]
241 #[case::start_default_with_layout(
242 &["start", "--layout", "../.config/muxr/layouts/work.json"],
243 "default",
244 Some("../.config/muxr/layouts/work.json")
245 )]
246 #[case::start_session_with_layout(
247 &["start", "work", "--layout", ".config/muxr/layouts/work.json"],
248 "work",
249 Some(".config/muxr/layouts/work.json")
250 )]
251 fn test_parse_when_start_args_vary_returns_start_cmd(
252 #[case] raw: &[&str],
253 #[case] expected_session: &str,
254 #[case] expected_layout: Option<&str>,
255 ) -> rootcause::Result<()> {
256 let parsed = Cmd::parse(&args(raw))?;
257 let Cmd::Start {
258 session,
259 external_layout,
260 } = parsed
261 else {
262 assert_that!(
263 parsed,
264 matches_pattern!(Cmd::Start {
265 session: anything(),
266 external_layout: anything()
267 })
268 );
269 return Ok(());
270 };
271 assert_that!(session.as_ref(), eq(expected_session));
272 assert_that!(external_layout.as_deref().and_then(Path::to_str), eq(expected_layout));
273 Ok(())
274 }
275
276 #[rstest]
277 #[case::help_arg(&["--help"])]
278 #[case::help_among_args(&["start", "--help"])]
279 fn test_parse_when_help_requested_returns_help(#[case] raw: &[&str]) -> rootcause::Result<()> {
280 assert_that!(Cmd::parse(&args(raw))?, eq(Cmd::Help));
281 Ok(())
282 }
283
284 #[test]
285 fn test_parse_when_no_args_returns_session_picker() -> rootcause::Result<()> {
286 assert_that!(Cmd::parse(&args(&[]))?, eq(Cmd::Sessions));
287 Ok(())
288 }
289
290 #[test]
291 fn test_server_executable_next_to_returns_sibling_without_checking_existence() -> rootcause::Result<()> {
292 let tempdir = tempfile::tempdir()?;
293 let muxr = tempdir.path().join("muxr");
294 let runner = tempdir.path().join(SERVER_EXECUTABLE);
295
296 assert_that!(server_executable_next_to(&muxr)?, eq(runner));
297 Ok(())
298 }
299
300 #[rstest]
301 #[case::start_extra_args(&["start", "work", "extra"])]
302 #[case::start_missing_layout(&["start", "--layout"])]
303 #[case::start_session_missing_layout(&["start", "work", "--layout"])]
304 #[case::start_layout_extra_args(&["start", "work", "--layout", "work", "extra"])]
305 #[case::old_memory_cmd(&["memory"])]
306 #[case::unknown_start_flag(&["start", "--bogus"])]
307 #[case::old_attach_cmd(&["attach"])]
308 #[case::old_detach_cmd(&["detach"])]
309 #[case::old_server_cmd(&["server", "work"])]
310 #[case::unknown_cmd(&["bogus"])]
311 fn test_parse_when_args_are_invalid_returns_error(#[case] raw: &[&str]) {
312 assert_that!(Cmd::parse(&args(raw)), err(anything()));
313 }
314
315 #[rstest]
316 #[case::attach(SessionAction::Attach, format!("{}", "Attach".green().bold()))]
317 #[case::delete(SessionAction::Delete, format!("{}", "Delete".red().bold()))]
318 fn test_session_action_display_when_action_varies_matches_zj_style(
319 #[case] action: SessionAction,
320 #[case] expected: String,
321 ) {
322 assert_that!(action.to_string(), eq(expected));
323 }
324
325 #[test]
326 fn test_delete_selected_sessions_when_one_delete_fails_still_attempts_all() -> rootcause::Result<()> {
327 let selected = ["ok", "bad", "later"]
328 .into_iter()
329 .map(str::parse)
330 .collect::<rootcause::Result<Vec<SessionName>>>()?;
331 let mut attempted = Vec::new();
332
333 let result = delete_selected_sessions(&selected, |session| {
334 attempted.push(session.to_string());
335 if session.as_ref() == "bad" {
336 Err(report!("delete failed"))
337 } else {
338 Ok(muxr_client::SessionDeleteOutcome::StoppedRemoved)
339 }
340 });
341
342 assert_that!(result, err(anything()));
343 assert_that!(attempted, eq(vec!["ok", "bad", "later"]));
344 Ok(())
345 }
346
347 #[test]
348 fn test_execute_session_action_when_attach_has_multiple_sessions_returns_error() -> rootcause::Result<()> {
349 let sessions = vec![listed_session("work")?, listed_session("notes")?];
350
351 let error = execute_session_action(SessionAction::Attach, &sessions)
352 .expect_err("expected attach multi-selection error");
353
354 assert_that!(error.to_string(), contains_substring("expected exactly one selection"));
355 Ok(())
356 }
357
358 #[rstest]
359 #[case::live_deleted(
360 muxr_client::SessionDeleteOutcome::LiveDeleted,
361 format!("{} work; stopped live server and removed state", "Deleted".red().bold())
362 )]
363 #[case::live_vanished_forced(
364 muxr_client::SessionDeleteOutcome::LiveVanishedForced,
365 format!("{} work; live server vanished, force-removed selected session files", "Deleted".red().bold())
366 )]
367 #[case::stopped_removed(
368 muxr_client::SessionDeleteOutcome::StoppedRemoved,
369 format!("{} work; removed stopped session state", "Deleted".red().bold())
370 )]
371 #[case::unknown_forced(
372 muxr_client::SessionDeleteOutcome::UnknownForced,
373 format!("{} work; force-removed unknown session files", "Deleted".red().bold())
374 )]
375 fn test_delete_session_message_when_outcome_varies_reports_behavior(
376 #[case] outcome: muxr_client::SessionDeleteOutcome,
377 #[case] expected: String,
378 ) -> rootcause::Result<()> {
379 assert_that!(delete_session_message(&listed_session("work")?, outcome), eq(expected));
380 Ok(())
381 }
382
383 #[test]
384 fn test_delete_session_failure_message_colors_failure_prefix() -> rootcause::Result<()> {
385 let error = report!("delete failed");
386 let message = delete_session_failure_message(&listed_session("work")?, &error);
387
388 assert_that!(
389 message,
390 starts_with(format!("{} to delete session work:", "Failed".red().bold()))
391 );
392 assert_that!(message, contains_substring("delete failed"));
393 Ok(())
394 }
395
396 fn args(raw: &[&str]) -> Vec<String> {
397 raw.iter().map(ToString::to_string).collect()
398 }
399
400 fn listed_session(raw: &str) -> rootcause::Result<SessionName> {
401 raw.parse()
402 }
403}