cargo_3ds/
command.rs

1use std::fs;
2use std::io::Read;
3use std::process::{self, Stdio};
4use std::sync::OnceLock;
5
6use cargo_metadata::{Message, Metadata};
7use clap::{Args, Parser, Subcommand};
8
9use crate::{CTRConfig, build_3dsx, cargo, get_artifact_config, link, print_command};
10
11#[derive(Parser, Debug)]
12#[command(name = "cargo", bin_name = "cargo")]
13pub enum Cargo {
14    #[command(name = "3ds")]
15    Input(Input),
16}
17
18#[derive(Args, Debug)]
19#[command(version, about)]
20pub struct Input {
21    #[command(subcommand)]
22    pub cmd: CargoCmd,
23
24    /// Print the exact commands `cargo-3ds` is running. Note that this does not
25    /// set the verbose flag for cargo itself. To set cargo's verbosity flag, add
26    /// `-- -v` to the end of the command line.
27    #[arg(long, short = 'v', global = true)]
28    pub verbose: bool,
29
30    /// Set cargo configuration on the command line. This is equivalent to
31    /// cargo's `--config` option.
32    #[arg(long, global = true)]
33    pub config: Vec<String>,
34}
35
36/// Run a cargo command. COMMAND will be forwarded to the real
37/// `cargo` with the appropriate arguments for the 3DS target.
38///
39/// If an unrecognized COMMAND is used, it will be passed through unmodified
40/// to `cargo` with the appropriate flags set for the 3DS target.
41#[derive(Subcommand, Debug)]
42#[command(allow_external_subcommands = true)]
43pub enum CargoCmd {
44    /// Builds an executable suitable to run on a 3DS (3dsx).
45    Build(Build),
46
47    /// Builds an executable and sends it to a device with `3dslink`.
48    Run(Run),
49
50    /// Builds a test executable and sends it to a device with `3dslink`.
51    ///
52    /// This can be used with `--test` for integration tests, or `--lib` for
53    /// unit tests (which require a custom test runner).
54    Test(Test),
55
56    /// Sets up a new cargo project suitable to run on a 3DS.
57    New(New),
58
59    // NOTE: it seems docstring + name for external subcommands are not rendered
60    // in help, but we might as well set them here in case a future version of clap
61    // does include them in help text.
62    /// Run any other `cargo` command with custom building tailored for the 3DS.
63    #[command(external_subcommand, name = "COMMAND")]
64    Passthrough(Vec<String>),
65}
66
67#[derive(Args, Debug)]
68pub struct RemainingArgs {
69    /// Pass additional options through to the `cargo` command.
70    ///
71    /// All arguments after the first `--`, or starting with the first unrecognized
72    /// option, will be passed through to `cargo` unmodified.
73    ///
74    /// To pass arguments to an executable being run, a *second* `--` must be
75    /// used to disambiguate cargo arguments from executable arguments.
76    /// For example, `cargo 3ds run -- -- xyz` runs an executable with the argument
77    /// `xyz`.
78    #[arg(
79        trailing_var_arg = true,
80        allow_hyphen_values = true,
81        value_name = "CARGO_ARGS"
82    )]
83    args: Vec<String>,
84}
85
86#[allow(unused_variables)]
87trait Callbacks {
88    fn build_callback(&self, config: &CTRConfig) {}
89    fn run_callback(&self, config: &CTRConfig) {}
90}
91
92#[derive(Args, Debug)]
93pub struct Build {
94    #[arg(from_global)]
95    pub verbose: bool,
96
97    // Passthrough cargo options.
98    #[command(flatten)]
99    pub passthrough: RemainingArgs,
100}
101
102#[derive(Args, Debug)]
103pub struct Run {
104    /// Specify the IP address of the device to send the executable to.
105    ///
106    /// Corresponds to 3dslink's `--address` arg, which defaults to automatically
107    /// finding the device.
108    #[arg(long, short = 'a')]
109    pub address: Option<std::net::Ipv4Addr>,
110
111    /// Set the 0th argument of the executable when running it. Corresponds to
112    /// 3dslink's `--argv0` argument.
113    #[arg(long, short = '0')]
114    pub argv0: Option<String>,
115
116    /// Start the 3dslink server after sending the executable. Corresponds to
117    /// 3dslink's `--server` argument.
118    #[arg(long, short = 's', default_value_t = false)]
119    pub server: bool,
120
121    /// Set the number of tries when connecting to the device to send the executable.
122    /// Corresponds to 3dslink's `--retries` argument.
123    // Can't use `short = 'r'` because that would conflict with cargo's `--release/-r`
124    #[arg(long)]
125    pub retries: Option<usize>,
126
127    // Passthrough `cargo build` options.
128    #[command(flatten)]
129    pub build_args: Build,
130
131    #[arg(from_global)]
132    config: Vec<String>,
133}
134
135#[derive(Args, Debug)]
136pub struct Test {
137    /// If set, the built executable will not be sent to the device to run it.
138    #[arg(long)]
139    pub no_run: bool,
140
141    /// If set, documentation tests will be built instead of unit tests.
142    /// This implies `--no-run`, unless Cargo's `target.armv6k-nintendo-3ds.runner`
143    /// is configured.
144    #[arg(long)]
145    pub doc: bool,
146
147    // The test command uses a superset of the same arguments as Run.
148    #[command(flatten)]
149    pub run_args: Run,
150}
151
152#[derive(Args, Debug)]
153pub struct New {
154    /// Path of the new project.
155    #[arg(required = true)]
156    pub path: String,
157
158    // The test command uses a superset of the same arguments as Run.
159    #[command(flatten)]
160    pub cargo_args: RemainingArgs,
161}
162
163impl CargoCmd {
164    /// Returns the additional arguments run by the "official" cargo subcommand.
165    pub(crate) fn cargo_args(&self) -> Vec<String> {
166        match self {
167            CargoCmd::Build(build) => build.passthrough.cargo_args(),
168            CargoCmd::Run(run) => run.build_args.passthrough.cargo_args(),
169            CargoCmd::Test(test) => test.cargo_args(),
170            CargoCmd::New(new) => {
171                // We push the original path in the new command (we captured it in [`New`] to learn about the context)
172                let mut cargo_args = new.cargo_args.cargo_args();
173                cargo_args.push(new.path.clone());
174
175                cargo_args
176            }
177            CargoCmd::Passthrough(other) => other.clone().split_off(1),
178        }
179    }
180
181    /// Returns the cargo subcommand run by `cargo-3ds` when handling a [`CargoCmd`].
182    ///
183    /// # Notes
184    ///
185    /// This is not equivalent to the lowercase name of the [`CargoCmd`] variant.
186    /// Commands may use different commands under the hood to function (e.g. [`CargoCmd::Run`] uses `build`
187    /// if no custom runner is configured).
188    pub(crate) fn subcommand_name(&self) -> &str {
189        match self {
190            CargoCmd::Build(_) => "build",
191            CargoCmd::Run(run) => {
192                if run.use_custom_runner() {
193                    "run"
194                } else {
195                    "build"
196                }
197            }
198            CargoCmd::Test(_) => "test",
199            CargoCmd::New(_) => "new",
200            CargoCmd::Passthrough(cmd) => &cmd[0],
201        }
202    }
203
204    /// Whether or not this command should compile any code, and thus needs import the custom environment configuration (e.g. target spec).
205    pub(crate) fn should_compile(&self) -> bool {
206        matches!(
207            self,
208            Self::Build(_) | Self::Run(_) | Self::Test(_) | Self::Passthrough(_)
209        )
210    }
211
212    /// Whether or not this command should build a 3DSX executable file.
213    pub fn should_build_3dsx(&self) -> bool {
214        match self {
215            Self::Build(_) | CargoCmd::Run(_) => true,
216            &Self::Test(Test { doc, .. }) => {
217                if doc {
218                    eprintln!("Documentation tests requested, no 3dsx will be built");
219                    false
220                } else {
221                    true
222                }
223            }
224            _ => false,
225        }
226    }
227
228    pub const DEFAULT_MESSAGE_FORMAT: &'static str = "json-render-diagnostics";
229
230    pub fn extract_message_format(&mut self) -> Result<Option<String>, String> {
231        let cargo_args = match self {
232            Self::Build(build) => &mut build.passthrough.args,
233            Self::Run(run) => &mut run.build_args.passthrough.args,
234            Self::New(new) => &mut new.cargo_args.args,
235            Self::Test(test) => &mut test.run_args.build_args.passthrough.args,
236            Self::Passthrough(args) => args,
237        };
238
239        let format = Self::extract_message_format_from_args(cargo_args)?;
240        if format.is_some() {
241            return Ok(format);
242        }
243
244        if let Self::Test(Test { doc: true, .. }) = self {
245            // We don't care about JSON output for doctests since we're not
246            // building any 3dsx etc. Just use the default output as it's more
247            // readable compared to DEFAULT_MESSAGE_FORMAT
248            Ok(Some(String::from("human")))
249        } else {
250            Ok(None)
251        }
252    }
253
254    fn extract_message_format_from_args(
255        cargo_args: &mut Vec<String>,
256    ) -> Result<Option<String>, String> {
257        // Checks for a position within the args where '--message-format' is located
258        if let Some(pos) = cargo_args
259            .iter()
260            .position(|s| s.starts_with("--message-format"))
261        {
262            // Remove the arg from list so we don't pass anything twice by accident
263            let arg = cargo_args.remove(pos);
264
265            // Allows for usage of '--message-format=<format>' and also using space separation.
266            // Check for a '=' delimiter and use the second half of the split as the format,
267            // otherwise remove next arg which is now at the same position as the original flag.
268            let format = if let Some((_, format)) = arg.split_once('=') {
269                format.to_string()
270            } else {
271                // Also need to remove the argument to the --message-format option
272                cargo_args.remove(pos)
273            };
274
275            // Non-json formats are not supported so the executable exits.
276            if format.starts_with("json") {
277                Ok(Some(format))
278            } else {
279                Err(String::from(
280                    "error: non-JSON `message-format` is not supported",
281                ))
282            }
283        } else {
284            Ok(None)
285        }
286    }
287
288    /// Runs the custom callback *after* the cargo command, depending on the type of command launched.
289    ///
290    /// # Examples
291    ///
292    /// - `cargo 3ds build` and other "build" commands will use their callbacks to build the final `.3dsx` file and link it.
293    /// - `cargo 3ds new` and other generic commands will use their callbacks to make 3ds-specific changes to the environment.
294    pub fn run_callbacks(&self, messages: &[Message], metadata: Option<&Metadata>) {
295        let configs = metadata
296            .map(|metadata| self.build_callbacks(messages, metadata))
297            .unwrap_or_default();
298
299        let config = match self {
300            // If we produced one executable, we will attempt to run that one
301            _ if configs.len() == 1 => configs.into_iter().next().unwrap(),
302
303            // --no-run may produce any number of executables, and we skip the callback
304            Self::Test(Test { no_run: true, .. }) => return,
305
306            // If using custom runners, they may be able to handle multiple executables,
307            // and we also want to skip our own callback. `cargo run` also has its own
308            // logic to disallow multiple executables.
309            Self::Test(Test { run_args: run, .. }) | Self::Run(run) if run.use_custom_runner() => {
310                return;
311            }
312
313            // Config is ignored by the New callback, using default is fine.
314            Self::New(_) => CTRConfig::default(),
315
316            // Otherwise (configs.len() != 1) print an error and exit
317            Self::Test(_) | Self::Run(_) => {
318                let paths: Vec<_> = configs.into_iter().map(|c| c.path_3dsx()).collect();
319                let names: Vec<_> = paths.iter().filter_map(|p| p.file_name()).collect();
320                eprintln!(
321                    "Error: expected exactly one (1) executable to run, got {}: {names:?}",
322                    paths.len(),
323                );
324                process::exit(1);
325            }
326
327            _ => return,
328        };
329
330        self.run_callback(&config);
331    }
332
333    /// Generate a .3dsx for every executable artifact within the workspace that
334    /// was built by the cargo command.
335    fn build_callbacks(&self, messages: &[Message], metadata: &Metadata) -> Vec<CTRConfig> {
336        let max_artifact_count = metadata.packages.iter().map(|pkg| pkg.targets.len()).sum();
337        let mut configs = Vec::with_capacity(max_artifact_count);
338
339        for message in messages {
340            let Message::CompilerArtifact(artifact) = message else {
341                continue;
342            };
343
344            if artifact.executable.is_none()
345                || !metadata.workspace_members.contains(&artifact.package_id)
346            {
347                continue;
348            }
349
350            let package = &metadata[&artifact.package_id];
351            let config = get_artifact_config(package.clone(), artifact.clone());
352
353            self.build_callback(&config);
354
355            configs.push(config);
356        }
357
358        configs
359    }
360
361    fn inner_callback(&self) -> Option<&dyn Callbacks> {
362        match self {
363            Self::Build(cmd) => Some(cmd),
364            Self::Run(cmd) => Some(cmd),
365            Self::Test(cmd) => Some(cmd),
366            Self::New(cmd) => Some(cmd),
367            _ => None,
368        }
369    }
370}
371
372impl Callbacks for CargoCmd {
373    fn build_callback(&self, config: &CTRConfig) {
374        if let Some(cb) = self.inner_callback() {
375            cb.build_callback(config);
376        }
377    }
378
379    fn run_callback(&self, config: &CTRConfig) {
380        if let Some(cb) = self.inner_callback() {
381            cb.run_callback(config);
382        }
383    }
384}
385
386impl RemainingArgs {
387    /// Get the args to be passed to `cargo`.
388    pub(crate) fn cargo_args(&self) -> Vec<String> {
389        self.split_args().0
390    }
391
392    /// Get the args to be passed to the executable itself (not `cargo`).
393    pub(crate) fn exe_args(&self) -> Vec<String> {
394        self.split_args().1
395    }
396
397    fn split_args(&self) -> (Vec<String>, Vec<String>) {
398        let mut args = self.args.clone();
399
400        if let Some(split) = args.iter().position(|s| s == "--") {
401            let second_half = args.split_off(split + 1);
402            // take off the "--" arg we found, we'll add one later if needed
403            args.pop();
404
405            (args, second_half)
406        } else {
407            (args, Vec::new())
408        }
409    }
410}
411
412impl Callbacks for Build {
413    /// Callback for `cargo 3ds build`.
414    ///
415    /// This callback handles building the application as a `.3dsx` file.
416    fn build_callback(&self, config: &CTRConfig) {
417        eprintln!("Building smdh: {}", config.path_smdh());
418        config.build_smdh(self.verbose);
419
420        eprintln!("Building 3dsx: {}", config.path_3dsx());
421        build_3dsx(config, self.verbose);
422    }
423}
424
425impl Callbacks for Run {
426    fn build_callback(&self, config: &CTRConfig) {
427        self.build_args.build_callback(config);
428    }
429
430    /// Callback for `cargo 3ds run`.
431    ///
432    /// This callback handles launching the application via `3dslink`.
433    fn run_callback(&self, config: &CTRConfig) {
434        if !self.use_custom_runner() {
435            eprintln!("Running 3dslink");
436            link(config, self, self.build_args.verbose);
437        }
438    }
439}
440
441impl Run {
442    /// Get the args to pass to `3dslink` based on these options.
443    pub(crate) fn get_3dslink_args(&self) -> Vec<String> {
444        let mut args = Vec::new();
445
446        if let Some(address) = self.address {
447            args.extend(["--address".to_string(), address.to_string()]);
448        }
449
450        if let Some(argv0) = &self.argv0 {
451            args.extend(["--arg0".to_string(), argv0.clone()]);
452        }
453
454        if let Some(retries) = self.retries {
455            args.extend(["--retries".to_string(), retries.to_string()]);
456        }
457
458        if self.server {
459            args.push("--server".to_string());
460        }
461
462        let exe_args = self.build_args.passthrough.exe_args();
463        if !exe_args.is_empty() {
464            // For some reason 3dslink seems to want 2 instances of `--`, one
465            // in front of all of the args like this...
466            args.extend(["--args".to_string(), "--".to_string()]);
467
468            let mut escaped = false;
469            for arg in exe_args.iter().cloned() {
470                if arg.starts_with('-') && !escaped {
471                    // And one before the first `-` arg that is passed in.
472                    args.extend(["--".to_string(), arg]);
473                    escaped = true;
474                } else {
475                    args.push(arg);
476                }
477            }
478        }
479
480        args
481    }
482
483    /// Returns whether the cargo environment has `target.armv6k-nintendo-3ds.runner`
484    /// configured. This will only be checked once during the lifetime of the program,
485    /// and takes into account the usual ways Cargo looks for its
486    /// [configuration](https://doc.rust-lang.org/cargo/reference/config.html):
487    ///
488    /// - `.cargo/config.toml`
489    /// - Environment variables
490    /// - Command-line `--config` overrides
491    pub(crate) fn use_custom_runner(&self) -> bool {
492        static HAS_RUNNER: OnceLock<bool> = OnceLock::new();
493
494        let &custom_runner_configured = HAS_RUNNER.get_or_init(|| {
495            let mut cmd = cargo(&self.config);
496            cmd.args([
497                // https://github.com/rust-lang/cargo/issues/9301
498                "-Z",
499                "unstable-options",
500                "config",
501                "get",
502                "target.armv6k-nintendo-3ds.runner",
503            ])
504            .stdout(Stdio::null())
505            .stderr(Stdio::null());
506
507            if self.build_args.verbose {
508                print_command(&cmd);
509            }
510
511            // `cargo config get` exits zero if the config exists, or nonzero otherwise
512            cmd.status().is_ok_and(|status| status.success())
513        });
514
515        if self.build_args.verbose {
516            eprintln!(
517                "Custom runner is {}configured",
518                if custom_runner_configured { "" } else { "not " }
519            );
520        }
521
522        custom_runner_configured
523    }
524}
525
526impl Callbacks for Test {
527    fn build_callback(&self, config: &CTRConfig) {
528        self.run_args.build_callback(config);
529    }
530
531    /// Callback for `cargo 3ds test`.
532    ///
533    /// This callback handles launching the application via `3dslink`.
534    fn run_callback(&self, config: &CTRConfig) {
535        if !self.no_run {
536            self.run_args.run_callback(config);
537        }
538    }
539}
540
541impl Test {
542    fn should_run(&self) -> bool {
543        self.run_args.use_custom_runner() && !self.no_run
544    }
545
546    /// The args to pass to the underlying `cargo test` command.
547    fn cargo_args(&self) -> Vec<String> {
548        let mut cargo_args = self.run_args.build_args.passthrough.cargo_args();
549
550        // We can't run 3DS executables on the host, but we want to respect
551        // the user's "runner" configuration if set.
552        //
553        // If doctests were requested, `--no-run` will be rejected on the
554        // command line and must be set with RUSTDOCFLAGS instead:
555        // https://github.com/rust-lang/rust/issues/87022
556
557        if self.doc {
558            cargo_args.extend([
559                "--doc".into(),
560                // https://github.com/rust-lang/cargo/issues/7040
561                "-Z".into(),
562                "doctest-xcompile".into(),
563            ]);
564        } else if !self.should_run() {
565            cargo_args.push("--no-run".into());
566        }
567
568        cargo_args
569    }
570
571    /// Flags to pass to rustdoc via RUSTDOCFLAGS
572    pub(crate) fn rustdocflags(&self) -> &'static str {
573        if self.should_run() {
574            ""
575        } else {
576            // We don't support running doctests by default, but cargo doesn't like
577            // --no-run for doctests, so we have to plumb it in via RUSTDOCFLAGS
578            " --no-run"
579        }
580    }
581}
582
583const TOML_CHANGES: &str = r#"ctru-rs = { git = "https://github.com/rust3ds/ctru-rs" }
584
585[package.metadata.cargo-3ds]
586romfs_dir = "romfs"
587"#;
588
589const CUSTOM_MAIN_RS: &str = r#"use ctru::prelude::*;
590
591fn main() {
592    let apt = Apt::new().unwrap();
593    let mut hid = Hid::new().unwrap();
594    let gfx = Gfx::new().unwrap();
595    let _console = Console::new(gfx.top_screen.borrow_mut());
596
597    println!("Hello, World!");
598    println!("\x1b[29;16HPress Start to exit");
599
600    while apt.main_loop() {
601        gfx.wait_for_vblank();
602
603        hid.scan_input();
604        if hid.keys_down().contains(KeyPad::START) {
605            break;
606        }
607    }
608}
609"#;
610
611impl Callbacks for New {
612    /// Callback for `cargo 3ds new`.
613    ///
614    /// This callback handles the custom environment modifications when creating a new 3DS project.
615    fn run_callback(&self, _: &CTRConfig) {
616        // Commmit changes to the project only if is meant to be a binary
617        if self.cargo_args.args.contains(&"--lib".to_string()) {
618            return;
619        }
620
621        // Attain a canonicalised path for the new project and it's TOML manifest
622        let project_path = fs::canonicalize(&self.path).unwrap();
623        let toml_path = project_path.join("Cargo.toml");
624        let romfs_path = project_path.join("romfs");
625        let main_rs_path = project_path.join("src/main.rs");
626        let dummy_romfs_path = romfs_path.join("PUT_YOUR_ROMFS_FILES_HERE.txt");
627
628        // Create the "romfs" directory, and place a dummy file within it.
629        fs::create_dir(romfs_path).unwrap();
630        fs::File::create(dummy_romfs_path).unwrap();
631
632        // Read the contents of `Cargo.toml` to a string
633        let mut buf = String::new();
634        fs::File::open(&toml_path)
635            .unwrap()
636            .read_to_string(&mut buf)
637            .unwrap();
638
639        // Add the custom changes to the TOML
640        let buf = buf + TOML_CHANGES;
641        fs::write(&toml_path, buf).unwrap();
642
643        // Add the custom changes to the main.rs file
644        fs::write(main_rs_path, CUSTOM_MAIN_RS).unwrap();
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use clap::CommandFactory;
651
652    use super::*;
653
654    #[test]
655    fn verify_app() {
656        Cargo::command().debug_assert();
657    }
658
659    #[test]
660    fn extract_format() {
661        const CASES: &[(&[&str], Option<&str>)] = &[
662            (&["--foo", "--message-format=json", "bar"], Some("json")),
663            (&["--foo", "--message-format", "json", "bar"], Some("json")),
664            (
665                &[
666                    "--foo",
667                    "--message-format",
668                    "json-render-diagnostics",
669                    "bar",
670                ],
671                Some("json-render-diagnostics"),
672            ),
673            (
674                &["--foo", "--message-format=json-render-diagnostics", "bar"],
675                Some("json-render-diagnostics"),
676            ),
677            (&["--foo", "bar"], None),
678        ];
679
680        for (args, expected) in CASES {
681            let mut cmd = CargoCmd::Build(Build {
682                passthrough: RemainingArgs {
683                    args: args.iter().map(ToString::to_string).collect(),
684                },
685                verbose: false,
686            });
687
688            assert_eq!(
689                cmd.extract_message_format().unwrap(),
690                expected.map(ToString::to_string)
691            );
692
693            if let CargoCmd::Build(build) = cmd {
694                assert_eq!(build.passthrough.args, vec!["--foo", "bar"]);
695            } else {
696                unreachable!();
697            }
698        }
699    }
700
701    #[test]
702    fn extract_format_err() {
703        for args in [&["--message-format=foo"][..], &["--message-format", "foo"]] {
704            let mut cmd = CargoCmd::Build(Build {
705                passthrough: RemainingArgs {
706                    args: args.iter().map(ToString::to_string).collect(),
707                },
708                verbose: false,
709            });
710
711            assert!(cmd.extract_message_format().is_err());
712        }
713    }
714
715    #[test]
716    fn split_run_args() {
717        struct TestParam {
718            input: &'static [&'static str],
719            expected_cargo: &'static [&'static str],
720            expected_exe: &'static [&'static str],
721        }
722
723        for param in [
724            TestParam {
725                input: &["--example", "hello-world", "--no-default-features"],
726                expected_cargo: &["--example", "hello-world", "--no-default-features"],
727                expected_exe: &[],
728            },
729            TestParam {
730                input: &["--example", "hello-world", "--", "--do-stuff", "foo"],
731                expected_cargo: &["--example", "hello-world"],
732                expected_exe: &["--do-stuff", "foo"],
733            },
734            TestParam {
735                input: &["--lib", "--", "foo"],
736                expected_cargo: &["--lib"],
737                expected_exe: &["foo"],
738            },
739            TestParam {
740                input: &["foo", "--", "bar"],
741                expected_cargo: &["foo"],
742                expected_exe: &["bar"],
743            },
744        ] {
745            let input: Vec<&str> = ["cargo", "3ds", "run"]
746                .iter()
747                .chain(param.input)
748                .copied()
749                .collect();
750
751            dbg!(&input);
752            let Cargo::Input(Input {
753                cmd: CargoCmd::Run(Run { build_args, .. }),
754                ..
755            }) = Cargo::try_parse_from(input).unwrap_or_else(|e| panic!("{e}"))
756            else {
757                panic!("parsed as something other than `run` subcommand")
758            };
759
760            assert_eq!(build_args.passthrough.cargo_args(), param.expected_cargo);
761            assert_eq!(build_args.passthrough.exe_args(), param.expected_exe);
762        }
763    }
764}