Repository: google/argh
Pull request #201 (2025-05-09 14:27:47)

Add attributes to configure usage line in --help
Consider the cargo build as an example:

```
$ cargo build --help
Usage: cargo build [OPTIONS]

Options:
      --future-incompat-report  Outputs a future incompatibility report at the end of the build
      --message-format <FMT>    Error format
  -v, --verbose...              Use verbose output (-vv very verbose/build.rs output)
  -q, --quiet                   Do not print cargo log messages
      --color <WHEN>            Coloring: auto, always, never
      --config <KEY=VALUE>      Override a configuration value
  -Z <FLAG>                     Unstable (nightly-only) flags to Cargo, see 'cargo -Z help' for details
  -h, --help                    Print help
...
```

Currently with how argh generate usage line this would look like:

```
Usage: cargo build [--future-incompat-report] [--message-format <fmt>] [-v] [-q] [--color <when>] [--config <key=value>] [-Z <flag>]
...
```

It's too long, barely readable, and also looks terrible when it gets split in multiple lines by the terminal. And there's no option around it.

We already provide an exhausting information in help. So I suggest adding an option to only outline the most useful parameters in usage, for example the following:

```
Usage: cargo build [-v] [-q] [--bin <name>] [--release]
```

This is done by adding `usage` attribute to fields we want to see in usage, other option are then hidden from the usage line by default.

```rust
#[derive(FromArgs)]
struct CliArgs {
...
    #[argh(option)]
    #[argh(usage)] // <--
    release: bool,
...
}
```

There is also a catch-all option to override the usage line entirely, so it's possible to reproduce what cargo build command has.

```rust
#[derive(FromArgs)]
#[argh(usage = "[OPTIONS]")] // <--
struct CliArgs {
...
}
```

Producing:

```
Usage: cargo build [OPTIONS]
```

I also rearranged components in the generated usage line and added a `[--]` hint. Previously positional arguments went first before options, which is kinda misleading, since positionals may themselves start with `-`.

So without subcommand it's now `[options...] [--] [positional...]` and with it `[options...] [positional...] [subcommand...]`.

The fuchsia cli guide doesn't specify on this. And I hasn't found any reason for why it wasn't that way before.



