Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Upgrade to clap4, add extra args and help strings #26

Merged
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ edition = "2021"
cargo_metadata = "0.14.0"
rustc_version = "0.4.0"
semver = "1.0.10"
serde = {version = "1.0.139", features = ['derive']}
serde = { version = "1.0.139", features = ['derive'] }
tee = "0.1.0"
toml = "0.5.6"
clap = { version = "3.2.12", features = ["derive"]}
clap = { version = "4.0.15", features = ["derive", "wrap_help"] }
337 changes: 314 additions & 23 deletions src/command.rs
Original file line number Diff line number Diff line change
@@ -1,36 +1,327 @@
use clap::{AppSettings, Args, Parser, ValueEnum};
use clap::{Args, Parser, Subcommand};

#[derive(Parser)]
#[clap(name = "cargo")]
#[clap(bin_name = "cargo")]
#[derive(Parser, Debug)]
#[command(name = "cargo", bin_name = "cargo")]
pub enum Cargo {
#[clap(name = "3ds")]
#[command(name = "3ds")]
Input(Input),
}

#[derive(Args)]
#[clap(about)]
#[clap(global_setting(AppSettings::AllowLeadingHyphen))]
#[derive(Args, Debug)]
#[command(version, about)]
pub struct Input {
#[clap(value_enum)]
pub cmd: CargoCommand,
pub cargo_opts: Vec<String>,
#[command(subcommand)]
pub cmd: CargoCmd,
}

#[derive(ValueEnum, Copy, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub enum CargoCommand {
Build,
Run,
Test,
Check,
Clippy,
/// The cargo command to run. This command will be forwarded to the real
/// `cargo` with the appropriate arguments for a 3DS executable.
///
/// If another command is passed which is not recognized, it will be passed
/// through unmodified to `cargo` with RUSTFLAGS set for the 3DS.
#[derive(Subcommand, Debug)]
#[command(allow_external_subcommands = true)]
pub enum CargoCmd {
/// Builds an executable suitable to run on a 3DS (3dsx).
Build(RemainingArgs),

/// Builds an executable and sends it to a device with `3dslink`.
Run(Run),

/// Builds a test executable and sends it to a device with `3dslink`.
///
/// This can be used with `--test` for integration tests, or `--lib` for
/// unit tests (which require a custom test runner).
Test(Test),

// NOTE: it seems docstring + name for external subcommands are not rendered
// in help, but we might as well set them here in case a future version of clap
// does include them in help text.
/// Run any other `cargo` command with RUSTFLAGS set for the 3DS.
#[command(external_subcommand, name = "COMMAND")]
Passthrough(Vec<String>),
}

#[derive(Args, Debug)]
pub struct RemainingArgs {
/// Pass additional options through to the `cargo` command.
///
/// To pass flags that start with `-`, you must use `--` to separate `cargo 3ds`
/// options from cargo options. Any argument after `--` will be passed through
/// to cargo unmodified.
AzureMarker marked this conversation as resolved.
Show resolved Hide resolved
AzureMarker marked this conversation as resolved.
Show resolved Hide resolved
///
/// If one of the arguments is itself `--`, the args following that will be
/// considered as args to pass to the executable, rather than to `cargo`
/// (only works for the `run` or `test` commands). For example, if you want
/// to pass some args to the executable directly it might look like this:
///
/// > cargo 3ds run -- -- arg1 arg2
#[arg(trailing_var_arg = true)]
#[arg(allow_hyphen_values = true)]
#[arg(global = true)]
#[arg(name = "CARGO_ARGS")]
args: Vec<String>,
}

#[derive(Parser, Debug)]
pub struct Test {
/// If set, the built executable will not be sent to the device to run it.
#[arg(long)]
pub no_run: bool,

// The test command uses a superset of the same arguments as Run.
#[command(flatten)]
pub run_args: Run,
}

#[derive(Parser, Debug)]
pub struct Run {
/// Specify the IP address of the device to send the executable to.
///
/// Corresponds to 3dslink's `--address` arg, which defaults to automatically
/// finding the device.
#[arg(long, short = 'a')]
pub address: Option<std::net::Ipv4Addr>,

/// Set the 0th argument of the executable when running it. Corresponds to
/// 3dslink's `--argv0` argument.
#[arg(long, short = '0')]
pub argv0: Option<String>,

/// Start the 3dslink server after sending the executable. Corresponds to
/// 3dslink's `--server` argument.
#[arg(long, short = 's', default_value_t = false)]
pub server: bool,

/// Set the number of tries when connecting to the device to send the executable.
/// Corresponds to 3dslink's `--retries` argument.
// Can't use `short = 'r'` because that would conflict with cargo's `--release/-r`
#[arg(long)]
pub retries: Option<usize>,

// Passthrough cargo options.
#[command(flatten)]
pub cargo_args: RemainingArgs,
}

