From ab5c1b36171f7d888cc88581b9a0af1066d5cd4a Mon Sep 17 00:00:00 2001
From: Maciej Dziardziel <fiedzia@gmail.com>
Date: Thu, 14 Nov 2024 22:27:12 +0000
Subject: [PATCH] feat: Group subcommands by help header

---
 clap_builder/src/builder/command.rs      | 39 +++++++++++
 clap_builder/src/output/help_template.rs | 39 +++++++++--
 tests/builder/subcommands.rs             | 84 ++++++++++++++++++++++++
 3 files changed, 155 insertions(+), 7 deletions(-)

diff --git a/clap_builder/src/builder/command.rs b/clap_builder/src/builder/command.rs
index 0bdb6a74da7..775cbca7ee7 100644
--- a/clap_builder/src/builder/command.rs
+++ b/clap_builder/src/builder/command.rs
@@ -101,9 +101,11 @@ pub struct Command {
     subcommands: Vec<Command>,
     groups: Vec<ArgGroup>,
     current_help_heading: Option<Str>,
+    current_subcommand_help_heading: Option<Str>,
     current_disp_ord: Option<usize>,
     subcommand_value_name: Option<Str>,
     subcommand_heading: Option<Str>,
+    subcommand_help_heading: Option<Option<Str>>,
     external_value_parser: Option<super::ValueParser>,
     long_help_exists: bool,
     deferred: Option<fn(Command) -> Command>,
@@ -490,6 +492,9 @@ impl Command {
             subcmd.disp_ord.get_or_insert(current);
             *current_disp_ord = current + 1;
         }
+        subcmd
+            .subcommand_help_heading
+            .get_or_insert_with(|| self.current_subcommand_help_heading.clone());
         self.subcommands.push(subcmd);
         self
     }
@@ -2295,6 +2300,22 @@ impl Command {
         self
     }
 
+    /// Set the default section heading for future subcommands.
+    ///
+    /// This will be used for any subcommand that hasn't had [`Command::subcommand_help_heading`] called.
+    ///
+    /// This is useful if the default `Commands` heading is
+    /// not specific enough for one's use case.
+    ///
+    /// [`Command::subcommand`]: Command::subcommand()
+    /// [`Command::subcommand_help_heading`]: crate::Command::subcommand_help_heading()
+    #[inline]
+    #[must_use]
+    pub fn next_subcommand_help_heading(mut self, heading: impl IntoResettable<Str>) -> Self {
+        self.current_subcommand_help_heading = heading.into_resettable().into_option();
+        self
+    }
+
     /// Change the starting value for assigning future display orders for args.
     ///
     /// This will be used for any arg that hasn't had [`Arg::display_order`] called.
@@ -3753,6 +3774,14 @@ impl Command {
         self.current_help_heading.as_deref()
     }
 
+    /// Get the custom section heading specified via [`Command::next_subcommand_help_heading`].
+    ///
+    /// [`Command::subcommand_help_heading`]: Command::subcommand_help_heading()
+    #[inline]
+    pub fn get_next_subcommand_help_heading(&self) -> Option<&str> {
+        self.current_subcommand_help_heading.as_deref()
+    }
+
     /// Iterate through the *visible* aliases for this subcommand.
     #[inline]
     pub fn get_visible_aliases(&self) -> impl Iterator<Item = &str> + '_ {
@@ -4889,6 +4918,14 @@ impl Command {
             .any(|sc| sc.name != "help" && !sc.is_set(AppSettings::Hidden))
     }
 
+    #[cfg(any(feature = "usage", feature = "help"))]
+    pub(crate) fn needs_commands_header(&self) -> bool {
+        self.subcommands
+            .iter()
+            .filter(|sc| !sc.is_set(AppSettings::Hidden))
+            .any(|sc| sc.subcommand_help_heading.is_none())
+    }
+
     /// Check if this subcommand can be referred to as `name`. In other words,
     /// check if `name` is the name of this subcommand or is one of its aliases.
     #[inline]
