Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
### Features

- *(derive)* Group values by their occurrence with `Vec<Vec<T>>`
- *(help)* Add `Command::help_subcommand` to force-generate (or suppress) the `help` subcommand on leaf subcommands, propagating the choice to descendants ([#6227](https://github.com/clap-rs/clap/issues/6227))

<!-- next-header -->
## [Unreleased] - ReleaseDate
Expand Down
93 changes: 91 additions & 2 deletions clap_builder/src/builder/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ pub struct Command {
template: Option<StyledStr>,
settings: AppFlags,
g_settings: AppFlags,
/// Tri-state for `help` subcommand generation (#6227).
///
/// - `None`: inherit prior behavior (auto-generate when child subcommands exist, suppress on leaves).
/// - `Some(true)`: force generation, including on leaf subcommands; propagates to descendants.
/// - `Some(false)`: suppress generation; propagates to descendants.
///
/// Currently consulted only when `unstable-v5` is active; the `AppSettings::DisableHelpSubcommand`
/// bit flag remains the source of truth for stable builds to preserve API compatibility.
help_subcommand: Option<bool>,
args: MKeyMap,
subcommands: Vec<Command>,
groups: Vec<ArgGroup>,
Expand Down Expand Up @@ -1644,14 +1653,73 @@ impl Command {
///
/// [`subcommand`]: crate::Command::subcommand()
#[inline]
pub fn disable_help_subcommand(self, yes: bool) -> Self {
pub fn disable_help_subcommand(mut self, yes: bool) -> Self {
// Mirror the bool into the tri-state field used by the `unstable-v5`
// `Command::help_subcommand` API (#6227). Stable builds continue to
// read the `AppSettings::DisableHelpSubcommand` bit flag below, so
// existing behavior is preserved verbatim.
self.help_subcommand = Some(!yes);
if yes {
self.global_setting(AppSettings::DisableHelpSubcommand)
} else {
self.unset_global_setting(AppSettings::DisableHelpSubcommand)
}
}

/// Generate a `help` subcommand for this command and propagate that choice to all child
/// subcommands.
///
/// This is the tri-state counterpart to [`Command::disable_help_subcommand`]:
///
/// - `true` forces a `help` subcommand to be generated for this command and every descendant,
/// even on leaf subcommands that have no further children of their own.
/// - `false` suppresses the `help` subcommand on this command and every descendant.
/// - `None` (via `Option::<bool>::None` or [`crate::builder::Resettable::Reset`]) clears the
/// choice, falling back to the historical behavior of auto-generating only when
/// non-`help` subcommands are defined.
///
/// This addresses [#6227](https://github.com/clap-rs/clap/issues/6227): out of the box,
/// `help` is only emitted on commands that have other subcommands, so `cmd one help` fails
/// where `cmd help` succeeds. Setting `help_subcommand(true)` on the root command resolves
/// that inconsistency.
///
/// <div class="warning">
///
/// **NOTE:** Available under the `unstable-v5` feature flag while the API is stabilized.
///
/// </div>
///
/// # Examples
///
/// ```rust
/// # use clap_builder as clap;
/// # use clap::{Command, error::ErrorKind};
/// # #[cfg(feature = "unstable-v5")] {
/// let mut cmd = Command::new("myprog")
/// .help_subcommand(true)
/// .subcommand(Command::new("one"))
/// .subcommand(Command::new("two"));
/// // `myprog one help` is now recognized for the leaf subcommand and
/// // short-circuits with `DisplayHelp` just like `myprog one --help` does.
/// let res = cmd.try_get_matches_from_mut(vec!["myprog", "one", "help"]);
/// assert_eq!(res.unwrap_err().kind(), ErrorKind::DisplayHelp);
/// # }
/// ```
#[cfg(feature = "unstable-v5")]
#[inline]
pub fn help_subcommand(mut self, yes: impl IntoResettable<bool>) -> Self {
self.help_subcommand = yes.into_resettable().into_option();
// Keep the legacy bit flag aligned with the tri-state choice so that
// existing reads of `is_disable_help_subcommand_set` stay consistent
// for the command this is set on. Propagation to descendants is
// handled by `_propagate_subcommand`.
match self.help_subcommand {
Some(true) => self.unset_global_setting(AppSettings::DisableHelpSubcommand),
Some(false) => self.global_setting(AppSettings::DisableHelpSubcommand),
None => self,
}
}

/// Disables colorized help messages.
///
/// <div class="warning">
Expand Down Expand Up @@ -4419,7 +4487,16 @@ impl Command {
self.settings.set(AppSettings::AllowExternalSubcommands);
}
if !self.has_subcommands() {
self.settings.set(AppSettings::DisableHelpSubcommand);
// #6227: under `unstable-v5`, an explicit `help_subcommand(true)` (set on
// this command or propagated from an ancestor) keeps the `help` subcommand
// on leaves so `cmd one help` works the same as `cmd help`.
#[cfg(feature = "unstable-v5")]
let force_help_subcommand = self.help_subcommand == Some(true);
#[cfg(not(feature = "unstable-v5"))]
let force_help_subcommand = false;
if !force_help_subcommand {
self.settings.set(AppSettings::DisableHelpSubcommand);
}
}

self._propagate();
Expand Down Expand Up @@ -4796,6 +4873,17 @@ impl Command {
sc.settings = sc.settings | self.g_settings;
sc.g_settings = sc.g_settings | self.g_settings;
sc.app_ext.update(&self.app_ext);

// #6227: propagate an explicit help-subcommand choice from parent to child
// without overriding an explicit choice already made on the child. Using
// `get_or_insert` matches epage's design comment exactly:
// `if self.help_subcommand.unwrap_or(false) { sc.help_subcommand.get_or_insert(true); }`
#[cfg(feature = "unstable-v5")]
{
if let Some(parent_choice) = self.help_subcommand {
sc.help_subcommand.get_or_insert(parent_choice);
}
}
}
}

Expand Down Expand Up @@ -5217,6 +5305,7 @@ impl Default for Command {
template: Default::default(),
settings: Default::default(),
g_settings: Default::default(),
help_subcommand: Default::default(),
args: Default::default(),
subcommands: Default::default(),
groups: Default::default(),
Expand Down
15 changes: 15 additions & 0 deletions clap_builder/src/builder/resettable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,21 @@ pub trait IntoResettable<T> {
fn into_resettable(self) -> Resettable<T>;
}

impl IntoResettable<bool> for Option<bool> {
fn into_resettable(self) -> Resettable<bool> {
match self {
Some(s) => Resettable::Value(s),
None => Resettable::Reset,
}
}
}

impl IntoResettable<bool> for bool {
fn into_resettable(self) -> Resettable<bool> {
Resettable::Value(self)
}
}

impl IntoResettable<char> for Option<char> {
fn into_resettable(self) -> Resettable<char> {
match self {
Expand Down
142 changes: 142 additions & 0 deletions tests/builder/help_subcommand.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
use clap::{error::ErrorKind, Command};

// #6227: regression guard for the default behavior (no opt-in).
// Without `help_subcommand(true)`, the root still gets a `help` subcommand
// because it has children, but leaf children do not.
#[test]
fn default_behavior_unchanged_leaf_has_no_help_subcommand() {
let mut cmd = Command::new("myprog")
.subcommand(Command::new("one"))
.subcommand(Command::new("two"));

// Root accepts `help`.
let res_root = cmd.try_get_matches_from_mut(vec!["myprog", "help"]);
assert!(
res_root.is_err(),
"root `help` should short-circuit with DisplayHelp"
);
assert_eq!(res_root.unwrap_err().kind(), ErrorKind::DisplayHelp);

// Leaf `one` does NOT have `help`.
let res_leaf = cmd.try_get_matches_from_mut(vec!["myprog", "one", "help"]);
assert!(res_leaf.is_err(), "leaf `one help` should fail by default");
let err = res_leaf.unwrap_err();
assert!(
matches!(
err.kind(),
ErrorKind::UnknownArgument | ErrorKind::InvalidSubcommand
),
"expected UnknownArgument or InvalidSubcommand, got {:?}",
err.kind()
);
}

// #6227: enabled at root, leaves get `help` too.
#[cfg(feature = "unstable-v5")]
#[test]
fn help_subcommand_true_propagates_to_leaves() {
let mut cmd = Command::new("myprog")
.help_subcommand(true)
.subcommand(Command::new("one"))
.subcommand(Command::new("two"));

let res_one = cmd.try_get_matches_from_mut(vec!["myprog", "one", "help"]);
assert!(
res_one.is_err(),
"with help_subcommand(true), `one help` should short-circuit with DisplayHelp"
);
assert_eq!(res_one.unwrap_err().kind(), ErrorKind::DisplayHelp);

let res_two = cmd.try_get_matches_from_mut(vec!["myprog", "two", "help"]);
assert!(res_two.is_err());
assert_eq!(res_two.unwrap_err().kind(), ErrorKind::DisplayHelp);
}

// #6227: enabled at root, nested leaves also get `help`.
#[cfg(feature = "unstable-v5")]
#[test]
fn help_subcommand_true_propagates_through_nested_subcommands() {
let mut cmd = Command::new("myprog").help_subcommand(true).subcommand(
Command::new("outer")
.subcommand(Command::new("inner_a"))
.subcommand(Command::new("inner_b")),
);

let res = cmd.try_get_matches_from_mut(vec!["myprog", "outer", "inner_a", "help"]);
assert!(res.is_err(), "deeply nested leaf should also accept `help`");
assert_eq!(res.unwrap_err().kind(), ErrorKind::DisplayHelp);
}

// #6227: explicit `false` on a child overrides parent's `true`.
#[cfg(feature = "unstable-v5")]
#[test]
fn help_subcommand_child_override_wins_over_parent() {
let mut cmd = Command::new("myprog").help_subcommand(true).subcommand(
Command::new("quiet")
.help_subcommand(false)
.subcommand(Command::new("leaf")),
);

// `quiet` itself has children, but `help_subcommand(false)` suppresses it.
let res = cmd.try_get_matches_from_mut(vec!["myprog", "quiet", "help"]);
assert!(
res.is_err(),
"child `help_subcommand(false)` should suppress `help` even when parent enabled it"
);
let err = res.unwrap_err();
assert!(
matches!(
err.kind(),
ErrorKind::UnknownArgument | ErrorKind::InvalidSubcommand
),
"expected UnknownArgument or InvalidSubcommand, got {:?}",
err.kind()
);
}

// #6227: `Option::<bool>::None` resets the tri-state choice.
#[cfg(feature = "unstable-v5")]
#[test]
fn help_subcommand_reset_via_none() {
// First set to true, then reset to None: default behavior is restored, so
// leaf `one` no longer accepts `help`.
let mut cmd = Command::new("myprog")
.help_subcommand(true)
.help_subcommand(Option::<bool>::None)
.subcommand(Command::new("one"));

let res = cmd.try_get_matches_from_mut(vec!["myprog", "one", "help"]);
assert!(
res.is_err(),
"after reset, leaf `one help` should fail like default"
);
let err = res.unwrap_err();
assert!(
matches!(
err.kind(),
ErrorKind::UnknownArgument | ErrorKind::InvalidSubcommand
),
"expected UnknownArgument or InvalidSubcommand, got {:?}",
err.kind()
);
}

// #6227: explicit `Resettable::Reset` is accepted via the IntoResettable path.
#[cfg(feature = "unstable-v5")]
#[test]
fn help_subcommand_reset_via_resettable() {
use clap::builder::Resettable;

let mut cmd = Command::new("myprog")
.help_subcommand(true)
.help_subcommand(Resettable::<bool>::Reset)
.subcommand(Command::new("one"));

let res = cmd.try_get_matches_from_mut(vec!["myprog", "one", "help"]);
assert!(res.is_err());
let err = res.unwrap_err();
assert!(matches!(
err.kind(),
ErrorKind::UnknownArgument | ErrorKind::InvalidSubcommand
));
}
Loading