impl CargoCommand {
impl CargoCmd {
/// Whether or not this command should build a 3DSX executable file.
pub fn should_build_3dsx(&self) -> bool {
matches!(
self,
CargoCommand::Build | CargoCommand::Run | CargoCommand::Test
)
matches!(self, Self::Build(_) | Self::Run(_) | Self::Test(_))
}

/// Whether or not the resulting executable should be sent to the 3DS with
/// `3dslink`.
pub fn should_link_to_device(&self) -> bool {
match self {
CargoCmd::Test(test) => !test.no_run,
CargoCmd::Run(_) => true,
_ => false,
}
}

pub const DEFAULT_MESSAGE_FORMAT: &str = "json-render-diagnostics";

pub fn extract_message_format(&mut self) -> Result<Option<String>, String> {
Self::extract_message_format_from_args(match self {
CargoCmd::Build(args) => &mut args.args,
CargoCmd::Run(run) => &mut run.cargo_args.args,
CargoCmd::Test(test) => &mut test.run_args.cargo_args.args,
CargoCmd::Passthrough(args) => args,
})
}

fn extract_message_format_from_args(
cargo_args: &mut Vec<String>,
) -> Result<Option<String>, String> {
// Checks for a position within the args where '--message-format' is located
if let Some(pos) = cargo_args
.iter()
.position(|s| s.starts_with("--message-format"))
{
// Remove the arg from list so we don't pass anything twice by accident
let arg = cargo_args.remove(pos);

// Allows for usage of '--message-format=<format>' and also using space separation.
// Check for a '=' delimiter and use the second half of the split as the format,
// otherwise remove next arg which is now at the same position as the original flag.
let format = if let Some((_, format)) = arg.split_once('=') {
format.to_string()
} else {
// Also need to remove the argument to the --message-format option
cargo_args.remove(pos)
};

// Non-json formats are not supported so the executable exits.
if format.starts_with("json") {
Ok(Some(format))
} else {
Err(String::from(
"error: non-JSON `message-format` is not supported",
))
}
} else {
Ok(None)
ian-h-chamberlain marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

impl RemainingArgs {
/// Get the args to be passed to the executable itself (not `cargo`).
pub fn cargo_args(&self) -> &[String] {
self.split_args().0
}

/// Get the args to be passed to the executable itself (not `cargo`).
pub fn exe_args(&self) -> &[String] {
self.split_args().1
}

fn split_args(&self) -> (&[String], &[String]) {
if let Some(split) = self.args.iter().position(|s| s == "--") {
self.args.split_at(split + 1)
} else {
(&self.args[..], &[])
}
}
}

impl Run {
/// Get the args to pass to `3dslink` based on these options.
pub fn get_3dslink_args(&self) -> Vec<String> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want to add tests for this? Not needed for the merge at least.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I can add it whenever I try to add CI / other test assertions in a future PR

let mut args = Vec::new();

if let Some(address) = self.address {
args.extend(["--address".to_string(), address.to_string()]);
}

if let Some(argv0) = &self.argv0 {
args.extend(["--arg0".to_string(), argv0.clone()]);
}

if let Some(retries) = self.retries {
args.extend(["--retries".to_string(), retries.to_string()]);
}

if self.server {
args.push("--server".to_string());
}

let exe_args = self.cargo_args.exe_args();
if !exe_args.is_empty() {
// For some reason 3dslink seems to want 2 instances of `--`, one
// in front of all of the args like this...
args.extend(["--args".to_string(), "--".to_string()]);

let mut escaped = false;
for arg in exe_args.iter().cloned() {
if arg.starts_with('-') && !escaped {
// And one before the first `-` arg that is passed in.
args.extend(["--".to_string(), arg]);
escaped = true;
} else {
args.push(arg);
}
}
}

args
}
}

#[cfg(test)]
mod tests {
use super::*;

use clap::CommandFactory;

#[test]
fn verify_app() {
Cargo::command().debug_assert();
}

#[test]
fn extract_format() {
for (args, expected) in [
(&["--foo", "--message-format=json", "bar"][..], Some("json")),
ian-h-chamberlain marked this conversation as resolved.
Show resolved Hide resolved
(&["--foo", "--message-format", "json", "bar"], Some("json")),
(
&[
"--foo",
"--message-format",
"json-render-diagnostics",
"bar",
],
Some("json-render-diagnostics"),
),
(
&["--foo", "--message-format=json-render-diagnostics", "bar"],
Some("json-render-diagnostics"),
),
] {
let mut cmd = CargoCmd::Build(RemainingArgs {
args: args.iter().map(ToString::to_string).collect(),
});

assert_eq!(
cmd.extract_message_format().unwrap(),
expected.map(ToString::to_string)
);

if let CargoCmd::Build(args) = cmd {
assert_eq!(args.args, vec!["--foo", "bar"]);
} else {
unreachable!();
}
}
}

#[test]
fn extract_format_err() {
for args in [&["--message-format=foo"][..], &["--message-format", "foo"]] {
let mut cmd = CargoCmd::Build(RemainingArgs {
args: args.iter().map(ToString::to_string).collect(),
});

assert!(cmd.extract_message_format().is_err());
}
}

#[test]
fn split_run_args() {
struct TestParam {
input: &'static [&'static str],
expected_cargo: &'static [&'static str],
expected_exe: &'static [&'static str],
}

for param in [
TestParam {
input: &["--example", "hello-world", "--no-default-features"],
expected_cargo: &["--example", "hello-world", "--no-default-features"],
expected_exe: &[],
},
TestParam {
input: &["--example", "hello-world", "--", "--do-stuff", "foo"],
expected_cargo: &["--example", "hello-world", "--"],
expected_exe: &["--do-stuff", "foo"],
},
TestParam {
input: &["--lib", "--", "foo"],
expected_cargo: &["--lib", "--"],
expected_exe: &["foo"],
},
TestParam {
input: &["foo", "--", "bar"],
expected_cargo: &["foo", "--"],
expected_exe: &["bar"],
},
] {
let Run { cargo_args, .. } =
Run::parse_from(std::iter::once(&"run").chain(param.input));

assert_eq!(cargo_args.cargo_args(), param.expected_cargo);
assert_eq!(cargo_args.exe_args(), param.expected_exe);
}
}
}
Loading