From 4c588ece9bd0d056cfdb4484466d2ba38a254689 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:47:17 +0000 Subject: [PATCH] feat(derive): read the package's version, and be called what the binary is called MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two papercuts from the communique port, both of which made an adopter write something twice or get an answer they did not mean. `#[usage(version)]` with no value now reads `CARGO_PKG_VERSION`, as clap's bare `#[command(version)]` does. It has to expand in the *adopter's* crate — usage-derive has a version of its own and it is not the answer — so the attribute carries tokens rather than a string, the same shape `effect` uses and for the same reason. Writing one out still works and still wins. And a program is called what its binary is called. The name defaulted to the struct's, a struct is usually called `Cli`, so `bin = "communique"` with no `name` bannered itself `cli 1.3.1` — and every adopter had to declare the same word twice to avoid it. Only a default: a CLI whose program name really does differ from its binary says so and is believed. Co-Authored-By: Claude Opus 5 --- conformance/tests/program_identity.rs | 79 +++++++++++++++++++++++++++ derive/src/codegen.rs | 5 +- derive/src/model.rs | 34 +++++++++++- 3 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 conformance/tests/program_identity.rs diff --git a/conformance/tests/program_identity.rs b/conformance/tests/program_identity.rs new file mode 100644 index 000000000..e46ddcb2f --- /dev/null +++ b/conformance/tests/program_identity.rs @@ -0,0 +1,79 @@ +//! What a program calls itself, and what version it reports. +//! +//! Two papercuts the communique port hit, both of which made an adopter write something twice +//! or get an answer they did not mean. + +use usage::Spec as LibSpec; +use usage_derive::Cli; + +/// A tool named after its binary +/// +/// `bin` is given and `name` is not — which is the ordinary case, and used to banner itself +/// `cli 0.0.0` after the struct. +#[derive(Cli)] +#[usage(bin = "communique", version)] +struct Cli_ { + #[usage(long)] + plain: bool, +} + +/// A tool that names itself something else +#[derive(Cli)] +#[usage(name = "the-tool", bin = "tool", version = "9.9")] +struct Renamed { + #[usage(long)] + plain: bool, +} + +#[test] +fn a_program_is_called_what_its_binary_is_called() { + // The name defaults to the struct's, and a struct is usually called `Cli`. So `bin` alone + // produced `name cli`, the banner read `cli 1.3.1`, and communique had to declare the same + // word twice to avoid it. + let spec: LibSpec = Cli_::to_kdl().parse().expect("valid spec"); + assert_eq!(spec.name, "communique"); + assert_eq!(spec.bin, "communique"); + + let page = usage_argv::help::render(Cli_::spec(), Cli_::spec().root.cmd, false).expect("page"); + assert!(page.starts_with("communique "), "{page}"); +} + +#[test] +fn a_declared_name_still_wins() { + // The default is only a default: a CLI whose program name differs from its binary says so + // and is believed. + let spec: LibSpec = Renamed::to_kdl().parse().expect("valid spec"); + assert_eq!(spec.name, "the-tool"); + assert_eq!(spec.bin, "tool"); +} + +#[test] +fn a_bare_version_is_the_packages_own() { + // `#[usage(version)]` with no value, as clap spells it. The expansion has to reach for + // `CARGO_PKG_VERSION` in the *adopter's* crate — usage-derive has a version of its own and + // it is not the answer — so this is the conformance crate's version, whatever it is. + let spec: LibSpec = Cli_::to_kdl().parse().expect("valid spec"); + assert_eq!(spec.version.as_deref(), Some(env!("CARGO_PKG_VERSION"))); + + // And it reaches the flag, which is the point of declaring one at all. + use std::ffi::OsStr; + let argv = [OsStr::new("--version")]; + assert!(matches!( + Cli_::parse_from(&argv), + Err(usage_argv::Error::Version) + )); +} + +#[test] +fn a_written_version_is_taken_as_written() { + let spec: LibSpec = Renamed::to_kdl().parse().expect("valid spec"); + assert_eq!(spec.version.as_deref(), Some("9.9")); +} + +#[test] +fn the_fields_are_bound() { + use std::ffi::OsStr; + let argv = [OsStr::new("--plain")]; + assert!(Cli_::parse_from(&argv).expect("should parse").plain); + assert!(Renamed::parse_from(&argv).expect("should parse").plain); +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index e917b40f1..ba5cf6e88 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -70,7 +70,10 @@ pub fn emit(cli: &Cli) -> TokenStream { let name = &cli.name; let bin = option_str(cli.bin.as_deref()); - let version = option_str(cli.version.as_deref()); + let version = match &cli.version { + Some(tokens) => quote!(::core::option::Option::Some(#tokens)), + None => quote!(::core::option::Option::None), + }; let about = option_str(cli.about.as_deref()); let long_about = option_str(cli.long_about.as_deref()); diff --git a/derive/src/model.rs b/derive/src/model.rs index c17cb5be9..282c094d4 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -20,7 +20,13 @@ pub struct Cli { pub fingerprint: String, pub name: String, pub bin: Option, - pub version: Option, + /// The version the CLI reports, as the tokens for an `Option<&str>`. + /// + /// Tokens rather than a string because bare `version` means "the package's", and + /// `env!("CARGO_PKG_VERSION")` has to be expanded in the *adopter's* crate — this one has + /// its own version and it is not the answer. clap's bare `#[command(version)]` reads it the + /// same way. + pub version: Option, /// Whether this CLI answers a completion request. /// /// Opt in rather than supplied like `--help`: it is a hidden command a binary carries and a @@ -314,6 +320,7 @@ impl Cli { )); } + let mut name_given = false; let (about, long_about) = doc_comment(&input.attrs)?; let mut cli = Cli { ident: input.ident.clone(), @@ -349,7 +356,10 @@ impl Cli { for meta in nested(attr)? { let path = meta.path().clone(); match ident_of(&path).as_str() { - "name" => cli.name = string_value(&meta)?, + "name" => { + cli.name = string_value(&meta)?; + name_given = true; + } "bin" => cli.bin = Some(string_value(&meta)?), // Through the same helper as `global` and `var`, so `completion = false` // means false rather than being read as the bare word with something @@ -358,7 +368,16 @@ impl Cli { "settings" => cli.settings = flag_value(&meta)?, "effect" => cli.effect = Some(effect_value(&meta)?), "min_usage_version" => cli.min_usage_version = Some(string_value(&meta)?), - "version" => cli.version = Some(string_value(&meta)?), + "version" => { + cli.version = Some(match &meta { + // `version` on its own: whatever the adopter's package says. + Meta::Path(_) => quote::quote!(env!("CARGO_PKG_VERSION")), + _ => { + let literal = string_value(&meta)?; + quote::quote!(#literal) + } + }) + } // A doc comment's long form always contains its short one — the short form // *is* the comment's first paragraph. A spec keeps `about` and `about_long` // independent, and mise's differ entirely: "Dev tools, env vars, and tasks @@ -419,6 +438,15 @@ impl Cli { for field in &named.named { cli.fields.push(Field::from_field(field)?); } + // A program is called what its binary is called, unless it says otherwise. The name + // defaults to the struct's, and a struct is usually called `Cli` — so `bin = + // "communique"` with no `name` bannered itself as `cli 1.3.1`, and every adopter had + // to declare the same word twice to avoid it. + if !name_given { + if let Some(bin) = &cli.bin { + cli.name = bin.clone(); + } + } cli.check()?; Ok(cli) }