Skip to main content

unlit3d_examples/
cli.rs

1//! The example's command line.
2//!
3//! The flags are declared with [`argh`], which derives the parser and the
4//! `--help` text from the [`Args`] struct. A value with a shape of its own — a
5//! `WxH` size — is parsed by the function [`Args`] names through `from_str_fn`,
6//! so the rules live next to the option they constrain.
7//!
8//! [`Args::validate`] holds the checks that span more than one option: here,
9//! a scene the example does not know.
10//!
11//! The scenes' snapshots are not compared through this command line. That is a
12//! test's job, and it lives in `tests/gpu_suite.rs`, where the same bodies run
13//! natively and in a browser.
14
15use argh::FromArgs;
16
17use crate::scenes;
18
19/// The program name argh puts in its usage and error messages.
20const PROGRAM: &str = "unlit3d-examples";
21
22/// The scene `--scene` starts from.
23fn default_scene() -> String {
24    scenes::default().id.to_owned()
25}
26
27/// Every option the example accepts.
28#[derive(FromArgs, Debug, Clone, PartialEq)]
29#[argh(
30    help_triggers("-h", "--help"),
31    description = "a windowed example with selectable scenes and an egui overlay"
32)]
33pub struct Args {
34    /// the scene to start from
35    #[argh(option, default = "default_scene()")]
36    pub scene: String,
37
38    /// print the scene table and exit
39    #[argh(switch)]
40    pub list_scenes: bool,
41
42    /// initial window size in pixels, as `WxH`; the scene's own when omitted
43    #[argh(option, from_str_fn(parse_size))]
44    pub size: Option<(u32, u32)>,
45
46    /// serve the scene over the Model Context Protocol on standard input and
47    /// output, showing the frames it renders in the window
48    #[argh(switch)]
49    pub mcp: bool,
50}
51
52impl Default for Args {
53    fn default() -> Self {
54        Self {
55            scene: default_scene(),
56            list_scenes: false,
57            size: None,
58            mcp: false,
59        }
60    }
61}
62
63/// What the command line asked the example to do.
64#[derive(Debug, Clone, PartialEq)]
65pub enum Parsed {
66    /// Run with these options.
67    Run(Args),
68    /// `--help` was given: print `String`, which argh generated from [`Args`].
69    Help(String),
70}
71
72/// Parse the arguments after the program name.
73pub fn parse<I>(args: I) -> Result<Parsed, String>
74where
75    I: IntoIterator,
76    I::Item: Into<String>,
77{
78    let args: Vec<String> = args.into_iter().map(Into::into).collect();
79    let refs: Vec<&str> = args.iter().map(String::as_str).collect();
80
81    match Args::from_args(&[PROGRAM], &refs) {
82        Ok(parsed) => {
83            parsed.validate()?;
84            Ok(Parsed::Run(parsed))
85        }
86        // argh reports `--help` as an early exit whose status is `Ok`, and a
87        // failed parse as one whose status is `Err`; only the message is
88        // useful either way.
89        Err(exit) if exit.status.is_ok() => Ok(Parsed::Help(usage())),
90        Err(exit) => Err(exit.output),
91    }
92}
93
94/// The `--help` text: argh's option list followed by the scene table.
95///
96/// argh derives the option text from [`Args`] but knows nothing about the
97/// scenes, so the table is appended here rather than restated by hand.
98pub fn usage() -> String {
99    let options = Args::from_args(&[PROGRAM], &["--help"])
100        .expect_err("`--help` exits early")
101        .output;
102    format!("{options}\n{}", scenes::list_text())
103}
104
105impl Args {
106    /// The checks that span more than one option.
107    ///
108    /// argh has already parsed each option on its own; this is the part only
109    /// the combination can decide.
110    fn validate(&self) -> Result<(), String> {
111        // Serving MCP still needs a scene to build, so the check stands whether
112        // the example opens a window or not.
113        // A scene the example does not know is a typo worth reporting.
114        if scenes::by_id(&self.scene).is_none() {
115            return Err(format!(
116                "unknown scene `{}`\n{}",
117                self.scene,
118                scenes::list_text()
119            ));
120        }
121
122        Ok(())
123    }
124}
125
126/// Parse a `WxH` size, both dimensions at least one pixel.
127fn parse_size(text: &str) -> Result<(u32, u32), String> {
128    let invalid = || format!("`{text}` is not a `WxH` size");
129    let (width, height) = text.split_once(['x', 'X']).ok_or_else(invalid)?;
130    let width: u32 = width.trim().parse().map_err(|_| invalid())?;
131    let height: u32 = height.trim().parse().map_err(|_| invalid())?;
132    if width == 0 || height == 0 {
133        return Err(format!("`{text}` has a zero dimension"));
134    }
135    Ok((width, height))
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    /// The options of a successful parse that was not `--help`.
143    fn run(args: &[&str]) -> Result<Args, String> {
144        match parse(args.iter().copied())? {
145            Parsed::Run(parsed) => Ok(parsed),
146            Parsed::Help(_) => panic!("expected options, got --help"),
147        }
148    }
149
150    #[test]
151    fn no_arguments_start_the_default_scene_at_its_own_size() {
152        let args = run(&[]).expect("no arguments parse");
153        assert_eq!(args.scene, scenes::default().id);
154        assert!(!args.list_scenes);
155        assert_eq!(args.size, None);
156    }
157
158    #[test]
159    fn the_window_options_are_read() {
160        let args = run(&["--scene", "skinned", "--size", "320x240", "--list-scenes"])
161            .expect("the options parse");
162        assert_eq!(args.scene, "skinned");
163        assert_eq!(args.size, Some((320, 240)));
164        assert!(args.list_scenes);
165        assert!(!args.mcp);
166    }
167
168    #[test]
169    fn the_mcp_switch_is_read() {
170        let args = run(&["--mcp"]).expect("the switch parses");
171        assert!(args.mcp);
172        assert_eq!(args.scene, scenes::default().id);
173    }
174
175    #[test]
176    fn help_is_reported_as_its_own_outcome() {
177        let help = match parse(["--help"]).expect("`--help` parses") {
178            Parsed::Help(text) => text,
179            Parsed::Run(_) => panic!("`--help` must not run"),
180        };
181        assert!(help.contains("--scene"), "help names the options: {help}");
182        assert!(parse(["-h"]).is_ok(), "`-h` is a help trigger too");
183    }
184
185    #[test]
186    fn an_unknown_scene_is_rejected() {
187        assert!(run(&["--scene", "nonsense"]).is_err());
188    }
189
190    #[test]
191    fn malformed_values_are_rejected() {
192        assert!(run(&["--size", "wide"]).is_err());
193        assert!(run(&["--size", "0x240"]).is_err());
194        assert!(run(&["--size"]).is_err());
195        assert!(run(&["--nonsense"]).is_err());
196    }
197}