Skip to content
This repository was archived by the owner on Jun 18, 2026. It is now read-only.
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
81 changes: 49 additions & 32 deletions crates/figue/src/layers/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ impl<'a> ParseContext<'a> {
}
}

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

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

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

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

// Find subcommand by comparing user input with kebab-case of effective_name
let subcommand = level
.subcommands()
.iter()
.find(|(name, _)| name.to_kebab_case() == arg);
// Find subcommand by long name (kebab-case) or short alias token ("d").
let subcommand = level.subcommands().iter().find(|(name, sub)| {
name.to_kebab_case() == arg
|| sub
.short()
.is_some_and(|short| arg.chars().eq(core::iter::once(short)))
});

if let Some((_, subcommand)) = subcommand {
self.index += 1;
let fields = self.parse_subcommand_args(level, subcommand);

// For flattened tuple variants like `Install(#[facet(flatten)] InstallOptions)`,
// the fields are already flat (they came from the inner struct's schema).
// We do NOT wrap them in a "0" key - the deserializer handles the routing.
// See module-level docs for the ConfigValue model.
let _ = subcommand.is_flattened_tuple(); // Acknowledge the flag exists but don't use it here

// Use the effective name for deserialization - facet-format expects
// the effective name (respecting `#[facet(rename = "...")]`), e.g., "rm" for a
// variant named `Remove` with `#[facet(rename = "rm")]`.
let enum_value = ConfigValue::Enum(Sourced {
value: EnumValue {
variant: subcommand.effective_name().to_string(),
fields,
},
span: None,
provenance: Some(Provenance::cli(arg, "")),
});
let Some((_, subcommand)) = subcommand else {
return false;
};

self.result.insert(field_name.to_string(), enum_value);
return true;
}
self.consume_subcommand(level, field_name, subcommand, arg);
true
}

false
fn consume_subcommand(
&mut self,
level: &'a ArgLevelSchema,
field_name: &str,
subcommand: &'a Subcommand,
input_arg: &str,
) {
self.index += 1;
let fields = self.parse_subcommand_args(level, subcommand);

// For flattened tuple variants like `Install(#[facet(flatten)] InstallOptions)`,
// the fields are already flat (they came from the inner struct's schema).
// We do NOT wrap them in a "0" key - the deserializer handles the routing.
// See module-level docs for the ConfigValue model.
let _ = subcommand.is_flattened_tuple(); // Acknowledge the flag exists but don't use it here

// Use the effective name for deserialization - facet-format expects
// the effective name (respecting `#[facet(rename = "...")]`), e.g., "rm" for a
// variant named `Remove` with `#[facet(rename = "rm")]`.
let enum_value = ConfigValue::Enum(Sourced {
value: EnumValue {
variant: subcommand.effective_name().to_string(),
fields,
},
span: None,
provenance: Some(Provenance::cli(input_arg, "")),
});

self.result.insert(field_name.to_string(), enum_value);
}