Patch:
diff --git a/argh_derive/src/help.rs b/argh_derive/src/help.rs
index 3f52331..99798b7 100644
--- a/argh_derive/src/help.rs
+++ b/argh_derive/src/help.rs
@@ -33,37 +33,53 @@ pub(crate) fn help(
     let positional = fields.iter().filter(|f| {
         f.kind == FieldKind::Positional && f.attrs.greedy.is_none() && !f.attrs.hidden_help
     });
-    let mut has_positional = false;
-    for arg in positional.clone() {
-        has_positional = true;
-        format_lit.push(' ');
-        positional_usage(&mut format_lit, arg);
-    }
-
+    let has_positional = positional.clone().next().is_some();
     let options = fields.iter().filter(|f| f.long_name.is_some() && !f.attrs.hidden_help);
-    for option in options.clone() {
-        format_lit.push(' ');
-        option_usage(&mut format_lit, option);
-    }
 
-    let remain = fields.iter().filter(|f| {
-        f.kind == FieldKind::Positional && f.attrs.greedy.is_some() && !f.attrs.hidden_help
-    });
-    for arg in remain {
+    if let Some(usage) = &ty_attrs.usage {
         format_lit.push(' ');
-        positional_usage(&mut format_lit, arg);
-    }
+        format_lit.push_str(&usage.value());
+    } else {
+        let has_explicit_usage = std::iter::empty()
+            .chain(positional.clone())
+            .chain(options.clone())
+            .any(|p| p.attrs.usage);
+        let positional = positional.clone().filter(|p| !has_explicit_usage || p.attrs.usage);
+        let options = options.clone().filter(|p| !has_explicit_usage || p.attrs.usage);
 
-    if let Some(subcommand) = subcommand {
-        format_lit.push(' ');
-        if !subcommand.optionality.is_required() {
-            format_lit.push('[');
+        for option in options.clone() {
+            format_lit.push(' ');
+            option_usage(&mut format_lit, option);
         }
-        format_lit.push_str("<command>");
-        if !subcommand.optionality.is_required() {
-            format_lit.push(']');
+
+        if has_positional && subcommand.is_none() {
+            format_lit.push_str(" [--]");
+        }
+
+        for arg in positional.clone() {
+            format_lit.push(' ');
+            positional_usage(&mut format_lit, arg);
+        }
+
+        let remain = fields.iter().filter(|f| {
+            f.kind == FieldKind::Positional && f.attrs.greedy.is_some() && !f.attrs.hidden_help
+        });
+        for arg in remain {
+            format_lit.push(' ');
+            positional_usage(&mut format_lit, arg);
+        }
+
+        if let Some(subcommand) = subcommand {
+            format_lit.push(' ');
+            if !subcommand.optionality.is_required() {
+                format_lit.push('[');
+            }
+            format_lit.push_str("<command>");
+            if !subcommand.optionality.is_required() {
+                format_lit.push(']');
+            }
+            format_lit.push_str(" [<args>]");
         }
-        format_lit.push_str(" [<args>]");
     }
 
     format_lit.push_str(SECTION_SEPARATOR);
diff --git a/argh_derive/src/parse_attrs.rs b/argh_derive/src/parse_attrs.rs
index bd9677f..cd192db 100644
--- a/argh_derive/src/parse_attrs.rs
+++ b/argh_derive/src/parse_attrs.rs
@@ -22,6 +22,7 @@ pub struct FieldAttrs {
     pub arg_name: Option<syn::LitStr>,
     pub greedy: Option<syn::Path>,
     pub hidden_help: bool,
+    pub usage: bool,
 }
 
 /// The purpose of a particular field on a `#![derive(FromArgs)]` struct.
@@ -126,13 +127,15 @@ impl FieldAttrs {
                     this.greedy = Some(name.clone());
                 } else if name.is_ident("hidden_help") {
                     this.hidden_help = true;
+                } else if name.is_ident("usage") {
+                    this.usage = true;
                 } else {
                     errors.err(
                         &meta,
                         concat!(
                             "Invalid field-level `argh` attribute\n",
                             "Expected one of: `arg_name`, `default`, `description`, `from_str_fn`, `greedy`, ",
-                            "`long`, `option`, `short`, `subcommand`, `switch`, `hidden_help`",
+                            "`long`, `option`, `short`, `subcommand`, `switch`, `hidden_help`, `usage`",
                         ),
                     );
                 }
@@ -280,6 +283,7 @@ pub struct TypeAttrs {
     pub error_codes: Vec<(syn::LitInt, syn::LitStr)>,
     /// Arguments that trigger printing of the help message
     pub help_triggers: Option<Vec<syn::LitStr>>,
+    pub usage: Option<syn::LitStr>,
 }
 
 impl TypeAttrs {
@@ -330,13 +334,17 @@ impl TypeAttrs {
                     if let Some(m) = errors.expect_meta_list(&meta) {
                         Self::parse_help_triggers(m, errors, &mut this);
                     }
+                } else if name.is_ident("usage") {
+                    if let Some(m) = errors.expect_meta_name_value(&meta) {
+                        this.parse_attr_usage(errors, m);
+                    }
                 } else {
                     errors.err(
                         &meta,
                         concat!(
                             "Invalid type-level `argh` attribute\n",
                             "Expected one of: `description`, `error_code`, `example`, `name`, ",
-                            "`note`, `subcommand`",
+                            "`note`, `subcommand`, `usage`",
                         ),
                     );
                 }
@@ -436,6 +444,10 @@ impl TypeAttrs {
             Err(err) => errors.push(err),
         }
     }
+
+    fn parse_attr_usage(&mut self, errors: &Errors, m: &syn::MetaNameValue) {
+        parse_attr_single_string(errors, m, "usage", &mut self.usage)
+    }
 }
 
 /// Represents a `FromArgs` enum variant's attributes.
@@ -657,8 +669,11 @@ fn unescape_doc(s: String) -> String {
 }
 
 fn parse_attr_description(errors: &Errors, m: &syn::MetaNameValue, slot: &mut Option<Description>) {
-    let lit_str =
-        if let Some(lit_str) = errors.expect_lit_str(&m.value) { lit_str } else { return };
+    let lit_str = if let Some(lit_str) = errors.expect_lit_str(&m.value) {
+        lit_str
+    } else {
+        return;
+    };
 
     // Don't allow multiple explicit (non doc-comment) descriptions
     if let Some(description) = slot {
@@ -673,8 +688,16 @@ fn parse_attr_description(errors: &Errors, m: &syn::MetaNameValue, slot: &mut Op
 /// Checks that a `#![derive(FromArgs)]` enum has an `#[argh(subcommand)]`
 /// attribute and that it does not have any other type-level `#[argh(...)]` attributes.
 pub fn check_enum_type_attrs(errors: &Errors, type_attrs: &TypeAttrs, type_span: &Span) {
-    let TypeAttrs { is_subcommand, name, description, examples, notes, error_codes, help_triggers } =
-        type_attrs;
+    let TypeAttrs {
+        is_subcommand,
+        name,
+        description,
+        examples,
+        notes,
+        error_codes,
+        help_triggers,
+        usage,
+    } = type_attrs;
 
     // Ensure that `#[argh(subcommand)]` is present.
     if is_subcommand.is_none() {
@@ -711,6 +734,9 @@ pub fn check_enum_type_attrs(errors: &Errors, type_attrs: &TypeAttrs, type_span:
             err_unused_enum_attr(errors, trigger);
         }
     }
+    if let Some(usage) = usage {
+        err_unused_enum_attr(errors, usage);
+    }
 }
 
 fn err_unused_enum_attr(errors: &Errors, location: &impl syn::spanned::Spanned) {


Test patch:
diff --git a/argh/tests/lib.rs b/argh/tests/lib.rs
index 831e18a..a508f17 100644
--- a/argh/tests/lib.rs
+++ b/argh/tests/lib.rs
@@ -631,7 +631,7 @@ mod positional {
             LastRepeating { a: 5, b: vec!["foo".into(), "bar".into()] },
         );
         assert_help_string::<LastRepeating>(
-            r###"Usage: test_arg_0 <a> [<b...>]
+            r###"Usage: test_arg_0 [--] <a> [<b...>]
 
 Woot
 
@@ -714,7 +714,7 @@ Options:
             },
         );
         assert_help_string::<LastRepeatingGreedy>(
-            r###"Usage: test_arg_0 <a> [--b] [--c <c>] [d...]
+            r###"Usage: test_arg_0 [--b] [--c <c>] [--] <a> [d...]
 
 Woot
 
@@ -1368,7 +1368,7 @@ Error codes:
     #[test]
     fn with_arg_name() {
         assert_help_string::<WithArgName>(
-            r###"Usage: test_arg_0 <name>
+            r###"Usage: test_arg_0 [--] <name>
 
 Destroy the contents of <file>.
 
@@ -1398,7 +1398,7 @@ Options:
         }
 
         assert_help_string::<Cmd>(
-            r###"Usage: test_arg_0 <two>
+            r###"Usage: test_arg_0 [--] <two>
 
 Short description
 
@@ -1884,3 +1884,56 @@ fn long_alphanumeric() {
     let cmd = Cmd::from_args(&["cmdname"], &["--ac97", "bar"]).unwrap();
     assert_eq!(cmd.ac97, "bar");
 }
+
+#[test]
+fn override_usage() {
+    /// Height options
+    #[derive(FromArgs)]
+    #[argh(help_triggers("-h", "--help", "help"))]
+    #[argh(usage = "USAGE LINE")]
+    struct Height {
+        /// how high to go
+        #[argh(option)]
+        _height: usize,
+    }
+
+    assert_help_string::<Height>(
+        r#"Usage: test_arg_0 USAGE LINE
+
+Height options
+
+Options:
+  --height          how high to go
+  -h, --help, help  display usage information
+"#,
+    );
+}
+
+#[test]
+fn customize_usage() {
+    /// Height options
+    #[derive(FromArgs)]
+    #[argh(help_triggers("-h", "--help", "help"))]
+    struct Height {
+        /// how high to go
+        #[argh(option)]
+        #[argh(usage)]
+        _height: usize,
+
+        /// hidden from usage
+        #[argh(option)]
+        _hidden: usize,
+    }
+
+    assert_help_string::<Height>(
+        r#"Usage: test_arg_0 --height <height>
+
+Height options
+
+Options:
+  --height          how high to go
+  --hidden          hidden from usage
+  -h, --help, help  display usage information
+"#,
+    );
+}