Skip to main content

muxr_core/
server_runner.rs

1use std::ffi::OsStr;
2use std::ffi::OsString;
3use std::path::PathBuf;
4
5use rootcause::report;
6
7use crate::EXTERNAL_LAYOUT_ARG;
8use crate::SessionName;
9
10/// Private `muxr-server` argument contract shared by the public CLI, client spawn path, and server runner.
11#[derive(Clone, Debug, Eq, PartialEq)]
12pub struct ServerRunnerArgs {
13    /// Optional external layout file used only when creating a new session.
14    pub external_layout: Option<PathBuf>,
15    /// Session name owned by the server runner.
16    pub session: SessionName,
17}
18
19impl ServerRunnerArgs {
20    /// Parse the private server-runner argument contract used by the public `muxr` CLI.
21    ///
22    /// # Errors
23    /// - The invocation is missing its session name, has extra args, or has an invalid session name.
24    pub fn parse(args: &[OsString]) -> rootcause::Result<Self> {
25        let Some((session, rest)) = args.split_first() else {
26            return Err(report!("missing muxr server session"));
27        };
28        let session = self::parse_session_arg(session)?;
29        let mut external_layout = None;
30        let mut rest = rest.iter();
31
32        while let Some(flag) = rest.next() {
33            if flag == OsStr::new(EXTERNAL_LAYOUT_ARG) {
34                let Some(layout) = rest.next() else {
35                    return Err(report!("missing muxr server layout").attach(format!("session={session}")));
36                };
37                if external_layout.replace(PathBuf::from(layout.clone())).is_some() {
38                    return Err(report!("duplicate muxr server layout").attach(format!("session={session}")));
39                }
40            } else {
41                return Err(report!("unexpected muxr server args").attach(format!("args={args:?}")));
42            }
43        }
44
45        Ok(Self {
46            external_layout,
47            session,
48        })
49    }
50
51    /// Return argv for `muxr-server`; keep this symmetric with [`Self::parse`].
52    #[must_use]
53    pub fn argv(&self) -> Vec<OsString> {
54        let mut args = vec![OsString::from(self.session.as_ref())];
55        if let Some(external_layout) = &self.external_layout {
56            args.push(OsString::from(EXTERNAL_LAYOUT_ARG));
57            args.push(external_layout.as_os_str().to_owned());
58        }
59        args
60    }
61}
62
63fn parse_session_arg(raw: &OsStr) -> rootcause::Result<SessionName> {
64    let Some(raw) = raw.to_str() else {
65        return Err(report!("invalid muxr server session").attach("reason=session must be valid UTF-8"));
66    };
67    raw.parse()
68}
69
70#[cfg(test)]
71mod tests {
72    use std::ffi::OsStr;
73    use std::path::Path;
74
75    use rstest::rstest;
76    use test_that::prelude::*;
77
78    use super::*;
79
80    #[test]
81    fn test_parse_when_session_is_supplied_returns_session() -> rootcause::Result<()> {
82        let args = ServerRunnerArgs::parse(&args(&["work"]))?;
83
84        assert_that!(args.session.as_ref(), eq("work"));
85        assert_that!(args.external_layout, eq(None));
86        Ok(())
87    }
88
89    #[test]
90    fn test_parse_when_layout_is_supplied_returns_layout() -> rootcause::Result<()> {
91        let args = ServerRunnerArgs::parse(&args(&["work", "--layout", ".config/muxr/layouts/work.json"]))?;
92
93        assert_that!(args.session.as_ref(), eq("work"));
94        assert_that!(
95            args.external_layout.as_deref().and_then(Path::to_str),
96            eq(Some(".config/muxr/layouts/work.json"))
97        );
98        Ok(())
99    }
100
101    #[test]
102    fn test_argv_when_layout_is_supplied_returns_runner_args() -> rootcause::Result<()> {
103        let args = ServerRunnerArgs {
104            external_layout: Some(PathBuf::from(".config/muxr/layouts/work.json")),
105            session: "work".parse()?,
106        };
107
108        assert_that!(
109            args.argv(),
110            eq(vec![
111                OsString::from("work"),
112                OsString::from(EXTERNAL_LAYOUT_ARG),
113                OsStr::new(".config/muxr/layouts/work.json").to_owned()
114            ])
115        );
116        Ok(())
117    }
118
119    #[cfg(unix)]
120    #[test]
121    fn test_parse_when_layout_is_not_utf8_preserves_path_bytes() -> rootcause::Result<()> {
122        use std::os::unix::ffi::OsStrExt;
123        use std::os::unix::ffi::OsStringExt;
124
125        let raw_layout = OsString::from_vec(b"layout-\xFF.json".to_vec());
126        let parsed =
127            ServerRunnerArgs::parse(&[OsString::from("work"), OsString::from(EXTERNAL_LAYOUT_ARG), raw_layout])?;
128
129        assert_that!(parsed.session.as_ref(), eq("work"));
130        assert_that!(
131            parsed
132                .external_layout
133                .as_deref()
134                .map(Path::as_os_str)
135                .map(OsStr::as_bytes),
136            eq(Some(b"layout-\xFF.json".as_slice()))
137        );
138        Ok(())
139    }
140
141    #[rstest]
142    #[case::missing_session(&[])]
143    #[case::extra_args(&["work", "extra"])]
144    #[case::missing_layout(&["work", "--layout"])]
145    fn test_parse_when_args_are_invalid_returns_error(#[case] raw: &[&str]) {
146        assert_that!(ServerRunnerArgs::parse(&args(raw)), err(anything()));
147    }
148
149    fn args(raw: &[&str]) -> Vec<OsString> {
150        raw.iter().map(OsString::from).collect()
151    }
152}