fn parse_subcommand_args(
Expand Down
8 changes: 8 additions & 0 deletions crates/figue/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,9 @@ pub struct Subcommand {
/// Documentation for this subcommand.
docs: Docs,

/// Optional short alias for this subcommand (e.g., `-d` for `daemon`).
short: Option<char>,

/// Arguments for this subcommand level.
args: ArgLevelSchema,

Expand Down Expand Up @@ -658,6 +661,11 @@ impl Subcommand {
&self.args
}

/// Get the short alias for this subcommand, if configured.
pub fn short(&self) -> Option<char> {
self.short
}

/// Check if this is a tuple variant with a flattened struct.
/// When true, parsed fields need to be wrapped in a "0" field for deserialization.
pub fn is_flattened_tuple(&self) -> bool {
Expand Down
38 changes: 38 additions & 0 deletions crates/figue/src/schema/from_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,19 @@ fn short_from_field(field: &Field) -> Option<char> {
})
}

fn short_from_variant(variant: &Variant) -> Option<char> {
variant
.get_attr(Some("args"), "short")
.and_then(|attr| attr.get_as::<Attr>())
.and_then(|attr| {
if let Attr::Short(c) = attr {
c.or_else(|| variant.effective_name().chars().next())
} else {
None
}
})
}

fn variant_fields_for_schema(variant: &Variant) -> &'static [Field] {
let fields = variant.data.fields;
if is_flattened_tuple_variant(variant) {
Expand Down Expand Up @@ -592,6 +605,7 @@ fn arg_level_from_fields_with_prefix(
let mut seen_long: HashMap<String, SchemaErrorContext> = HashMap::new();
let mut seen_short: HashMap<char, SchemaErrorContext> = HashMap::new();
let mut seen_subcommands: HashMap<String, SchemaErrorContext> = HashMap::new();
let mut seen_subcommand_short: HashMap<char, SchemaErrorContext> = HashMap::new();

let mut first_subcommand_field: Option<SchemaErrorContext> = None;

Expand Down Expand Up @@ -778,16 +792,40 @@ fn arg_level_from_fields_with_prefix(
// effective_name respects #[facet(rename = "...")], used for deserialization
let effective_name = variant.effective_name().to_string();
let docs = docs_from_lines(variant.doc);
let short = short_from_variant(variant);
let variant_fields = variant_fields_for_schema(variant);
let variant_ctx =
SchemaErrorContext::root(enum_shape).with_variant(cli_name.clone());
let args_schema = arg_level_from_fields(variant_fields, &variant_ctx)?;
let is_flattened_tuple = is_flattened_tuple_variant(variant);

if let Some(short) = short {
if let Some(existing_ctx) = seen_subcommand_short.get(&short) {
return Err(SchemaError::new(
existing_ctx.clone(),
format!("duplicate subcommand short alias `{short}`"),
)
.with_primary_label(format!("`{short}` first defined here"))
.with_label(variant_ctx.clone(), "defined again here"));
}
if let Some(existing_ctx) = seen_subcommands.get(&short.to_string()) {
return Err(SchemaError::new(
existing_ctx.clone(),
format!(
"subcommand short alias `{short}` conflicts with existing subcommand name"
),
)
.with_primary_label("conflicting name defined here")
.with_label(variant_ctx.clone(), "conflicting short alias defined here"));
}
seen_subcommand_short.insert(short, variant_ctx.clone());
}

let sub = Subcommand {
name: cli_name.clone(),
effective_name: effective_name.clone(),
docs,
short,
args: args_schema,
is_flattened_tuple,
shape: enum_shape,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
---
source: crates/figue/src/schema/tests.rs
assertion_line: 266
expression: stripped
---
Error: config field must be a struct
╭─[ <unknown>:4:5 ]
2 │ struct BadConfigField {
│ ───────┬──────
│ ╰──────── defined at crates/figue/src/schema/tests.rs:130
│ ╰──────── defined at crates/figue/src/schema/tests.rs:174
4 │ config: String,
│ ───────┬──────
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
source: crates/figue/src/schema/tests.rs
assertion_line: 193
expression: stripped
---
Error: top-level shape must be a struct
Expand All @@ -9,7 +10,7 @@ Error: top-level shape must be a struct
┆ ┆
3 │ │ enum TopLevelEnum {
│ │ ──────┬─────
│ │ ╰─────── defined at crates/figue/src/schema/tests.rs:136
│ │ ╰─────── defined at crates/figue/src/schema/tests.rs:180
┆ ┆
5 │ ├─▶ }
│ │ ┬
Expand Down
72 changes: 72 additions & 0 deletions crates/figue/src/schema/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,50 @@ struct ConflictingShortFlags {
b: bool,
}

#[derive(Facet)]
#[repr(u8)]
enum SubcommandWithShort {
#[facet(args::short = 'd')]
Daemon,
Doctor,
}

#[derive(Facet)]
struct ArgsWithSubcommandShort {
#[facet(args::subcommand)]
command: SubcommandWithShort,
}

#[derive(Facet)]
#[repr(u8)]
enum SubcommandShortConflictsWithFlagCommand {
#[facet(args::short = 'd')]
Daemon,
}

#[derive(Facet)]
struct SubcommandShortConflictsWithFlag {
#[facet(args::named, args::short = 'd')]
debug: bool,
#[facet(args::subcommand)]
command: SubcommandShortConflictsWithFlagCommand,
}

#[derive(Facet)]
#[repr(u8)]
enum SubcommandShortConflictsCommand {
#[facet(args::short = 'd')]
Daemon,
#[facet(args::short = 'd')]
Doctor,
}

#[derive(Facet)]
struct SubcommandShortConflicts {
#[facet(args::subcommand)]
command: SubcommandShortConflictsCommand,
}

#[derive(Facet)]
struct BadConfigField {
#[facet(args::config)]
Expand Down Expand Up @@ -189,6 +233,34 @@ fn snapshot_schema_conflicting_short_flags() {
assert_schema_snapshot!(Schema::from_shape(ConflictingShortFlags::SHAPE));
}

#[test]
fn test_schema_subcommand_short_stored() {
let schema = Schema::from_shape(ArgsWithSubcommandShort::SHAPE).unwrap();
let daemon = schema
.args()
.subcommands()
.values()
.find(|sub| sub.cli_name() == "daemon")
.unwrap();
assert_eq!(daemon.short(), Some('d'));
}

#[test]
fn test_schema_subcommand_short_conflicts_with_flag() {
Schema::from_shape(SubcommandShortConflictsWithFlag::SHAPE)
.expect("subcommand short alias 'd' should not conflict with flag short '-d'");
}

#[test]
fn test_schema_subcommand_short_conflicts_with_subcommand_short() {
let result = Schema::from_shape(SubcommandShortConflicts::SHAPE);
let err = result.unwrap_err().to_string();
assert!(
err.contains("duplicate subcommand short alias `d`"),
"unexpected error: {err}"
);
}

#[test]
fn snapshot_schema_bad_config_field() {
assert_schema_snapshot!(Schema::from_shape(BadConfigField::SHAPE));
Expand Down
54 changes: 54 additions & 0 deletions crates/figue/tests/integration/subcommand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,60 @@ fn test_subcommand_basic() {
assert_eq!(args.command, Command::Build { release: false });
}

#[test]
fn test_subcommand_short_alias() {
#[derive(Facet, Debug, PartialEq)]
#[repr(u8)]
enum Command {
Doctor,
#[facet(args::short = 'd')]
Daemon,
}

#[derive(Facet, Debug, PartialEq)]
struct Args {
#[facet(args::subcommand)]
command: Command,
}

let args: Args = figue::from_slice(&["d"]).unwrap();
assert_eq!(args.command, Command::Daemon);
}

#[test]
fn test_subcommand_short_alias_only_when_not_a_flag() {
#[derive(Facet, Debug, PartialEq)]
#[repr(u8)]
enum NestedCommand {
#[facet(args::short = 'd')]
Daemon,
}

#[derive(Facet, Debug, PartialEq)]
struct RepoArgs {
#[facet(args::subcommand)]
command: Option<NestedCommand>,
}

#[derive(Facet, Debug, PartialEq)]
#[repr(u8)]
enum Command {
Repo(RepoArgs),
}

#[derive(Facet, Debug, PartialEq)]
struct Args {
#[facet(args::named, args::short = 'd')]
debug: bool,
#[facet(args::subcommand)]
command: Command,
}

let args: Args = figue::from_slice(&["repo", "-d"]).unwrap();
assert!(args.debug);
assert_eq!(args.command, Command::Repo(RepoArgs { command: None }));
}

/// Test subcommand with kebab-case variant names
#[test]
fn test_subcommand_kebab_case() {
Expand Down