@@ -5131,9 +5168,11 @@ impl Default for Command {
             subcommands: Default::default(),
             groups: Default::default(),
             current_help_heading: Default::default(),
+            current_subcommand_help_heading: Default::default(),
             current_disp_ord: Some(0),
             subcommand_value_name: Default::default(),
             subcommand_heading: Default::default(),
+            subcommand_help_heading: Default::default(),
             external_value_parser: Default::default(),
             long_help_exists: false,
             deferred: None,
diff --git a/clap_builder/src/output/help_template.rs b/clap_builder/src/output/help_template.rs
index da08ccd3895..85bb2aa2085 100644
--- a/clap_builder/src/output/help_template.rs
+++ b/clap_builder/src/output/help_template.rs
@@ -406,7 +406,9 @@ impl<'cmd, 'writer> HelpTemplate<'cmd, 'writer> {
                 .cmd
                 .get_subcommand_help_heading()
                 .unwrap_or(&default_help_heading);
-            let _ = write!(self.writer, "{header}{help_heading}:{header:#}\n",);
+            if self.cmd.needs_commands_header() {
+                let _ = write!(self.writer, "{header}{help_heading}:{header:#}\n",);
+            }
 
             self.write_subcommands(self.cmd);
         }
@@ -864,13 +866,14 @@ impl<'cmd, 'writer> HelpTemplate<'cmd, 'writer> {
             .filter(|subcommand| should_show_subcommand(subcommand))
         {
             ord_v.push((
+                subcommand.get_subcommand_help_heading(),
                 subcommand.get_display_order(),
                 subcommand.get_name(),
                 subcommand,
             ));
         }
-        ord_v.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1)));
-        for (_, _, subcommand) in ord_v {
+        ord_v.sort_by(|a, b| (a.0, a.1, &a.2).cmp(&(b.0, b.1, &b.2)));
+        for (_, _, _, subcommand) in ord_v {
             if !*first {
                 self.writer.push_str("\n\n");
             }
@@ -930,19 +933,41 @@ impl<'cmd, 'writer> HelpTemplate<'cmd, 'writer> {
                 let _ = write!(styled, ", {literal}--{long}{literal:#}",);
             }
             longest = longest.max(styled.display_width());
-            ord_v.push((subcommand.get_display_order(), styled, subcommand));
+            ord_v.push((
+                subcommand.get_subcommand_help_heading(),
+                subcommand.get_display_order(),
+                styled,
+                subcommand,
+            ));
         }
-        ord_v.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1)));
+        ord_v.sort_by(|a, b| (a.0, a.1, &a.2).cmp(&(b.0, b.1, &b.2)));
 
         debug!("HelpTemplate::write_subcommands longest = {longest}");
 
         let next_line_help = self.will_subcommands_wrap(cmd.get_subcommands(), longest);
+        let mut current_help_heading = &None;
+        let mut help_heading_nl_needed = true;
 
-        for (i, (_, sc_str, sc)) in ord_v.into_iter().enumerate() {
+        for (i, (opt_help_heading, _, sc_str, sc)) in ord_v.iter().enumerate() {
             if 0 < i {
                 self.writer.push_str("\n");
             }
-            self.write_subcommand(sc_str, sc, next_line_help, longest);
+            if current_help_heading != opt_help_heading {
+                if let Some(help_heading) = opt_help_heading {
+                    let header = &self.styles.get_header();
+                    if help_heading_nl_needed {
+                        help_heading_nl_needed = false;
+                    } else {
+                        self.writer.push_str("\n");
+                    };
+                    let _ = write!(self.writer, "{header}{help_heading}:{header:#}\n",);
+                }
+                current_help_heading = &ord_v[i].0;
+            } else {
+                help_heading_nl_needed = false;
+            }
+
+            self.write_subcommand(sc_str.clone(), sc, next_line_help, longest);
         }
     }
 
diff --git a/tests/builder/subcommands.rs b/tests/builder/subcommands.rs
index 1cf2f6db12a..cbd70f6c656 100644
--- a/tests/builder/subcommands.rs
+++ b/tests/builder/subcommands.rs
@@ -1,5 +1,6 @@
 use clap::{arg, error::ErrorKind, Arg, ArgAction, Command};
 
+
 use super::utils;
 use snapbox::assert_data_eq;
 use snapbox::str;
@@ -630,3 +631,86 @@ fn duplicate_subcommand_alias() {
         .subcommand(Command::new("unique").alias("repeat"))
         .build();
 }
+
+#[test]
+fn subcommand_help_header() {
+    static VISIBLE_ALIAS_HELP: &str = "\
+Usage: clap-test [COMMAND]
+
+Commands:
+  help  Print this message or the help of the given subcommand(s)
+
+Test commands:
+  test  Some help
+
+Options:
+  -h, --help     Print help
+  -V, --version  Print version
+";
+
+    let cmd = Command::new("clap-test").version("2.6").subcommand(
+        Command::new("test")
+            .about("Some help")
+            .subcommand_help_heading("Test commands")
+    );
+    utils::assert_output(cmd, "clap-test --help", VISIBLE_ALIAS_HELP, false);
+}
+
+
+#[test]
+fn subcommand_help_header_hide_commands_header() {
+    static VISIBLE_ALIAS_HELP: &str = "\
+Usage: clap-test [COMMAND]
+
+Test commands:
+  test  Some help
+
+Options:
+  -h, --help     Print help
+  -V, --version  Print version
+";
+
+    let cmd = Command::new("clap-test")
+        .version("2.6")
+        .disable_help_subcommand(true)
+        .subcommand(
+            Command::new("test")
+                .about("Some help")
+                .subcommand_help_heading("Test commands")
+        );
+    utils::assert_output(cmd, "clap-test --help", VISIBLE_ALIAS_HELP, false);
+}
+
+
+#[test]
+fn subcommand_help_header_multiple_help_headers() {
+    static VISIBLE_ALIAS_HELP: &str = "\
+Usage: clap-test [COMMAND]
+
+Test commands 1:
+  test1  Some help
+
+Test commands 2:
+  test2  Some help
+
+Options:
+  -h, --help     Print help
+  -V, --version  Print version
+";
+
+    let cmd = Command::new("clap-test")
+        .version("2.6")
+        .disable_help_subcommand(true)
+        .subcommand(
+            Command::new("test1")
+                .about("Some help")
+                .subcommand_help_heading("Test commands 1")
+        )
+        .subcommand(
+            Command::new("test2")
+                .about("Some help")
+                .subcommand_help_heading("Test commands 2")
+        );
+
+    utils::assert_output(cmd, "clap-test --help", VISIBLE_ALIAS_HELP, false);
+}