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

Commit 7ae215d

Browse files
committed
Merge branch 'main' into teamy-positional-bug
2 parents 77d6551 + 9673321 commit 7ae215d

6 files changed

Lines changed: 240 additions & 8 deletions

crates/figue/src/driver.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,72 @@ impl<T: Facet<'static>> Driver<T> {
419419
return DriverOutcome::err(DriverError::Help { text: help });
420420
}
421421

422+
// Check if the only missing field is a subcommand with available variants
423+
// (covers nested subcommands like `tracey query` missing a sub-subcommand)
424+
let missing_subcommand_with_variants = !has_unknown
425+
&& missing_fields.len() == 1
426+
&& !missing_fields[0].available_subcommands.is_empty();
427+
428+
if missing_subcommand_with_variants {
429+
let field = &missing_fields[0];
430+
431+
// Format the available subcommands list
432+
let items: Vec<(String, Option<&str>)> = field
433+
.available_subcommands
434+
.iter()
435+
.map(|sub| (sub.name.clone(), sub.doc.as_deref()))
436+
.collect();
437+
438+
// Find max width for alignment
439+
let max_width = items.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
440+
let mut cmds = String::new();
441+
for (name, doc) in &items {
442+
use std::fmt::Write;
443+
write!(cmds, " {name}").unwrap();
444+
let padding = max_width.saturating_sub(name.len());
445+
for _ in 0..padding {
446+
cmds.push(' ');
447+
}
448+
if let Some(doc) = doc {
449+
write!(cmds, " {}", doc.trim()).unwrap();
450+
}
451+
cmds.push('\n');
452+
}
453+
454+
let mut diagnostics = vec![Diagnostic {
455+
message: format!(
456+
"expected a subcommand\n\navailable subcommands:\n{}",
457+
cmds.trim_end()
458+
),
459+
label: None,
460+
path: None,
461+
span: None,
462+
severity: Severity::Error,
463+
}];
464+
465+
// Add help hint if the schema has a help field
466+
if self.config.schema.special().help.is_some() {
467+
diagnostics.push(Diagnostic {
468+
message: "Run with --help for usage information.".to_string(),
469+
label: None,
470+
path: None,
471+
span: None,
472+
severity: Severity::Note,
473+
});
474+
}
475+
476+
return DriverOutcome::err(DriverError::Failed {
477+
report: Box::new(DriverReport {
478+
diagnostics,
479+
layers,
480+
file_resolution,
481+
overrides,
482+
cli_args_source: cli_args_display.to_string(),
483+
source_name: "<cli>".to_string(),
484+
}),
485+
});
486+
}
487+
422488
// Check if all missing fields are simple CLI arguments (not config fields)
423489
// Use the proper kind field to distinguish between CLI args and config fields
424490
let all_cli_missing = has_missing

crates/figue/src/missing.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ pub enum MissingFieldKind {
4242
ConfigField,
4343
}
4444

45+
/// A available subcommand name and its doc summary.
46+
#[derive(Debug, Clone)]
47+
pub struct AvailableSubcommand {
48+
/// CLI name (kebab-case)
49+
pub name: String,
50+
/// Doc summary if available
51+
pub doc: Option<String>,
52+
}
53+
4554
/// Information about a missing required field.
4655
#[derive(Debug, Clone)]
4756
pub struct MissingFieldInfo {
@@ -61,6 +70,9 @@ pub struct MissingFieldInfo {
6170
pub env_aliases: Vec<String>,
6271
/// What kind of field this is - determines error formatting strategy
6372
pub kind: MissingFieldKind,
73+
/// If this field is a subcommand, the available subcommands.
74+
/// Non-empty only when the missing field is a subcommand field.
75+
pub available_subcommands: Vec<AvailableSubcommand>,
6476
}
6577

6678
/// Information about a corrected command with missing arguments for display.
@@ -189,6 +201,7 @@ pub fn collect_missing_fields(
189201
env_var: None,
190202
env_aliases: Vec::new(),
191203
kind: MissingFieldKind::ConfigField,
204+
available_subcommands: Vec::new(),
192205
});
193206
}
194207
} else {
@@ -230,6 +243,7 @@ fn collect_missing_in_arg_level(
230243
env_var: None, // CLI args don't have env vars
231244
env_aliases: Vec::new(),
232245
kind: MissingFieldKind::CliArg,
246+
available_subcommands: Vec::new(),
233247
});
234248
}
235249
}
@@ -243,6 +257,16 @@ fn collect_missing_in_arg_level(
243257
} else {
244258
format!("{}.{}", path_prefix, subcommand_field)
245259
};
260+
261+
let available_subcommands: Vec<AvailableSubcommand> = arg_level
262+
.subcommands()
263+
.iter()
264+
.map(|(_, sub)| AvailableSubcommand {
265+
name: sub.cli_name().to_string(),
266+
doc: sub.docs().summary().map(|s| s.to_string()),
267+
})
268+
.collect();
269+
246270
missing.push(MissingFieldInfo {
247271
field_name: subcommand_field.to_string(),
248272
field_path,
@@ -252,6 +276,7 @@ fn collect_missing_in_arg_level(
252276
env_var: None,
253277
env_aliases: Vec::new(),
254278
kind: MissingFieldKind::CliArg,
279+
available_subcommands,
255280
});
256281
} else if let Some(ConfigValue::Enum(sourced)) = obj_map.get(subcommand_field) {
257282
// Subcommand is present - recursively check its arguments
@@ -411,6 +436,7 @@ fn collect_missing_in_config_value(
411436
env_var: None,
412437
env_aliases: field_schema.env_aliases().to_vec(),
413438
kind: MissingFieldKind::ConfigField,
439+
available_subcommands: Vec::new(),
414440
});
415441
}
416442
}
@@ -493,6 +519,7 @@ fn check_missing_field(
493519
env_var,
494520
env_aliases: field_schema.env_aliases().to_vec(),
495521
kind: MissingFieldKind::ConfigField,
522+
available_subcommands: Vec::new(),
496523
});
497524
}
498525
}
Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
---
22
source: crates/figue/tests/integration/subcommand.rs
3-
expression: "$crate :: common :: strip_ansi(& err.to_string())"
3+
expression: "strip_ansi_escapes :: strip_str(& err.to_string())"
44
---
5-
Error: missing required argument
6-
╭─[ <suggestion>:1:4 ]
7-
8-
1 │ ci <action>
9-
│ ────┬───
10-
│ ╰───── missing required argument
11-
───╯
5+
Error: expected a subcommand
6+
7+
available subcommands:
8+
generate Generate CI workflow files from Rust code
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
source: crates/figue/tests/integration/subcommand_errors.rs
3+
expression: "strip_ansi_escapes :: strip_str(& err.to_string())"
4+
---
5+
Error: expected a subcommand
6+
7+
available subcommands:
8+
status Coverage overview
9+
uncovered List rules without implementation references
10+
untested List rules without verification references
11+
unmapped Show unmapped code units
12+
rule Show details about a specific rule
13+
config Display current configuration
14+
validate Validate the spec and implementation
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
source: crates/figue/tests/integration/subcommand_errors.rs
3+
expression: "strip_ansi_escapes :: strip_str(& err.to_string())"
4+
---
5+
Error: expected a subcommand
6+
7+
available subcommands:
8+
clone Clone a repository
9+
push Push changes
10+
pull Pull changes
11+
Note: Run with --help for usage information.

