Skip to content
This repository was archived by the owner on Jun 18, 2026. It is now read-only.

Commit cd1ac16

Browse files
Support short subcommand aliases as positional tokens (#78)
## Summary - add subcommand variant short aliases to schema via `#[facet(args::short = 'x')]` - parse short aliases as positional subcommand tokens (`d`), not flag tokens (`-d`) - allow subcommand alias `d` to coexist with flag short `-d` - detect duplicate subcommand short aliases - add regression tests for schema + integration coverage ## Testing - cargo nextest run -p figue integration::subcommand:: - cargo nextest run -p figue schema::tests::
1 parent ddae64c commit cd1ac16

7 files changed

Lines changed: 225 additions & 34 deletions

File tree

crates/figue/src/layers/cli.rs

Lines changed: 49 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ impl<'a> ParseContext<'a> {
393393
}
394394
}
395395

396-
fn parse_short_flag(&mut self, arg: &str, level: &ArgLevelSchema) {
396+
fn parse_short_flag(&mut self, arg: &str, level: &'a ArgLevelSchema) {
397397
let flag_part = &arg[1..]; // strip "-"
398398

399399
// Check for `-k=value` syntax (single short flag with equals)
@@ -493,7 +493,12 @@ impl<'a> ParseContext<'a> {
493493
}
494494

495495
/// Parse a short flag with an inline value (e.g., -k=3)
496-
fn parse_short_flag_with_value(&mut self, ch: char, value_str: &str, level: &ArgLevelSchema) {
496+
fn parse_short_flag_with_value(
497+
&mut self,
498+
ch: char,
499+
value_str: &str,
500+
level: &'a ArgLevelSchema,
501+
) {
497502
let found = level.args().iter().find(|(_, schema)| {
498503
if let ArgKind::Named { short: Some(s), .. } = schema.kind() {
499504
*s == ch
@@ -712,39 +717,51 @@ impl<'a> ParseContext<'a> {
712717

713718
let arg = self.args[self.index];
714719

715-
// Find subcommand by comparing user input with kebab-case of effective_name
716-
let subcommand = level
717-
.subcommands()
718-
.iter()
719-
.find(|(name, _)| name.to_kebab_case() == arg);
720+
// Find subcommand by long name (kebab-case) or short alias token ("d").
721+
let subcommand = level.subcommands().iter().find(|(name, sub)| {
722+
name.to_kebab_case() == arg
723+
|| sub
724+
.short()
725+
.is_some_and(|short| arg.chars().eq(core::iter::once(short)))
726+
});
720727

721-
if let Some((_, subcommand)) = subcommand {
722-
self.index += 1;
723-
let fields = self.parse_subcommand_args(level, subcommand);
724-
725-
// For flattened tuple variants like `Install(#[facet(flatten)] InstallOptions)`,
726-
// the fields are already flat (they came from the inner struct's schema).
727-
// We do NOT wrap them in a "0" key - the deserializer handles the routing.
728-
// See module-level docs for the ConfigValue model.
729-
let _ = subcommand.is_flattened_tuple(); // Acknowledge the flag exists but don't use it here
730-
731-
// Use the effective name for deserialization - facet-format expects
732-
// the effective name (respecting `#[facet(rename = "...")]`), e.g., "rm" for a
733-
// variant named `Remove` with `#[facet(rename = "rm")]`.
734-
let enum_value = ConfigValue::Enum(Sourced {
735-
value: EnumValue {
736-
variant: subcommand.effective_name().to_string(),
737-
fields,
738-
},
739-
span: None,
740-
provenance: Some(Provenance::cli(arg, "")),
741-
});
728+
let Some((_, subcommand)) = subcommand else {
729+
return false;
730+
};
742731

743-
self.result.insert(field_name.to_string(), enum_value);
744-
return true;
745-
}
732+
self.consume_subcommand(level, field_name, subcommand, arg);
733+
true
734+
}
746735

747-
false
736+
fn consume_subcommand(
737+
&mut self,
738+
level: &'a ArgLevelSchema,
739+
field_name: &str,
740+
subcommand: &'a Subcommand,
741+
input_arg: &str,
742+
) {
743+
self.index += 1;
744+
let fields = self.parse_subcommand_args(level, subcommand);
745+
746+
// For flattened tuple variants like `Install(#[facet(flatten)] InstallOptions)`,
747+
// the fields are already flat (they came from the inner struct's schema).
748+
// We do NOT wrap them in a "0" key - the deserializer handles the routing.
749+
// See module-level docs for the ConfigValue model.
750+
let _ = subcommand.is_flattened_tuple(); // Acknowledge the flag exists but don't use it here
751+
752+
// Use the effective name for deserialization - facet-format expects
753+
// the effective name (respecting `#[facet(rename = "...")]`), e.g., "rm" for a
754+
// variant named `Remove` with `#[facet(rename = "rm")]`.
755+
let enum_value = ConfigValue::Enum(Sourced {
756+
value: EnumValue {
757+
variant: subcommand.effective_name().to_string(),
758+
fields,
759+
},
760+
span: None,
761+
provenance: Some(Provenance::cli(input_arg, "")),
762+
});
763+
764+
self.result.insert(field_name.to_string(), enum_value);
748765
}
749766

750767
fn parse_subcommand_args(

crates/figue/src/schema.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,9 @@ pub struct Subcommand {
244244
/// Documentation for this subcommand.
245245
docs: Docs,
246246

247+
/// Optional short alias for this subcommand (e.g., `-d` for `daemon`).
248+
short: Option<char>,
249+
247250
/// Arguments for this subcommand level.
248251
args: ArgLevelSchema,
249252

@@ -658,6 +661,11 @@ impl Subcommand {
658661
&self.args
659662
}
660663

664+
/// Get the short alias for this subcommand, if configured.
665+
pub fn short(&self) -> Option<char> {
666+
self.short
667+
}
668+
661669
/// Check if this is a tuple variant with a flattened struct.
662670
/// When true, parsed fields need to be wrapped in a "0" field for deserialization.
663671
pub fn is_flattened_tuple(&self) -> bool {

crates/figue/src/schema/from_schema.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,19 @@ fn short_from_field(field: &Field) -> Option<char> {
540540
})
541541
}
542542

543+
fn short_from_variant(variant: &Variant) -> Option<char> {
544+
variant
545+
.get_attr(Some("args"), "short")
546+
.and_then(|attr| attr.get_as::<Attr>())
547+
.and_then(|attr| {
548+
if let Attr::Short(c) = attr {
549+
c.or_else(|| variant.effective_name().chars().next())
550+
} else {
551+
None
552+
}
553+
})
554+
}
555+
543556
fn variant_fields_for_schema(variant: &Variant) -> &'static [Field] {
544557
let fields = variant.data.fields;
545558
if is_flattened_tuple_variant(variant) {
@@ -592,6 +605,7 @@ fn arg_level_from_fields_with_prefix(
592605
let mut seen_long: HashMap<String, SchemaErrorContext> = HashMap::new();
593606
let mut seen_short: HashMap<char, SchemaErrorContext> = HashMap::new();
594607
let mut seen_subcommands: HashMap<String, SchemaErrorContext> = HashMap::new();
608+
let mut seen_subcommand_short: HashMap<char, SchemaErrorContext> = HashMap::new();
595609

596610
let mut first_subcommand_field: Option<SchemaErrorContext> = None;
597611

@@ -778,16 +792,40 @@ fn arg_level_from_fields_with_prefix(
778792
// effective_name respects #[facet(rename = "...")], used for deserialization
779793
let effective_name = variant.effective_name().to_string();
780794
let docs = docs_from_lines(variant.doc);
795+
let short = short_from_variant(variant);
781796
let variant_fields = variant_fields_for_schema(variant);
782797
let variant_ctx =
783798
SchemaErrorContext::root(enum_shape).with_variant(cli_name.clone());
784799
let args_schema = arg_level_from_fields(variant_fields, &variant_ctx)?;
785800
let is_flattened_tuple = is_flattened_tuple_variant(variant);
786801

802+
if let Some(short) = short {
803+
if let Some(existing_ctx) = seen_subcommand_short.get(&short) {
804+
return Err(SchemaError::new(
805+
existing_ctx.clone(),
806+
format!("duplicate subcommand short alias `{short}`"),
807+
)
808+
.with_primary_label(format!("`{short}` first defined here"))
809+
.with_label(variant_ctx.clone(), "defined again here"));
810+
}
811+
if let Some(existing_ctx) = seen_subcommands.get(&short.to_string()) {
812+
return Err(SchemaError::new(
813+
existing_ctx.clone(),
814+
format!(
815+
"subcommand short alias `{short}` conflicts with existing subcommand name"
816+
),
817+
)
818+
.with_primary_label("conflicting name defined here")
819+
.with_label(variant_ctx.clone(), "conflicting short alias defined here"));
820+
}
821+
seen_subcommand_short.insert(short, variant_ctx.clone());
822+
}
823+
787824
let sub = Subcommand {
788825
name: cli_name.clone(),
789826
effective_name: effective_name.clone(),
790827
docs,
828+
short,
791829
args: args_schema,
792830
is_flattened_tuple,
793831
shape: enum_shape,

crates/figue/src/schema/snapshots/figue__schema__tests__snapshot_schema_bad_config_field.snap

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
---
22
source: crates/figue/src/schema/tests.rs
3+
assertion_line: 266
34
expression: stripped
45
---
56
Error: config field must be a struct
67
╭─[ <unknown>:4:5 ]
78
89
2 │ struct BadConfigField {
910
│ ───────┬──────
10-
│ ╰──────── defined at crates/figue/src/schema/tests.rs:130
11+
│ ╰──────── defined at crates/figue/src/schema/tests.rs:174
1112
1213
4config: String,
1314
│ ───────┬──────

crates/figue/src/schema/snapshots/figue__schema__tests__snapshot_schema_top_level_enum.snap

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
source: crates/figue/src/schema/tests.rs
3+
assertion_line: 193
34
expression: stripped
45
---
56
Error: top-level shape must be a struct
@@ -9,7 +10,7 @@ Error: top-level shape must be a struct
910
┆ ┆
1011
3 │ │ enum TopLevelEnum {
1112
│ │ ──────┬─────
12-
│ │ ╰─────── defined at crates/figue/src/schema/tests.rs:136
13+
│ │ ╰─────── defined at crates/figue/src/schema/tests.rs:180
1314
┆ ┆
1415
5 │ ├─▶ }
1516
│ │ ┬

crates/figue/src/schema/tests.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,50 @@ struct ConflictingShortFlags {
127127
b: bool,
128128
}
129129

130+
#[derive(Facet)]
131+
#[repr(u8)]
132+
enum SubcommandWithShort {
133+
#[facet(args::short = 'd')]
134+
Daemon,
135+
Doctor,
136+
}
137+
138+
#[derive(Facet)]
139+
struct ArgsWithSubcommandShort {
140+
#[facet(args::subcommand)]
141+
command: SubcommandWithShort,
142+
}
143+
144+
#[derive(Facet)]
145+
#[repr(u8)]
146+
enum SubcommandShortConflictsWithFlagCommand {
147+
#[facet(args::short = 'd')]
148+
Daemon,
149+
}
150+
151+
#[derive(Facet)]
152+
struct SubcommandShortConflictsWithFlag {
153+
#[facet(args::named, args::short = 'd')]
154+
debug: bool,
155+
#[facet(args::subcommand)]
156+
command: SubcommandShortConflictsWithFlagCommand,
157+
}
158+
159+
#[derive(Facet)]
160+
#[repr(u8)]
161+
enum SubcommandShortConflictsCommand {
162+
#[facet(args::short = 'd')]
163+
Daemon,
164+
#[facet(args::short = 'd')]
165+
Doctor,
166+
}
167+
168+
#[derive(Facet)]
169+
struct SubcommandShortConflicts {
170+
#[facet(args::subcommand)]
171+
command: SubcommandShortConflictsCommand,
172+
}
173+
130174
#[derive(Facet)]
131175
struct BadConfigField {
132176
#[facet(args::config)]
@@ -189,6 +233,34 @@ fn snapshot_schema_conflicting_short_flags() {
189233
assert_schema_snapshot!(Schema::from_shape(ConflictingShortFlags::SHAPE));
190234
}
191235

236+
#[test]
237+
fn test_schema_subcommand_short_stored() {
238+
let schema = Schema::from_shape(ArgsWithSubcommandShort::SHAPE).unwrap();
239+
let daemon = schema
240+
.args()
241+
.subcommands()
242+
.values()
243+
.find(|sub| sub.cli_name() == "daemon")
244+
.unwrap();
245+
assert_eq!(daemon.short(), Some('d'));
246+
}
247+
248+
#[test]
249+
fn test_schema_subcommand_short_conflicts_with_flag() {
250+
Schema::from_shape(SubcommandShortConflictsWithFlag::SHAPE)
251+
.expect("subcommand short alias 'd' should not conflict with flag short '-d'");
252+
}
253+
254+
#[test]
255+
fn test_schema_subcommand_short_conflicts_with_subcommand_short() {
256+
let result = Schema::from_shape(SubcommandShortConflicts::SHAPE);
257+
let err = result.unwrap_err().to_string();
258+
assert!(
259+
err.contains("duplicate subcommand short alias `d`"),
260+
"unexpected error: {err}"
261+
);
262+
}
263+
192264
#[test]
193265
fn snapshot_schema_bad_config_field() {
194266
assert_schema_snapshot!(Schema::from_shape(BadConfigField::SHAPE));

crates/figue/tests/integration/subcommand.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,60 @@ fn test_subcommand_basic() {
128128
assert_eq!(args.command, Command::Build { release: false });
129129
}
130130

131+
#[test]
132+
fn test_subcommand_short_alias() {
133+
#[derive(Facet, Debug, PartialEq)]
134+
#[repr(u8)]
135+
enum Command {
136+
Doctor,
137+
#[facet(args::short = 'd')]
138+
Daemon,
139+
}
140+
141+
#[derive(Facet, Debug, PartialEq)]
142+
struct Args {
143+
#[facet(args::subcommand)]
144+
command: Command,
145+
}
146+
147+
let args: Args = figue::from_slice(&["d"]).unwrap();
148+
assert_eq!(args.command, Command::Daemon);
149+
}
150+
151+
#[test]
152+
fn test_subcommand_short_alias_only_when_not_a_flag() {
153+
#[derive(Facet, Debug, PartialEq)]
154+
#[repr(u8)]
155+
enum NestedCommand {
156+
#[facet(args::short = 'd')]
157+
Daemon,
158+
}
159+
160+
#[derive(Facet, Debug, PartialEq)]
161+
struct RepoArgs {
162+
#[facet(args::subcommand)]
163+
command: Option<NestedCommand>,
164+
}
165+
166+
#[derive(Facet, Debug, PartialEq)]
167+
#[repr(u8)]
168+
enum Command {
169+
Repo(RepoArgs),
170+
}
171+
172+
#[derive(Facet, Debug, PartialEq)]
173+
struct Args {
174+
#[facet(args::named, args::short = 'd')]
175+
debug: bool,
176+
#[facet(args::subcommand)]
177+
command: Command,
178+
}
179+
180+
let args: Args = figue::from_slice(&["repo", "-d"]).unwrap();
181+
assert!(args.debug);
182+
assert_eq!(args.command, Command::Repo(RepoArgs { command: None }));
183+
}
184+
131185
/// Test subcommand with kebab-case variant names
132186
#[test]
133187
fn test_subcommand_kebab_case() {

0 commit comments

Comments
 (0)