Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
13 changes: 11 additions & 2 deletions argv/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3036,7 +3036,7 @@ pub fn choice_matches(choices: &[&str], value: &str, ignore_case: bool) -> bool
.any(|choice| *choice == value || ignore_case && choice.eq_ignore_ascii_case(value))
}

/// An enum whose variants are one command's mutually exclusive switch flags.
/// An enum whose variants are one command's mutually exclusive flags.
///
/// What a CLI spells `--json` or `--yaml` and holds as a `Mode`, rather than as one `bool` per
/// member plus a `match` over which of them is set. Nothing new reaches the spec: the enum
Expand All @@ -3050,7 +3050,7 @@ pub fn choice_matches(choices: &[&str], value: &str, ignore_case: bool) -> bool
pub trait ArgGroup: Sized {
/// What the group is called, in the emitted spec and in a failed check.
const NAME: &'static str;
/// One switch per variant, to splice into the holding command's parse table.
/// One flag per variant, to splice into the holding command's parse table.
///
/// A `const` for the same reason [`CommandArgs::COMMAND`] is: the tables stay `static`
/// all the way down, so nothing is built at run time to start a parse.
Expand Down Expand Up @@ -3095,6 +3095,15 @@ pub trait ArgGroup: Sized {
/// The variant that was selected, or `None` when no member was given.
fn build(partial: &Self::Partial) -> Option<Self>;

/// Build the selected variant, reporting a payload that could not be converted.
///
/// The default preserves hand-written implementations whose members are all switches.
/// Derived groups with value-carrying variants override this and keep their raw word in
/// [`Self::Partial`] until this step, just as an ordinary command field does.
fn try_build(partial: &Self::Partial) -> Result<Option<Self>, crate::Error<'static, 'static>> {
Ok(Self::build(partial))
}

/// Find a member by any selector it accepts.
///
/// Parents use this for `requires` / `conflicts` / conditional defaults that name a
Expand Down
101 changes: 100 additions & 1 deletion conformance/tests/arg_group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
use std::ffi::OsStr;

use usage_argv::{help, Error};
use usage_derive::{ArgGroup, Args, Cli, Subcommands};
use usage_derive::{ArgGroup, Args, Cli, Subcommands, ValueEnum};

fn argv<const N: usize>(tokens: [&str; N]) -> [&OsStr; N] {
tokens.map(OsStr::new)
Expand Down Expand Up @@ -38,6 +38,105 @@ enum Source {
Clipboard,
}

#[derive(Debug, PartialEq, ValueEnum)]
#[usage(ignore_case)]
enum MigrationSource {
Prettier,
Biome,
}

/// What the formatter should do.
#[derive(ArgGroup, Debug, PartialEq)]
enum Mode {
/// Write files in place.
Write,
/// Check files without changing them.
Check,
/// Migrate a configuration.
#[usage(value_name = "SOURCE", value_enum)]
Migrate(MigrationSource),
/// Read source from standard input as this path.
StdinFilepath(std::path::PathBuf),
}

#[derive(Cli)]
#[usage(bin = "valued-group")]
struct ValuedGroup {
#[usage(arg_group)]
mode: Option<Mode>,
#[usage(long, required_if_eq("--migrate", "biome"))]
confirm: bool,
}

#[test]
fn a_group_member_can_carry_a_typed_value() {
let a = argv(["--migrate", "BIOME", "--confirm"]);
assert_eq!(
ValuedGroup::parse_from(&a)
.expect("value-enum payload")
.mode,
Some(Mode::Migrate(MigrationSource::Biome))
);

let a = argv(["--stdin-filepath", "src/input.ts"]);
assert_eq!(
ValuedGroup::parse_from(&a).expect("path payload").mode,
Some(Mode::StdinFilepath("src/input.ts".into()))
);

let a = argv(["--migrate", "unknown"]);
assert!(matches!(
ValuedGroup::parse_from(&a),
Err(Error::InvalidValue(error)) if error.name == "migrate"
));
}

#[test]
fn case_insensitive_group_values_match_relationships_the_same_way_they_parse() {
let a = argv(["--migrate", "BIOME"]);
assert!(ValuedGroup::parse_from(&a).is_err());

let a = argv(["--migrate", "BIOME", "--confirm"]);
assert!(ValuedGroup::parse_from(&a).is_ok());
}

#[test]
fn a_value_carrying_group_member_reaches_help_and_the_spec() {
let help =
help::render(ValuedGroup::spec(), ValuedGroup::spec().root.cmd, false).expect("a page");
assert!(help.contains("--migrate <SOURCE>"), "{help}");
assert!(help.contains("[prettier, biome]"), "{help}");
assert!(help.contains("--stdin-filepath <STDIN_FILEPATH>"), "{help}");

let kdl = ValuedGroup::to_kdl();
assert!(
kdl.contains("flag --migrate")
&& kdl.contains("arg <SOURCE>")
&& kdl.contains("choices ignore_case=#true")
&& kdl.contains("choice prettier")
&& kdl.contains("choice biome"),
"{kdl}"
);
assert!(
kdl.contains("group mode --write --check --migrate --stdin-filepath"),
"{kdl}"
);
}

#[test]
fn update_reports_an_invalid_group_payload_without_changing_the_value() {
let mut parsed = ValuedGroup {
mode: Some(Mode::Write),
confirm: false,
};
let a = argv(["--migrate", "unknown"]);
assert!(matches!(
parsed.try_update_from(&a),
Err(Error::InvalidValue(error)) if error.name == "migrate"
));
assert_eq!(parsed.mode, Some(Mode::Write));
}

/// A CLI whose format is optional and whose source is not.
#[derive(Cli)]
#[usage(bin = "grp")]
Expand Down
Loading