crates/figue/tests/integration/subcommand_errors.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,3 +408,120 @@ fn test_subcommand_positional_looks_like_flag() {
408408
let err = figue::from_slice::<Cli>(&["create", "--help"]).unwrap_err();
409409
assert_diag_snapshot!(err);
410410
}
411+
412+
// ============================================================================
413+
// Test Case 11: Nested subcommand not provided (lists available subcommands)
414+
// ============================================================================
415+
416+
#[test]
417+
fn test_nested_subcommand_not_provided() {
418+
#[derive(Facet, Debug)]
419+
struct Cli {
420+
#[facet(args::subcommand)]
421+
command: Command,
422+
}
423+
424+
#[derive(Facet, Debug)]
425+
#[repr(u8)]
426+
#[allow(dead_code)]
427+
enum Command {
428+
/// Query operations
429+
Query(QueryArgs),
430+
/// Serve the dashboard
431+
Serve,
432+
}
433+
434+
#[derive(Facet, Debug)]
435+
struct QueryArgs {
436+
#[facet(args::subcommand)]
437+
action: QueryAction,
438+
}
439+
440+
#[derive(Facet, Debug)]
441+
#[repr(u8)]
442+
#[allow(dead_code)]
443+
enum QueryAction {
444+
/// Coverage overview
445+
Status,
446+
/// List rules without implementation references
447+
Uncovered,
448+
/// List rules without verification references
449+
Untested,
450+
/// Show unmapped code units
451+
Unmapped,
452+
/// Show details about a specific rule
453+
Rule(RuleArgs),
454+
/// Display current configuration
455+
Config,
456+
/// Validate the spec and implementation
457+
Validate,
458+
}
459+
460+
#[derive(Facet, Debug)]
461+
struct RuleArgs {
462+
/// Rule ID to look up
463+
#[facet(args::positional)]
464+
rule_id: String,
465+
}
466+
467+
// User types: "cli query" without specifying which query subcommand
468+
// Should list the available subcommands for the query level
469+
let err = figue::from_slice::<Cli>(&["query"]).unwrap_err();
470+
assert_diag_snapshot!(err);
471+
}
472+
473+
// ============================================================================
474+
// Test Case 12: Nested subcommand not provided (with FigueBuiltins)
475+
// ============================================================================
476+
477+
#[test]
478+
fn test_nested_subcommand_not_provided_with_builtins() {
479+
#[derive(Facet, Debug)]
480+
struct Cli {
481+
#[facet(args::subcommand)]
482+
command: Command,
483+
484+
#[facet(flatten)]
485+
builtins: args::FigueBuiltins,
486+
}
487+
488+
#[derive(Facet, Debug)]
489+
#[repr(u8)]
490+
#[allow(dead_code)]
491+
enum Command {
492+
/// Repository operations
493+
Repo(RepoArgs),
494+
/// Build the project
495+
Build,
496+
}
497+
498+
#[derive(Facet, Debug)]
499+
struct RepoArgs {
500+
#[facet(args::subcommand)]
501+
action: RepoAction,
502+
}
503+
504+
#[derive(Facet, Debug)]
505+
#[repr(u8)]
506+
#[allow(dead_code)]
507+
enum RepoAction {
508+
/// Clone a repository
509+
Clone(CloneArgs),
510+
/// Push changes
511+
Push,
512+
/// Pull changes
513+
Pull,
514+
}
515+
516+
#[derive(Facet, Debug)]
517+
struct CloneArgs {
518+
/// Repository URL
519+
#[facet(args::positional)]
520+
url: String,
521+
}
522+
523+
// User types: "cli repo" without specifying which repo action
524+
// Should list clone, push, pull as available subcommands
525+
let err = figue::from_slice::<Cli>(&["repo"]).unwrap_err();
526+
assert_diag_snapshot!(err);
527+
}

0 commit comments

Comments
 (0)