diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dffb3ea31..a27e41571 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -62,25 +62,19 @@ jobs: run: | set -euo pipefail - # All workspace crates share a single version (lockstep). - # Check the root crate to decide if publishing is needed. + # All workspace crates share a single version (lockstep), so the root + # crate's version is the target version for every crate. Whether + # there is anything to publish is decided per crate in the loop below, + # not here: a crate added to the workspace after the current version + # was released is missing from crates.io even though the root crate is + # already at that version, so an early exit keyed on `fgumi` alone + # would silently skip it. VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r \ '.packages[] | select(.name == "fgumi") | .version') - PUBLISHED_VERSION=$(curl -sSf \ - -H "User-Agent: fgumi-ci (https://github.com/fulcrumgenomics/fgumi)" \ - "https://crates.io/api/v1/crates/fgumi" | jq -r \ - '.crate.max_version // "0.0.0"') - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - if [ "$VERSION" = "$PUBLISHED_VERSION" ]; then - echo "All crates already at v$VERSION -- nothing to publish" - echo "published=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "Publishing all workspace crates (v$VERSION)..." + echo "Target version for all workspace crates: v$VERSION" # Workspace crates in topological (dependency) order. # Leaf crates first, root crate last. Update this list when @@ -90,6 +84,9 @@ jobs: fgumi-bgzf fgumi-simd-fastq fgumi-tag + fgumi-fmt + fgumi-cli-macros + fgumi-cli-common fgumi-raw-bam fgumi-bam-io fgumi-umi @@ -169,6 +166,7 @@ jobs: exit 1 fi + PUBLISHED_ANY=false for crate in "${CRATES[@]}"; do CRATE_MAX_VERSION=$(curl -sS \ -H "User-Agent: fgumi-ci (https://github.com/fulcrumgenomics/fgumi)" \ @@ -183,9 +181,14 @@ jobs: echo "Publishing $crate v$VERSION..." cargo publish -p "$crate" echo "✓ Published $crate v$VERSION" + PUBLISHED_ANY=true done - echo "published=true" >> "$GITHUB_OUTPUT" + if [ "$PUBLISHED_ANY" = "false" ]; then + echo "All crates already at v$VERSION -- nothing to publish" + fi + + echo "published=$PUBLISHED_ANY" >> "$GITHUB_OUTPUT" - name: Create GitHub release if: steps.publish.outputs.version != '' diff --git a/Cargo.lock b/Cargo.lock index 670f7b087..144cea757 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -789,6 +789,19 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "fgumi-cli-macros" +version = "0.5.0" +dependencies = [ + "anyhow", + "clap", + "proc-macro2", + "quote", + "rstest", + "syn 2.0.119", + "trybuild", +] + [[package]] name = "fgumi-consensus" version = "0.5.0" @@ -2296,6 +2309,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "2.0.1" @@ -2438,6 +2460,12 @@ dependencies = [ "windows", ] +[[package]] +name = "target-triple" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171" + [[package]] name = "tempfile" version = "3.27.0" @@ -2451,6 +2479,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2507,6 +2544,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2537,6 +2589,27 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "trybuild" +version = "1.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06649c6f63d86604ba0c8950d5a1829fc9a17afd70fc6629f481d75b6a624c78" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml", +] + [[package]] name = "typenum" version = "1.20.1" diff --git a/Cargo.toml b/Cargo.toml index 4704e3f0e..8dd539a8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [".", "crates/fgumi-raw-bam", "crates/fgumi-dna", "crates/fgumi-bgzf", "crates/fgumi-fmt", "crates/fgumi-metrics", "crates/fgumi-sam", "crates/fgumi-simd-fastq", "crates/fgumi-tag", "crates/fgumi-umi", "crates/fgumi-consensus", "crates/fgumi-bam-io", "crates/fgumi-sort", "crates/fgumi-cli-common", "crates/xtask"] +members = [".", "crates/fgumi-raw-bam", "crates/fgumi-dna", "crates/fgumi-bgzf", "crates/fgumi-fmt", "crates/fgumi-metrics", "crates/fgumi-sam", "crates/fgumi-simd-fastq", "crates/fgumi-tag", "crates/fgumi-umi", "crates/fgumi-consensus", "crates/fgumi-bam-io", "crates/fgumi-sort", "crates/fgumi-cli-common", "crates/fgumi-cli-macros", "crates/xtask"] resolver = "2" [workspace.package] @@ -23,6 +23,7 @@ flate2 = { version = "1.1", features = ["zlib-rs"] } fgumi-bam-io = { version = "0.5.0", path = "crates/fgumi-bam-io" } fgumi-bgzf = { version = "0.5.0", path = "crates/fgumi-bgzf" } fgumi-cli-common = { version = "0.5.0", path = "crates/fgumi-cli-common" } +fgumi-cli-macros = { version = "0.5.0", path = "crates/fgumi-cli-macros" } fgumi-fmt = { version = "0.5.0", path = "crates/fgumi-fmt" } fgumi-consensus = { version = "0.5.0", path = "crates/fgumi-consensus", default-features = false } fgumi-dna = { version = "0.5.0", path = "crates/fgumi-dna" } diff --git a/crates/fgumi-cli-macros/Cargo.toml b/crates/fgumi-cli-macros/Cargo.toml new file mode 100644 index 000000000..0e8843f5e --- /dev/null +++ b/crates/fgumi-cli-macros/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "fgumi-cli-macros" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Proc-macro support crate for fgumi CLI option re-exposure (multi_options)" +repository.workspace = true +license.workspace = true + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2", features = ["full"] } +quote = "1" +proc-macro2 = "1" + +[dev-dependencies] +clap = { workspace = true } +anyhow = { workspace = true } +rstest = { workspace = true } +trybuild = "1.0" + +[lints.clippy] +pedantic = { level = "deny", priority = -1 } diff --git a/crates/fgumi-cli-macros/src/lib.rs b/crates/fgumi-cli-macros/src/lib.rs new file mode 100644 index 000000000..bd980b78a --- /dev/null +++ b/crates/fgumi-cli-macros/src/lib.rs @@ -0,0 +1,1600 @@ +#![deny(unsafe_code)] + +//! Proc-macro support for fgumi CLI option re-exposure. +//! +//! Provides [`multi_options`] — an attribute macro that pairs a standalone +//! command's `clap::Args` options struct with a sibling `Multi` struct +//! whose CLI flags are prefixed and grouped under a help heading. Used by the +//! `runall` command to re-expose every per-stage option of `fgumi sort` / +//! `fgumi group` / `fgumi simplex` / `fgumi duplex` / `fgumi codec` without +//! hand-maintaining a parallel option set on `RunAll`. +//! +//! # Flag naming +//! +//! A re-exposed flag is named `--::` (e.g. `--sort::max-memory`). +//! The `::` separator is deliberate: `runall` flattens several stages into one +//! command, and every stage independently owns flags like `--threads` or +//! `--max-memory`. A plain kebab prefix (`--sort-max-memory`) would be +//! ambiguous with a stage that genuinely has a `--max` flag taking a `memory` +//! value, and would read as one flag name rather than as a stage-qualified one. +//! `::` cannot appear in a clap-derived flag name, so it can never collide with +//! a real flag, and it makes the stage qualifier obvious in `--help`. +//! +//! # Field-kind handling +//! +//! * `#[arg(skip)]` / `#[arg(skip = expr)]` fields are invisible to the CLI. +//! They are carried onto the Multi struct as skip fields too, so their values +//! survive a `From` + `validate()` round trip; when the Multi struct is +//! parsed from the command line they take the skip expression's value, or the +//! original struct's `Default` for a bare `skip`. +//! * `Option`, `Vec` and bare `bool` fields keep their type: clap already +//! treats each as absent-able (`None`, empty, `false`). +//! * Fields with any `default_value*` attribute keep their type and their +//! default — the attribute is copied verbatim, so the prefixed flag advertises +//! and applies exactly the default the standalone command does. +//! * Every other field is required: it becomes `Option` on the Multi struct, +//! and the generated `validate()` returns an error when the corresponding +//! `--::` is missing. This keeps required-ness staged — clap +//! never refuses to parse, so `runall` can report which stage is missing what. +//! +//! # What is and is not carried over +//! +//! Copied verbatim onto the generated field: `#[doc]` comments (each attribute +//! separately, so clap's short/long help split survives), `#[cfg]` and +//! `#[cfg_attr]` gates, every `default_value*`, and every other `#[arg(...)]` +//! key the macro does not classify (`value_parser`, `value_name`, `action`, +//! `num_args`, `hide`, `env`, …). A `#[cfg]` additionally gates the generated +//! conversion arms, so a field compiled out is absent from all three sites; +//! `#[cfg_attr]` never removes a field, so it lands on the field alone. +//! +//! Rewritten: `long` (re-prefixed) and long-form aliases (`alias`, +//! `aliases`, `visible_alias`, `visible_aliases` — each re-prefixed the same +//! way, so no un-namespaced flag ever reaches the parent command). +//! +//! Dropped: `short` and every short alias — one letter cannot be namespaced per +//! stage. A field-level `help_heading` is dropped for the same reason: the +//! companion files every field under its stage's heading, and a field-level one +//! is emitted after it and wins, so the field would escape its stage — and two +//! stages declaring the same heading would merge into one section naming +//! neither. `required` is also dropped from the clap side and enforced by +//! `validate()` instead, so a missing value is reported by `runall`'s staged +//! validation rather than by clap's parser. +//! +//! `help` and `long_help` are preserved verbatim, like any other unclassified +//! key: they are the field's own documentation and read identically on both +//! commands. One consequence is that an explicit `help` on a required field +//! replaces the generated "Required when `` is selected." line, since +//! clap prefers `help` over `#[doc]`; the staged `validate()` error still names +//! both the flag and the stage. +//! +//! Rejected at expansion time, with a spanned build error: cross-field +//! reference keys (`requires`, `conflicts_with`, …) whose arg ids would dangle +//! once fields are prefixed; `id` / `name` overrides; positional arguments, +//! whether declared by `index` or by declaring neither `long` nor `short` +//! (several stages' positionals would be mutually ambiguous, and clap panics +//! when a positional carries a `long`); clap's `key(value)` call form for any +//! key the macro classifies; a clap attribute hidden behind `#[cfg_attr]`, +//! which the macro cannot classify through; a `required` that could never be +//! enforced, because the field always holds a value (defaulted — including a +//! defaulted `Option` or `Vec` — skipped, or a bare `bool`); +//! `#[command(...)]` on a field; struct-level `#[command(...)]` / +//! `#[group(...)]`; generic structs, since the companion and its conversions +//! are emitted without generic parameters; and the legacy `#[clap(...)]` / +//! `#[structopt(...)]` spellings, which every classifier would otherwise +//! silently ignore. +//! +//! Note that `env` is preserved verbatim: two stages re-exposing the same +//! options struct therefore read the same environment variable, exactly as the +//! two standalone commands would. + +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::{format_ident, quote}; +use syn::{Lit, Meta}; + +/// Attribute macro that passes the original options struct through unchanged and +/// generates a `Multi` companion whose flags are named +/// `--::` and filed under `` in `--help`, plus a +/// `validate()` method, a `TryFrom>` impl and a +/// `From` impl. +/// +/// Usage: `#[multi_options("prefix", "Help Heading")]` +/// +/// The generated items inherit the annotated struct's visibility. +/// +/// See the crate-level docs for the field-kind classification rules and for the +/// full list of what is carried over, rewritten, dropped and rejected. +/// +/// # Errors +/// +/// Emits a spanned compile error — pointing at the offending field, attribute or +/// literal — rather than panicking. See the crate docs' "What is and is not +/// carried over" section for the rejected forms. +#[proc_macro_attribute] +pub fn multi_options(attr: TokenStream, item: TokenStream) -> TokenStream { + let item = TokenStream2::from(item); + match expand(TokenStream2::from(attr), &item) { + Ok(expanded) => expanded.into(), + Err(error) => { + // Re-emit the annotated item next to the diagnostic. Without it, every + // downstream reference to the struct raises its own "cannot find type" + // error and buries the one that actually explains the problem. + let compile_error = error.into_compile_error(); + quote! { #item #compile_error }.into() + } + } +} + +/// Expand `#[multi_options(...)]`, returning either the generated tokens or the +/// accumulated diagnostics. +fn expand(attr: TokenStream2, item: &TokenStream2) -> syn::Result { + let args = syn::parse2::(attr)?; + let input = parse_annotated_struct(item)?; + let fields = named_fields(&input)?; + + let context = ExpandContext { + struct_name: &input.ident, + vis: &input.vis, + prefix: &args.prefix, + heading: &args.heading, + }; + let generated = generate_fields(fields, &context)?; + Ok(render(&input, &generated, &context)) +} + +/// Parse the annotated item, rejecting anything that is not a struct. +fn parse_annotated_struct(item: &TokenStream2) -> syn::Result { + let parsed = syn::parse2::(item.clone())?; + let syn::Item::Struct(input) = parsed else { + return Err(syn::Error::new_spanned( + parsed, + "multi_options only supports structs with named fields", + )); + }; + reject_unsupported_struct_attrs(&input.attrs)?; + // The companion struct and both conversion impls are emitted without generic + // parameters, so a generic options struct expands into code that cannot + // compile — and every resulting error names the type parameter rather than + // this macro. + if !input.generics.params.is_empty() { + return Err(syn::Error::new_spanned(&input.generics, GENERICS_MSG)); + } + // `Generics`' `ToTokens` emits only the `<...>` params, so spanning on it + // when they are empty would collapse the span to the call site and lose the + // field-accurate diagnostics every other rejection here produces. + if let Some(where_clause) = &input.generics.where_clause { + return Err(syn::Error::new_spanned(where_clause, GENERICS_MSG)); + } + Ok(input) +} + +/// Borrow the struct's named fields, rejecting tuple and unit structs. +fn named_fields( + input: &syn::ItemStruct, +) -> syn::Result<&syn::punctuated::Punctuated> { + match &input.fields { + syn::Fields::Named(named) => Ok(&named.named), + other => Err(syn::Error::new_spanned( + other, + "multi_options only supports structs with named fields", + )), + } +} + +/// Classify and generate every field, reporting all bad fields at once rather +/// than making the author fix them one build at a time. +fn generate_fields( + fields: &syn::punctuated::Punctuated, + context: &ExpandContext<'_>, +) -> syn::Result> { + let mut errors: Option = None; + let mut generated = Vec::with_capacity(fields.len()); + for field in fields { + match ParsedField::from_field(field, context.prefix) { + Ok(parsed) => generated.push(parsed.generate(context)), + Err(error) => match &mut errors { + Some(accumulated) => accumulated.combine(error), + None => errors = Some(error), + }, + } + } + match errors { + Some(error) => Err(error), + None => Ok(generated), + } +} + +/// Assemble the original struct, the generated companion, and the conversions. +fn render( + input: &syn::ItemStruct, + generated: &[GeneratedField], + context: &ExpandContext<'_>, +) -> TokenStream2 { + let struct_name = context.struct_name; + let multi_struct_name = format_ident!("Multi{}", struct_name); + let multi_fields = generated.iter().map(|g| &g.multi_field); + let validate_arms = generated.iter().map(|g| &g.validate_arm); + let from_arms = generated.iter().map(|g| &g.from_arm); + let vis = context.vis; + + quote! { + #input + + /// Prefixed options struct generated by `#[multi_options]` for the + /// runall command. Carries the same fields as the original options + /// struct but exposes each via `--::`, filed under the + /// stage's help heading. + #[derive(::clap::Args, Debug, Clone)] + // clap adopts a flattened struct's doc comment as the parent command's + // description when the parent declares none. This struct's rustdoc is + // for docs.rs (and for crates that deny(missing_docs)) — it would be + // nonsense as a command description, and with several stages flattened + // clap would arbitrarily pick whichever came first. + #[command(about = None, long_about = None)] + #vis struct #multi_struct_name { + #(#multi_fields)* + } + + impl #multi_struct_name { + /// Validate required fields and convert to the original options + /// struct. Returns `Err` naming the missing `--::` + /// when a field the standalone command requires was not supplied. + #vis fn validate(self) -> ::anyhow::Result<#struct_name> { + <#struct_name as ::core::convert::TryFrom>::try_from(self) + } + } + + impl ::core::convert::TryFrom<#multi_struct_name> for #struct_name { + type Error = ::anyhow::Error; + + fn try_from(opts: #multi_struct_name) -> ::anyhow::Result { + Ok(Self { + #(#validate_arms)* + }) + } + } + + impl ::core::convert::From<#struct_name> for #multi_struct_name { + fn from(opts: #struct_name) -> Self { + Self { + #(#from_arms)* + } + } + } + } +} + +/// Everything the per-field generator needs from the annotated struct. +struct ExpandContext<'a> { + struct_name: &'a syn::Ident, + vis: &'a syn::Visibility, + prefix: &'a str, + heading: &'a str, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Macro arguments +// ───────────────────────────────────────────────────────────────────────────── + +/// Parsed arguments for `#[multi_options("prefix", "heading")]`. +struct MultiOptionsArgs { + prefix: String, + heading: String, +} + +/// The one-line usage reminder attached to every argument-shape diagnostic. +const USAGE: &str = "multi_options requires two string literal arguments: \ + #[multi_options(\"prefix\", \"heading\")]"; + +/// Rejection message for a generic annotated struct, shared by the type-parameter +/// and `where`-clause arms so the two cannot drift apart. +const GENERICS_MSG: &str = "multi_options does not support generic structs: the generated Multi struct and its \ + conversions are emitted without generic parameters, so the expansion would not compile. \ + Use a concrete options struct."; + +impl syn::parse::Parse for MultiOptionsArgs { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let prefix_lit: syn::LitStr = + input.parse().map_err(|e| syn::Error::new(e.span(), USAGE))?; + input.parse::().map_err(|e| syn::Error::new(e.span(), USAGE))?; + let heading_lit: syn::LitStr = + input.parse().map_err(|e| syn::Error::new(e.span(), USAGE))?; + if !input.is_empty() { + return Err(syn::Error::new(input.span(), USAGE)); + } + + let prefix = prefix_lit.value(); + validate_prefix(&prefix).map_err(|msg| syn::Error::new(prefix_lit.span(), msg))?; + let heading = heading_lit.value(); + if heading.is_empty() { + return Err(syn::Error::new( + heading_lit.span(), + "multi_options: the help heading must not be empty", + )); + } + + Ok(Self { prefix, heading }) + } +} + +/// Check that a prefix can be spliced into both a flag name and an identifier. +/// +/// The prefix appears in the flag (`--::`) and in the generated +/// field identifier (`_`), so an empty or non-identifier prefix +/// would otherwise surface as `--::flag` or as an opaque `format_ident!` panic +/// with no mention of `multi_options`. +fn validate_prefix(prefix: &str) -> Result<(), String> { + let Some(first) = prefix.chars().next() else { + return Err("multi_options: the prefix must not be empty (it would generate flags named \ + `--::`)" + .to_string()); + }; + if !first.is_ascii_alphabetic() { + return Err(format!( + "multi_options: the prefix must start with an ASCII letter, got `{prefix}` — it also \ + becomes the leading segment of the generated field identifier `_`" + )); + } + if let Some(bad) = + prefix.chars().find(|c| !(c.is_ascii_alphanumeric() || *c == '_' || *c == '-')) + { + return Err(format!( + "multi_options: the prefix may only contain ASCII letters, digits, `_` and `-`, but \ + `{prefix}` contains `{bad}`" + )); + } + Ok(()) +} + +/// Reject struct-level clap configuration the macro cannot faithfully reproduce. +/// +/// The generated struct is built from the fields alone, so any struct-level clap +/// setting would apply to the standalone command and silently not to the +/// re-exposed one. +fn reject_unsupported_struct_attrs(attrs: &[syn::Attribute]) -> syn::Result<()> { + for attr in attrs { + let path = attr.path(); + if path.is_ident("clap") || path.is_ident("structopt") { + return Err(legacy_spelling_error(attr)); + } + if path.is_ident("group") { + return Err(syn::Error::new_spanned( + attr, + "multi_options does not support a struct-level #[group(...)]: the group names its \ + members by their unprefixed arg ids, which do not exist on the generated Multi \ + struct. Enforce the grouping in the command's validate()/resolve() instead.", + )); + } + if path.is_ident("command") { + return Err(syn::Error::new_spanned( + attr, + "multi_options does not carry a struct-level #[command(...)] onto the generated \ + Multi struct, so the standalone and re-exposed commands would silently diverge. \ + Move the setting onto the individual #[arg(...)] attributes, or drop it.", + )); + } + } + Ok(()) +} + +/// Reject a `#[cfg_attr(...)]` that hides a clap attribute behind a condition. +/// +/// Every classifier keys on a literal `#[arg(...)]` / `#[command(...)]`, so a +/// `#[cfg_attr(unix, arg(long, default_value_t = 3))]` is invisible: the field is +/// classified as required, wrapped in `Option`, and *then* the forwarded +/// attribute applies `default_value_t` to the wrapped type — a wall of type +/// errors that never mentions `multi_options`. Classifying through the condition +/// is not possible either, since the macro cannot evaluate `cfg` predicates. +fn reject_conditional_clap_attr(attr: &syn::Attribute, field: &syn::Field) -> syn::Result<()> { + let Ok(metas) = + attr.parse_args_with(syn::punctuated::Punctuated::::parse_terminated) + else { + // Not a shape we can inspect; leave it to rustc. + return Ok(()); + }; + // The first meta is the `cfg` predicate; the rest are the attributes it gates. + for meta in metas.iter().skip(1) { + let path = meta.path(); + if path.is_ident("arg") || path.is_ident("clap") || path.is_ident("command") { + let key = path.get_ident().map_or_else(|| "arg".to_string(), ToString::to_string); + return Err(syn::Error::new_spanned( + attr, + format!( + "multi_options: field `{}` hides a clap attribute behind #[cfg_attr(…, \ + {key}(…))]. The macro classifies fields from their literal #[arg(...)] \ + attributes and cannot evaluate a cfg predicate, so this one would be ignored \ + and the field misclassified. Apply #[cfg] to the field and write the \ + #[arg(...)] unconditionally.", + field_name(field) + ), + )); + } + } + Ok(()) +} + +/// Build the diagnostic for clap's legacy attribute spellings. +/// +/// Every classifier in this macro keys on `#[arg(...)]` / `#[command(...)]`, so a +/// `#[clap(skip)]` would be invisible — the field would be exposed as a required +/// CLI flag instead of being skipped. Reject rather than silently misclassify. +fn legacy_spelling_error(attr: &syn::Attribute) -> syn::Error { + syn::Error::new_spanned( + attr, + "multi_options does not support the legacy #[clap(...)] / #[structopt(...)] spelling: \ + every classifier keys on #[arg(...)] and #[command(...)], so this attribute would be \ + silently ignored and the field misclassified. Use the #[arg(...)] / #[command(...)] \ + spelling.", + ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// clap `#[arg(...)]` key tables +// ───────────────────────────────────────────────────────────────────────────── + +/// clap `#[arg(...)]` keys that reference *another argument by its string id*. +/// +/// The Multi struct renames every field to `_` and never emits a +/// matching arg alias, so any of these ids would dangle on the Multi side and +/// clap would panic ("arg id `x` not defined") when it builds the `runall` +/// command. Reject them at macro-expansion time instead (D1). +const CROSS_REFERENCE_ARG_KEYS: &[&str] = &[ + "requires", + "requires_all", + "requires_if", + "requires_ifs", + "conflicts_with", + "conflicts_with_all", + "overrides_with", + "overrides_with_all", + "required_if_eq", + "required_if_eq_all", + "required_if_eq_any", + "required_unless_present", + "required_unless_present_any", + "required_unless_present_all", + "default_value_if", + "default_value_ifs", + "default_values_if", + "default_values_ifs", + "group", + "groups", +]; + +/// clap `#[arg(...)]` keys that rename the argument itself. +/// +/// The Multi struct derives every arg id from its own prefixed field name, so an +/// explicit id would reintroduce the unprefixed name and collide across stages. +const RENAMING_ARG_KEYS: &[&str] = &["id", "name"]; + +/// clap `#[arg(...)]` keys that declare the argument to be positional. +/// +/// A positional argument has no flag name to prefix, and clap panics outright +/// when one is given a `long` — which is exactly what the Multi struct emits. +const POSITIONAL_ARG_KEYS: &[&str] = &["index"]; + +/// clap `#[arg(...)]` keys that declare a default value, in every spelling. +/// +/// A field carrying any of these is optional on the standalone command, so it +/// must not be classified as required. Deliberately excludes +/// `default_missing_value`, which is the value used when a flag is passed +/// *without* one and says nothing about whether the flag may be omitted. +const DEFAULT_VALUE_ARG_KEYS: &[&str] = &[ + "default_value", + "default_value_t", + "default_value_os", + "default_value_os_t", + "default_values", + "default_values_t", + "default_values_os", + "default_values_os_t", +]; + +/// clap `#[arg(...)]` keys this macro classifies via `Meta::NameValue` / +/// `Meta::Path` (to strip, rewrite, or read them). +/// +/// clap also accepts the equivalent `key(value)` call form, which arrives as a +/// `Meta::List` and would slip past every classifier — silently changing the +/// flag name, the required-ness, the default, or leaking an un-prefixed alias. +/// Reject the call form for these keys (D2). +const CALL_FORM_SENSITIVE_ARG_KEYS: &[&str] = &[ + "long", + "short", + "required", + "skip", + "alias", + "aliases", + "visible_alias", + "visible_aliases", + "short_alias", + "short_aliases", + "visible_short_alias", + "visible_short_aliases", + "help_heading", + "default_value", + "default_value_t", + "default_value_os", + "default_value_os_t", + "default_values", + "default_values_t", + "default_values_os", + "default_values_os_t", +]; + +/// Long-form alias keys, which are re-prefixed exactly like `long`. +const LONG_ALIAS_ARG_KEYS: &[&str] = &["alias", "visible_alias"]; + +/// Long-form alias keys taking a list of aliases; every element is re-prefixed. +const LONG_ALIAS_LIST_ARG_KEYS: &[&str] = &["aliases", "visible_aliases"]; + +/// Short-form alias keys, dropped for the same reason as `short`. +const SHORT_ALIAS_ARG_KEYS: &[&str] = + &["short_alias", "short_aliases", "visible_short_alias", "visible_short_aliases"]; + +// ───────────────────────────────────────────────────────────────────────────── +// Per-field parsing +// ───────────────────────────────────────────────────────────────────────────── + +/// How a `#[arg(skip)]` field obtains its value. +enum Skip { + /// Bare `#[arg(skip)]` — falls back to the original struct's `Default`. + Bare, + /// `#[arg(skip = expr)]` — uses the expression verbatim. + Expr(syn::Expr), +} + +/// One field of the annotated struct, classified in a single pass over its +/// attributes. +/// +/// Every `#[arg(...)]` meta is visited exactly once and routed to the piece of +/// state it affects, so the key tables above are the single source of truth for +/// classification — earlier revisions re-walked `field.attrs` in six independent +/// helpers, which let the three key lists drift apart. +struct ParsedField<'a> { + ident: &'a syn::Ident, + ty: &'a syn::Type, + /// `#[doc]`, `#[cfg]` and `#[cfg_attr]` attributes, copied onto the + /// generated field verbatim. + forwarded: Vec<&'a syn::Attribute>, + /// The `#[cfg]` subset, which must additionally gate the `TryFrom` and + /// `From` arms so a gated-out field is absent from all three sites. + /// + /// `#[cfg_attr]` is deliberately excluded: it never removes a field, so the + /// arms do not need it, and only `#[cfg]` is valid on a struct-expression + /// field — forwarding `#[cfg_attr(unix, serde(skip))]` onto one would expand + /// to `#[serde(skip)]` on an expression, which does not compile. + cfgs: Vec<&'a syn::Attribute>, + /// `#[arg(...)]` entries carried onto the generated field, already rendered + /// as `, key = value` continuations ready to splice into a new `#[arg(...)]`. + preserved: Vec, + skip: Option, + /// The value of an explicit `#[arg(long = "...")]` override. + long_override: Option, + has_default: bool, + /// Whether the field carries `#[arg(required)]` / `#[arg(required = true)]`. + required: bool, + /// Whether the field declares a flag name — `long` or `short`, in any + /// spelling. A field declaring neither is positional to clap. + has_flag_name: bool, +} + +impl<'a> ParsedField<'a> { + /// Classify one field, or return every diagnostic it earns. + fn from_field(field: &'a syn::Field, prefix: &str) -> syn::Result { + let ident = field.ident.as_ref().ok_or_else(|| { + syn::Error::new_spanned(field, "multi_options only supports structs with named fields") + })?; + let mut parsed = Self { + ident, + ty: &field.ty, + forwarded: Vec::new(), + cfgs: Vec::new(), + preserved: Vec::new(), + skip: None, + long_override: None, + has_default: false, + required: false, + has_flag_name: false, + }; + + for attr in &field.attrs { + let path = attr.path(); + if path.is_ident("doc") { + parsed.forwarded.push(attr); + } else if path.is_ident("cfg") { + parsed.forwarded.push(attr); + parsed.cfgs.push(attr); + } else if path.is_ident("cfg_attr") { + reject_conditional_clap_attr(attr, field)?; + parsed.forwarded.push(attr); + } else if path.is_ident("clap") || path.is_ident("structopt") { + return Err(legacy_spelling_error(attr)); + } else if path.is_ident("command") { + return Err(field_command_error(attr, field)); + } else if path.is_ident("arg") { + for meta in parse_arg_metas(attr, field)? { + parsed.absorb_arg_meta(&meta, field, prefix)?; + } + } + } + + parsed.check_required_is_enforceable(field)?; + parsed.check_is_not_positional(field)?; + Ok(parsed) + } + + /// Reject a field clap would treat as a positional argument. + /// + /// In clap's derive a field with neither `long` nor `short` is positional. + /// The Multi struct always emits a `long`, which would silently convert + /// `fgumi sort ` into `--sort::input ` — and, when the field + /// carries an explicit `index`, makes clap panic outright while building the + /// runall command. A positional cannot be namespaced per stage anyway: with + /// several stages flattened into one command, their positionals would be + /// mutually ambiguous. + fn check_is_not_positional(&self, field: &syn::Field) -> syn::Result<()> { + if self.skip.is_some() || self.has_flag_name { + return Ok(()); + } + Err(syn::Error::new_spanned( + field, + format!( + "multi_options: field `{}` declares neither `long` nor `short`, so clap treats it \ + as a positional argument. A positional has no flag name to prefix, and several \ + stages flattened into one runall command would have mutually ambiguous \ + positionals. Add an explicit `#[arg(long)]` (or `#[arg(long = \"...\")]`) to the \ + field.", + field_name(field) + ), + )) + } + + /// Route one `#[arg(...)]` meta to the state it affects. + fn absorb_arg_meta( + &mut self, + meta: &Meta, + field: &syn::Field, + prefix: &str, + ) -> syn::Result<()> { + let Some(key) = meta.path().get_ident().map(ToString::to_string) else { + self.preserved.push(quote! { , #meta }); + return Ok(()); + }; + let key = key.as_str(); + let name = field_name(field); + reject_unsupported_arg_key(meta, field, &name, key)?; + + match key { + "skip" => { + self.skip = Some(match meta { + Meta::NameValue(nv) => Skip::Expr(nv.value.clone()), + _ => Skip::Bare, + }); + } + // The Multi field declares its own prefixed `long`; a bare `long` + // just means "kebab of the field name", which is the fallback. + "long" => { + self.has_flag_name = true; + if let Meta::NameValue(nv) = meta { + self.long_override = Some(string_literal(&nv.value).ok_or_else(|| { + non_literal_error(field, &name, "long", "a string literal") + })?); + } + } + // One letter cannot be namespaced per stage. + "short" => self.has_flag_name = true, + _ if SHORT_ALIAS_ARG_KEYS.contains(&key) => {} + // Neither can a help heading. The companion files every field under + // the stage's heading; a field-level one is emitted after it and + // wins, so the field escapes its stage — and two stages declaring + // the same heading would merge into one section that names neither. + "help_heading" => {} + // Required-ness is enforced by validate(), not by clap, so runall + // can name the stage that is missing a value. + "required" => { + self.required = match meta { + Meta::Path(_) => true, + Meta::NameValue(nv) => bool_literal(&nv.value).ok_or_else(|| { + non_literal_error(field, &name, "required", "`true` or `false`") + })?, + Meta::List(_) => unreachable!("call form rejected above"), + }; + } + _ if LONG_ALIAS_ARG_KEYS.contains(&key) => { + self.preserved.push(prefixed_alias(meta, field, &name, key, prefix)?); + } + _ if LONG_ALIAS_LIST_ARG_KEYS.contains(&key) => { + self.preserved.push(prefixed_alias_list(meta, field, &name, key, prefix)?); + } + _ => { + if DEFAULT_VALUE_ARG_KEYS.contains(&key) { + self.has_default = true; + } + self.preserved.push(quote! { , #meta }); + } + } + Ok(()) + } + + /// Reject `#[arg(required)]` on a field whose generated form always holds a + /// value. + /// + /// `validate()` enforces required-ness by observing absence — a `None`, an + /// empty `Vec`, or the `Option` wrapper the macro adds. A skipped field, a + /// defaulted field and a bare `bool` are never absent, so the requirement + /// would be silently unenforceable. + /// + /// `Option` and `Vec` are absent-able only while they carry no default: + /// clap applies a declared `default_value` to those types too (an omitted + /// `Option` with `default_value = "42"` parses as `Some(42)`, not + /// `None`), so the generated `is_none()` / `is_empty()` check would never + /// fire and the requirement would be lost in silence. + fn check_required_is_enforceable(&self, field: &syn::Field) -> syn::Result<()> { + if !self.required { + return Ok(()); + } + let name = field_name(field); + if self.skip.is_some() { + return Err(syn::Error::new_spanned( + field, + format!( + "multi_options: field `{name}` combines #[arg(required …)] with #[arg(skip)]. \ + A skipped field is never supplied on the command line, so the requirement \ + could never be satisfied." + ), + )); + } + let absent_able = (is_option_type(self.ty) || is_vec_type(self.ty)) && !self.has_default; + if self.is_required_kind() || absent_able { + return Ok(()); + } + Err(syn::Error::new_spanned( + field, + format!( + "multi_options: field `{name}` combines #[arg(required …)] with a default value or \ + a bare `bool`. The generated field always holds a value — clap applies a declared \ + default to `Option` and `Vec` as well — so the Multi side cannot distinguish \ + \"not supplied\" from \"supplied the default\" and the requirement would be \ + silently unenforceable. Drop `required`, or drop the default." + ), + )) + } + + /// Whether the field must be wrapped in `Option` on the Multi struct and + /// enforced by `validate()`. + /// + /// `Option`, `Vec` and bare `bool` are all absent-able as-is, and a + /// field with a default is never missing. + fn is_required_kind(&self) -> bool { + !self.has_default + && !is_option_type(self.ty) + && !is_vec_type(self.ty) + && !is_bool_type(self.ty) + } + + /// Emit the Multi-struct field plus the matching `TryFrom` and `From` arms. + fn generate(&self, ctx: &ExpandContext<'_>) -> GeneratedField { + let field_ident = self.ident; + let field_type = self.ty; + let prefixed_ident = format_ident!("{}_{}", ctx.prefix.replace('-', "_"), field_ident); + let forwarded = &self.forwarded; + let cfgs = &self.cfgs; + let preserved = &self.preserved; + let vis = ctx.vis; + + // Skipped fields are invisible to the CLI on both sides. Carrying them + // onto the Multi struct as skip fields keeps `From` + `validate()` a + // genuine round trip instead of re-deriving the value from `Default`. + if let Some(skip) = &self.skip { + let value = match skip { + Skip::Expr(expr) => quote! { #expr }, + Skip::Bare => { + let struct_name = ctx.struct_name; + quote! { #struct_name::default().#field_ident } + } + }; + return GeneratedField { + multi_field: quote! { + #(#forwarded)* + #[arg(skip = #value)] + #vis #prefixed_ident: #field_type, + }, + validate_arm: quote! { #(#cfgs)* #field_ident: opts.#prefixed_ident, }, + from_arm: quote! { #(#cfgs)* #prefixed_ident: opts.#field_ident, }, + }; + } + + // Honor an explicit `#[arg(long = "...")]` so the prefixed flag tracks the + // standalone command's flag name (`tmp_dirs` with `long = "tmp-dir"` + // becomes `--::tmp-dir`, not `--::tmp-dirs`). + let base_name = + self.long_override.clone().unwrap_or_else(|| field_ident.to_string().replace('_', "-")); + let long_name = format!("{}::{}", ctx.prefix, base_name); + let heading = ctx.heading; + let prefix = ctx.prefix; + let error_msg = format!("--{long_name} is required when {prefix} is selected"); + + if self.is_required_kind() { + let required_doc = format!("Required when {prefix} is selected."); + return GeneratedField { + multi_field: quote! { + #(#forwarded)* + #[doc = #required_doc] + #[arg(long = #long_name, help_heading = #heading #(#preserved)*)] + #vis #prefixed_ident: Option<#field_type>, + }, + validate_arm: quote! { + #(#cfgs)* + #field_ident: opts.#prefixed_ident + .ok_or_else(|| ::anyhow::anyhow!(#error_msg))?, + }, + from_arm: quote! { #(#cfgs)* #prefixed_ident: Some(opts.#field_ident), }, + }; + } + + // Absent-able or defaulted: the field keeps its type and its declared + // default. `required` on such a field is dropped from the clap side, so + // re-assert it here — otherwise a flag the standalone command demands + // would be quietly optional on the Multi side. + let value = if self.required && is_option_type(field_type) { + quote! {{ + let value = opts.#prefixed_ident; + if value.is_none() { + ::anyhow::bail!(#error_msg); + } + value + }} + } else if self.required && is_vec_type(field_type) { + quote! {{ + let value = opts.#prefixed_ident; + if value.is_empty() { + ::anyhow::bail!(#error_msg); + } + value + }} + } else { + quote! { opts.#prefixed_ident } + }; + + GeneratedField { + multi_field: quote! { + #(#forwarded)* + #[arg(long = #long_name, help_heading = #heading #(#preserved)*)] + #vis #prefixed_ident: #field_type, + }, + validate_arm: quote! { #(#cfgs)* #field_ident: #value, }, + from_arm: quote! { #(#cfgs)* #prefixed_ident: opts.#field_ident, }, + } + } +} + +/// The three token streams one source field contributes to the expansion. +struct GeneratedField { + multi_field: TokenStream2, + validate_arm: TokenStream2, + from_arm: TokenStream2, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Attribute helpers +// ───────────────────────────────────────────────────────────────────────────── + +/// Render a field's name for a diagnostic. +/// +/// `multi_options` only accepts named-field structs, so the `None` arm is +/// unreachable in practice; it exists so a diagnostic never panics while +/// reporting another diagnostic. +fn field_name(field: &syn::Field) -> String { + field.ident.as_ref().map_or_else(|| "".to_string(), ToString::to_string) +} + +/// Parse the comma-separated metas out of one `#[arg(...)]` attribute. +/// +/// An unparseable `#[arg(...)]` is a hard build error: silently treating it as +/// "no attributes" would misclassify the field — a dropped `skip` exposes a +/// field that should have no CLI flag, a dropped `long` renames it, a dropped +/// `default_value` makes it required. +fn parse_arg_metas( + attr: &syn::Attribute, + field: &syn::Field, +) -> syn::Result> { + attr.parse_args_with(syn::punctuated::Punctuated::::parse_terminated) + .map_err(|e| { + syn::Error::new_spanned( + attr, + format!( + "multi_options: failed to parse #[arg(...)] on field `{}`: {e}", + field_name(field) + ), + ) + }) +} + +/// Build the diagnostic for a field-level `#[command(...)]`. +/// +/// `flatten` and `subcommand` nest another struct whose fields the macro cannot +/// reach to prefix; any other field-level `#[command(...)]` key would simply not +/// be carried over. +fn field_command_error(attr: &syn::Attribute, field: &syn::Field) -> syn::Error { + let name = field_name(field); + let metas = attr + .parse_args_with(syn::punctuated::Punctuated::::parse_terminated) + .ok(); + let nests = metas.is_some_and(|metas| { + metas + .iter() + .any(|meta| meta.path().is_ident("flatten") || meta.path().is_ident("subcommand")) + }); + if nests { + syn::Error::new_spanned( + attr, + format!( + "multi_options does not support #[command(flatten)] / #[command(subcommand)] on \ + field `{name}`. The nested struct's fields cannot be reached to prefix them; \ + inline them directly." + ), + ) + } else { + syn::Error::new_spanned( + attr, + format!( + "multi_options does not carry a field-level #[command(...)] onto the generated \ + Multi struct, so field `{name}` would behave differently on the two commands. \ + Move the setting onto #[arg(...)], or drop it." + ), + ) + } +} + +/// Reject an `#[arg(...)]` key the macro cannot faithfully re-expose. +/// +/// Covers three latent traps: cross-field reference keys whose arg-id strings +/// dangle once the field is prefixed (D1), explicit id overrides that would +/// reintroduce the un-prefixed name, and clap's `key(value)` call form for any +/// key the macro only recognizes in `key = value` / bare-`key` form (D2). +fn reject_unsupported_arg_key( + meta: &Meta, + field: &syn::Field, + name: &str, + key: &str, +) -> syn::Result<()> { + if CROSS_REFERENCE_ARG_KEYS.contains(&key) { + return Err(syn::Error::new_spanned( + field, + format!( + "multi_options: field `{name}` uses #[arg({key} …)], which references another \ + argument by its unprefixed id. The Multi struct renames fields to \ + `_`, so that id would dangle and clap would panic when it builds \ + the runall command. Enforce this coupling in the command's validate()/resolve() \ + instead (see AlignerOptions::resolve)." + ), + )); + } + if RENAMING_ARG_KEYS.contains(&key) { + return Err(syn::Error::new_spanned( + field, + format!( + "multi_options: field `{name}` uses #[arg({key} …)], which overrides the \ + argument's id. The Multi struct derives every id from its own prefixed field \ + name, so an explicit id would reintroduce the un-prefixed name and collide \ + between stages. Drop it." + ), + )); + } + if POSITIONAL_ARG_KEYS.contains(&key) { + return Err(syn::Error::new_spanned( + field, + format!( + "multi_options: field `{name}` uses #[arg({key} …)], which makes it a positional \ + argument. The Multi struct gives every field a prefixed `long`, and clap panics \ + when a positional has one (\"is a positional argument and can't have short or \ + long name versions\"). A positional cannot be namespaced per stage — expose it \ + as a flag with `#[arg(long)]` instead." + ), + )); + } + if matches!(meta, Meta::List(_)) && CALL_FORM_SENSITIVE_ARG_KEYS.contains(&key) { + return Err(syn::Error::new_spanned( + field, + format!( + "multi_options: field `{name}` uses the call form #[arg({key}(…))]. Use the \ + `{key} = …` name-value form (or bare `{key}`) — the macro only classifies those \ + spellings and would mishandle the call form." + ), + )); + } + Ok(()) +} + +/// Re-prefix a single long alias so it can never reach the parent command +/// un-namespaced. +fn prefixed_alias( + meta: &Meta, + field: &syn::Field, + name: &str, + key: &str, + prefix: &str, +) -> syn::Result { + let Meta::NameValue(nv) = meta else { + return Err(non_literal_error(field, name, key, "a string literal")); + }; + let alias = string_literal(&nv.value) + .ok_or_else(|| non_literal_error(field, name, key, "a string literal"))?; + let prefixed = format!("{prefix}::{alias}"); + let key = format_ident!("{}", key); + Ok(quote! { , #key = #prefixed }) +} + +/// Re-prefix every element of an alias list (`aliases = ["a", "b"]`). +fn prefixed_alias_list( + meta: &Meta, + field: &syn::Field, + name: &str, + key: &str, + prefix: &str, +) -> syn::Result { + const EXPECTED: &str = "an array of string literals"; + let Meta::NameValue(nv) = meta else { + return Err(non_literal_error(field, name, key, EXPECTED)); + }; + let syn::Expr::Array(array) = &nv.value else { + return Err(non_literal_error(field, name, key, EXPECTED)); + }; + let prefixed = array + .elems + .iter() + .map(|elem| { + string_literal(elem) + .map(|alias| format!("{prefix}::{alias}")) + .ok_or_else(|| non_literal_error(field, name, key, EXPECTED)) + }) + .collect::>>()?; + let key = format_ident!("{}", key); + Ok(quote! { , #key = [#(#prefixed),*] }) +} + +/// Build the diagnostic for a classified key whose value is not the literal form +/// the macro can read. +fn non_literal_error(field: &syn::Field, name: &str, key: &str, expected: &str) -> syn::Error { + syn::Error::new_spanned( + field, + format!( + "multi_options: field `{name}` uses #[arg({key} = …)] with a value that is not \ + {expected}. The macro reads this key at expansion time — to rewrite the flag name, \ + re-prefix the alias, or classify required-ness — so it cannot be an arbitrary \ + expression." + ), + ) +} + +/// Read a string literal out of an attribute value. +fn string_literal(expr: &syn::Expr) -> Option { + if let syn::Expr::Lit(expr_lit) = expr + && let Lit::Str(lit) = &expr_lit.lit + { + return Some(lit.value()); + } + None +} + +/// Read a boolean literal out of an attribute value. +fn bool_literal(expr: &syn::Expr) -> Option { + if let syn::Expr::Lit(expr_lit) = expr + && let Lit::Bool(lit) = &expr_lit.lit + { + return Some(lit.value); + } + None +} + +// ───────────────────────────────────────────────────────────────────────────── +// Type predicates +// ───────────────────────────────────────────────────────────────────────────── + +/// Check whether a type is `Vec`. +/// +/// clap collects a `Vec` naturally (empty when the flag is absent), so it +/// needs no default to be absent-able. +fn is_vec_type(ty: &syn::Type) -> bool { + if let syn::Type::Path(type_path) = ty + && let Some(segment) = type_path.path.segments.last() + { + return segment.ident == "Vec"; + } + false +} + +/// Check whether a type is a bare `bool`. +/// +/// clap gives a bare `bool` field `ArgAction::SetTrue` — a valueless flag +/// defaulting to `false` — so it needs no `default_value` to be absent-able. +fn is_bool_type(ty: &syn::Type) -> bool { + if let syn::Type::Path(type_path) = ty + && let Some(segment) = type_path.path.segments.last() + { + return segment.ident == "bool" && segment.arguments.is_none(); + } + false +} + +/// Check whether a type is `Option`. +fn is_option_type(ty: &syn::Type) -> bool { + if let syn::Type::Path(type_path) = ty + && let Some(segment) = type_path.path.segments.last() + { + return segment.ident == "Option"; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use syn::parse::Parser; + + /// Parse a single named struct field from tokens (e.g. + /// `#[arg(long, requires = "x")] pub y: u32`). + fn named_field(tokens: TokenStream2) -> syn::Field { + syn::Field::parse_named.parse2(tokens).expect("parse named field") + } + + /// Classify a field the way `expand` does, returning the diagnostic message + /// on rejection. + fn classify(tokens: TokenStream2) -> Result<(), String> { + let field = named_field(tokens); + ParsedField::from_field(&field, "p").map(|_| ()).map_err(|e| e.to_string()) + } + + /// Every `#[arg(...)]` spelling the macro classifies must be accepted in its + /// bare and name-value forms. + #[rstest] + #[case::short_and_string_default(quote! { #[arg(long, short = 'x', default_value = "7")] pub a: u32 })] + #[case::long_override_and_typed_default( + quote! { #[arg(long = "max-memory", default_value_t = 5)] pub b: usize } + )] + #[case::bare_long_on_option(quote! { #[arg(long)] pub c: Option })] + #[case::vec_with_required( + quote! { #[arg(long, value_delimiter = ',', required = true)] pub d: Vec } + )] + #[case::short_only(quote! { #[arg(short = 'x')] pub e: u32 })] + #[case::bare_skip(quote! { #[arg(skip)] pub f: u32 })] + #[case::skip_with_expr(quote! { #[arg(skip = 7u32)] pub g: u32 })] + #[case::long_alias(quote! { #[arg(long, alias = "ref")] pub h: u32 })] + #[case::alias_list(quote! { #[arg(long, aliases = ["ref", "fasta"])] pub i: u32 })] + #[case::short_alias(quote! { #[arg(long, short_alias = 'r')] pub j: u32 })] + #[case::os_default(quote! { #[arg(long, default_value_os = "/tmp/x")] pub k: PathBuf })] + #[case::doc_and_cfg(quote! { /// docs + #[cfg(unix)] + #[arg(long)] pub l: Option })] + fn accepts_supported_arg_forms(#[case] tokens: TokenStream2) { + assert_eq!(classify(tokens), Ok(()), "expected supported form to be accepted"); + } + + /// D1: every key that names another argument by id would dangle once the + /// field is prefixed. The macro classifies on the key alone, so the uniform + /// `key = "other"` spelling exercises each one. + #[rstest] + fn rejects_every_cross_reference_key( + #[values( + "requires", + "requires_all", + "requires_if", + "requires_ifs", + "conflicts_with", + "conflicts_with_all", + "overrides_with", + "overrides_with_all", + "required_if_eq", + "required_if_eq_all", + "required_if_eq_any", + "required_unless_present", + "required_unless_present_any", + "required_unless_present_all", + "default_value_if", + "default_value_ifs", + "default_values_if", + "default_values_ifs", + "group", + "groups" + )] + key: &str, + ) { + let key_ident = format_ident!("{}", key); + let err = classify(quote! { #[arg(long, #key_ident = "other")] pub a: u32 }) + .expect_err("cross-reference key must be rejected"); + assert!(err.contains(key), "message should name the key {key:?}: {err}"); + assert!(err.contains("field `a`"), "message should name the field: {err}"); + } + + /// Exhaustive by construction: drives the constant itself, so a key added to + /// `CROSS_REFERENCE_ARG_KEYS` is covered without touching the table above. + #[test] + fn every_cross_reference_key_in_the_table_is_rejected() { + for key in CROSS_REFERENCE_ARG_KEYS { + let key_ident = format_ident!("{}", key); + let err = classify(quote! { #[arg(long, #key_ident = "other")] pub a: u32 }) + .expect_err("every cross-reference key must be rejected"); + assert!(err.contains(key), "message should name the key {key:?}: {err}"); + } + } + + /// Every key the classifier reads or rewrites must also be rejected in + /// clap's `key(value)` call form — that form arrives as a `Meta::List` and + /// matches no classifier arm, so a key missing from + /// `CALL_FORM_SENSITIVE_ARG_KEYS` is silently preserved verbatim and the + /// rewrite is skipped. Adding a spelling to any table below without adding + /// it here reopens the D2 hole for that key. + #[test] + fn every_classified_key_is_call_form_sensitive() { + let classified = ["skip", "long", "short", "required", "help_heading"] + .iter() + .copied() + .chain(DEFAULT_VALUE_ARG_KEYS.iter().copied()) + .chain(LONG_ALIAS_ARG_KEYS.iter().copied()) + .chain(LONG_ALIAS_LIST_ARG_KEYS.iter().copied()) + .chain(SHORT_ALIAS_ARG_KEYS.iter().copied()); + for key in classified { + assert!( + CALL_FORM_SENSITIVE_ARG_KEYS.contains(&key), + "classified key {key:?} is missing from CALL_FORM_SENSITIVE_ARG_KEYS, so its \ + `{key}(…)` call form would slip past the classifier" + ); + } + } + + /// Every `default_value*` spelling must mark the field as defaulted; a + /// missed one is silently reclassified as required and gains a spurious + /// `Option` wrapper plus a false "Required when …" help line. + #[test] + fn every_default_value_spelling_makes_a_field_non_required() { + for key in DEFAULT_VALUE_ARG_KEYS { + let key_ident = format_ident!("{}", key); + let field = named_field(quote! { #[arg(long, #key_ident = "x")] pub a: u32 }); + let parsed = ParsedField::from_field(&field, "p") + .unwrap_or_else(|e| panic!("{key} should classify: {e}")); + assert!(parsed.has_default, "{key} must count as a default"); + assert!(!parsed.is_required_kind(), "{key} must not leave the field required"); + } + } + + /// A positional argument has no flag name to prefix, and clap panics when one + /// carries a `long` — which the companion always emits. + #[rstest] + #[case::no_attributes(quote! { pub a: u32 }, "positional")] + #[case::arg_without_long_or_short(quote! { #[arg(value_name = "FILE")] pub b: u32 }, "positional")] + #[case::explicit_index(quote! { #[arg(index = 1)] pub c: u32 }, "index")] + fn rejects_positional_fields(#[case] tokens: TokenStream2, #[case] needle: &str) { + let err = classify(tokens).expect_err("a positional field must be rejected"); + assert!(err.contains(needle), "message should mention {needle:?}: {err}"); + } + + /// A skipped field is never a CLI argument, so it needs no flag name. + #[test] + fn skipped_fields_are_exempt_from_the_positional_check() { + assert_eq!(classify(quote! { #[arg(skip)] pub a: u32 }), Ok(())); + } + + /// The macro cannot evaluate a `cfg` predicate, so a clap attribute behind + /// one would be ignored and the field misclassified. + #[rstest] + #[case::arg(quote! { #[cfg_attr(unix, arg(long, default_value_t = 3))] pub a: u32 })] + #[case::command(quote! { #[cfg_attr(unix, command(flatten))] pub b: u32 })] + #[case::legacy_clap(quote! { #[cfg_attr(unix, clap(long))] pub c: u32 })] + fn rejects_clap_attributes_hidden_behind_cfg_attr(#[case] tokens: TokenStream2) { + let err = classify(tokens).expect_err("a conditional clap attribute must be rejected"); + assert!(err.contains("cfg_attr"), "message should name the attribute: {err}"); + } + + /// A `#[cfg_attr]` carrying no clap attribute is forwarded untouched. + #[test] + fn allows_cfg_attr_without_a_clap_attribute() { + assert_eq!( + classify(quote! { #[cfg_attr(unix, doc = "unix only")] #[arg(long)] pub a: u32 }), + Ok(()) + ); + } + + /// D2: the `key(value)` call form arrives as `Meta::List` and would slip past + /// the `Meta::NameValue` / `Meta::Path` classifiers. + #[rstest] + #[case::long(quote! { #[arg(long("x"))] pub a: u32 }, "long")] + #[case::short(quote! { #[arg(long, short('x'))] pub b: u32 }, "short")] + #[case::default_value_t(quote! { #[arg(default_value_t(4))] pub c: u32 }, "default_value_t")] + #[case::required(quote! { #[arg(long, required(true))] pub d: u32 }, "required")] + #[case::alias(quote! { #[arg(long, alias("ref"))] pub e: u32 }, "alias")] + #[case::visible_alias( + quote! { #[arg(long, visible_alias("ref"))] pub f: u32 }, + "visible_alias" + )] + #[case::default_value_os( + quote! { #[arg(long, default_value_os("/tmp/x"))] pub g: PathBuf }, + "default_value_os" + )] + fn rejects_call_form_for_classified_keys(#[case] tokens: TokenStream2, #[case] needle: &str) { + let err = classify(tokens).expect_err("call form must be rejected"); + assert!(err.contains(needle), "message should name the key {needle:?}: {err}"); + } + + #[test] + fn allows_call_form_for_unclassified_keys() { + // `value_parser(...)` in call form is preserved verbatim and is not one of + // the classified keys, so it must not be rejected. + assert_eq!( + classify(quote! { #[arg(long, value_parser(clap::value_parser!(u32)))] pub a: u32 }), + Ok(()) + ); + } + + /// Keys the macro reads at expansion time cannot be arbitrary expressions. + #[rstest] + #[case::long(quote! { #[arg(long = SOME_CONST)] pub a: u32 }, "long")] + #[case::alias(quote! { #[arg(long, alias = SOME_CONST)] pub b: u32 }, "alias")] + #[case::aliases(quote! { #[arg(long, aliases = SOME_CONST)] pub c: u32 }, "aliases")] + #[case::aliases_of_non_literals( + quote! { #[arg(long, aliases = [SOME_CONST])] pub d: u32 }, + "aliases" + )] + #[case::required(quote! { #[arg(long, required = SOME_CONST)] pub e: u32 }, "required")] + fn rejects_non_literal_values_for_classified_keys( + #[case] tokens: TokenStream2, + #[case] needle: &str, + ) { + let err = classify(tokens).expect_err("non-literal value must be rejected"); + assert!(err.contains(needle), "message should name the key {needle:?}: {err}"); + } + + /// An explicit arg id would reintroduce the un-prefixed name. + #[rstest] + #[case::id(quote! { #[arg(long, id = "other")] pub a: u32 }, "id")] + #[case::name(quote! { #[arg(long, name = "other")] pub b: u32 }, "name")] + fn rejects_renaming_keys(#[case] tokens: TokenStream2, #[case] needle: &str) { + let err = classify(tokens).expect_err("renaming key must be rejected"); + assert!(err.contains(needle), "message should name the key {needle:?}: {err}"); + } + + /// The legacy spellings bypass every classifier, so they must not be ignored. + #[rstest] + #[case::clap_skip(quote! { #[clap(skip)] pub a: u32 })] + #[case::clap_long(quote! { #[clap(long, default_value = "3")] pub b: u32 })] + #[case::structopt(quote! { #[structopt(long)] pub c: u32 })] + fn rejects_legacy_attribute_spellings(#[case] tokens: TokenStream2) { + let err = classify(tokens).expect_err("legacy spelling must be rejected"); + assert!(err.contains("#[clap(...)]"), "message should name the spelling: {err}"); + } + + /// A field-level `#[command(...)]` either nests a struct the macro cannot + /// reach, or would silently not be carried over. + #[rstest] + #[case::flatten(quote! { #[command(flatten)] pub a: Inner }, "flatten")] + #[case::subcommand(quote! { #[command(subcommand)] pub b: Inner }, "subcommand")] + #[case::other(quote! { #[command(next_help_heading = "x")] pub c: u32 }, "field-level")] + fn rejects_field_level_command_attrs(#[case] tokens: TokenStream2, #[case] needle: &str) { + let err = classify(tokens).expect_err("field-level #[command(...)] must be rejected"); + assert!(err.contains(needle), "message should mention {needle:?}: {err}"); + } + + /// `#[command(flatten)]` is matched on the parsed meta, not on a substring of + /// the attribute's tokens, so a key that merely *contains* "flatten" is + /// reported as an ordinary unsupported field-level attribute. + #[test] + fn flatten_guard_does_not_substring_match() { + let err = classify(quote! { #[command(help_heading = "flatten me")] pub a: u32 }) + .expect_err("field-level #[command(...)] is rejected"); + assert!( + err.contains("field-level"), + "a value merely containing \"flatten\" must not be reported as a flatten: {err}" + ); + } + + /// `required` is only enforceable where absence is observable. + /// + /// The `Option` / `Vec` cases are the subtle ones: clap applies a + /// declared default even to those types, so the generated `is_none()` / + /// `is_empty()` check never fires and the requirement is silently lost. + #[rstest] + #[case::defaulted(quote! { #[arg(long, default_value_t = 3, required = true)] pub a: u32 })] + #[case::bare_bool(quote! { #[arg(long, required = true)] pub b: bool })] + #[case::skipped(quote! { #[arg(skip, required = true)] pub c: u32 })] + #[case::defaulted_option( + quote! { #[arg(long, default_value = "3", required = true)] pub d: Option } + )] + #[case::defaulted_vec( + quote! { #[arg(long, default_values_t = [1u32], required = true)] pub e: Vec } + )] + fn rejects_unenforceable_required(#[case] tokens: TokenStream2) { + let err = classify(tokens).expect_err("unenforceable `required` must be rejected"); + assert!(err.contains("required"), "message should name the key: {err}"); + } + + /// Without a default, absence IS observable on both types, so `required` is + /// enforceable and must be accepted. + #[rstest] + #[case::option(quote! { #[arg(long, required = true)] pub a: Option })] + #[case::vec(quote! { #[arg(long, required = true)] pub b: Vec })] + fn accepts_enforceable_required_on_absent_able_types(#[case] tokens: TokenStream2) { + assert_eq!(classify(tokens), Ok(())); + } + + /// A generic struct expands into a companion emitted without generics, so the + /// expansion cannot compile — and the resulting errors name `T`, never + /// `multi_options`. + #[rstest] + #[case::type_param(quote! { pub struct Opts { #[arg(long)] pub a: T } })] + #[case::lifetime(quote! { pub struct Opts<'a> { #[arg(long)] pub a: &'a str } })] + #[case::where_clause( + quote! { pub struct Opts where u32: Clone { #[arg(long)] pub a: u32 } } + )] + fn rejects_generic_structs(#[case] tokens: TokenStream2) { + let err = parse_annotated_struct(&tokens) + .expect_err("a generic struct must be rejected") + .to_string(); + assert!(err.contains("generic"), "message should mention generics: {err}"); + } + + /// The ordinary non-generic case must still pass the same guard. + #[test] + fn accepts_a_plain_struct() { + assert!(parse_annotated_struct("e! { pub struct Opts { pub a: u32 } }).is_ok()); + } + + /// `required = false` is a no-op and must not trip the enforceability check. + #[test] + fn accepts_required_false_on_a_defaulted_field() { + assert_eq!( + classify(quote! { #[arg(long, default_value_t = 3, required = false)] pub a: u32 }), + Ok(()) + ); + } + + /// `required` has a bare form as well as the name-value one, and only the + /// name-value `false` disables it. + #[rstest] + #[case::bare(quote! { #[arg(long, required)] pub a: Option }, true)] + #[case::explicit_true(quote! { #[arg(long, required = true)] pub a: Option }, true)] + #[case::explicit_false(quote! { #[arg(long, required = false)] pub a: Option }, false)] + #[case::absent(quote! { #[arg(long)] pub a: Option }, false)] + fn recognizes_every_required_spelling(#[case] tokens: TokenStream2, #[case] expected: bool) { + let field = named_field(tokens); + let parsed = ParsedField::from_field(&field, "p").expect("classify"); + assert_eq!(parsed.required, expected); + } + + /// A bare alias key carries no value to re-prefix, so it cannot be honored. + #[rstest] + #[case::bare_alias(quote! { #[arg(long, alias)] pub a: u32 }, "alias")] + #[case::bare_aliases(quote! { #[arg(long, aliases)] pub b: u32 }, "aliases")] + fn rejects_valueless_alias_keys(#[case] tokens: TokenStream2, #[case] needle: &str) { + let err = classify(tokens).expect_err("a valueless alias key must be rejected"); + assert!(err.contains(needle), "message should name the key {needle:?}: {err}"); + } + + /// Struct-level clap configuration is not reproduced on the companion, so it + /// must be rejected rather than silently applied to one command only. + #[rstest] + #[case::legacy_clap(quote! { #[clap(next_help_heading = "x")] }, "#[clap(...)]")] + #[case::legacy_structopt(quote! { #[structopt(name = "x")] }, "#[clap(...)]")] + #[case::group(quote! { #[group(required = true)] }, "#[group(...)]")] + #[case::command(quote! { #[command(next_help_heading = "x")] }, "#[command(...)]")] + fn rejects_unsupported_struct_level_attrs( + #[case] attr_tokens: TokenStream2, + #[case] needle: &str, + ) { + let item: syn::ItemStruct = syn::parse2(quote! { + #attr_tokens + pub struct Opts { pub a: u32 } + }) + .expect("parse struct"); + let err = reject_unsupported_struct_attrs(&item.attrs) + .expect_err("struct-level attr must be rejected") + .to_string(); + assert!(err.contains(needle), "message should name {needle:?}: {err}"); + } + + /// `#[derive(...)]` and doc comments ride along on every annotated struct and + /// must not be mistaken for clap configuration. + #[test] + fn accepts_derive_and_doc_attrs_on_the_struct() { + let item: syn::ItemStruct = syn::parse2(quote! { + /// Docs. + #[derive(clap::Args, Debug, Clone)] + pub struct Opts { pub a: u32 } + }) + .expect("parse struct"); + assert!(reject_unsupported_struct_attrs(&item.attrs).is_ok()); + } + + /// The attribute takes exactly two non-empty string literals. + #[rstest] + #[case::well_formed(quote! { "sort", "Sort Options" }, true)] + #[case::missing_heading(quote! { "sort" }, false)] + #[case::missing_comma(quote! { "sort" "Sort Options" }, false)] + #[case::extra_argument(quote! { "sort", "Sort Options", "extra" }, false)] + #[case::empty(quote! {}, false)] + #[case::empty_heading(quote! { "sort", "" }, false)] + #[case::non_literal(quote! { sort, "Sort Options" }, false)] + fn parses_only_two_string_literal_arguments( + #[case] tokens: TokenStream2, + #[case] expected_ok: bool, + ) { + assert_eq!(syn::parse2::(tokens).is_ok(), expected_ok); + } + + /// The prefix is spliced into both a flag name and an identifier. + #[rstest] + #[case::simple("sort", true)] + #[case::with_digit("codec2", true)] + #[case::with_underscore("read_group", true)] + #[case::with_dash("read-group", true)] + #[case::empty("", false)] + #[case::leading_digit("2fast", false)] + #[case::leading_dash("-sort", false)] + #[case::colon("so::rt", false)] + #[case::space("so rt", false)] + #[case::non_ascii("sørt", false)] + fn validate_prefix_accepts_only_identifier_fragments( + #[case] prefix: &str, + #[case] expected_ok: bool, + ) { + assert_eq!(validate_prefix(prefix).is_ok(), expected_ok, "prefix {prefix:?}"); + } + + fn parse_type(tokens: TokenStream2) -> syn::Type { + syn::parse2(tokens).expect("parse type") + } + + /// `is_vec_type` drives whether a field is absent-able, so a non-path type — + /// reference, array, tuple, slice — must answer `false` rather than panic or + /// match on the last path segment of something that has none. + #[rstest] + #[case::vec(quote! { Vec }, true)] + #[case::qualified_vec(quote! { std::vec::Vec }, true)] + #[case::option_not_vec(quote! { Option }, false)] + #[case::plain(quote! { u32 }, false)] + #[case::reference(quote! { &str }, false)] + #[case::array(quote! { [u8; 4] }, false)] + #[case::tuple(quote! { (u32, u32) }, false)] + #[case::unit(quote! { () }, false)] + fn is_vec_type_only_matches_path_types_named_vec( + #[case] tokens: TokenStream2, + #[case] expected: bool, + ) { + assert_eq!(is_vec_type(&parse_type(tokens)), expected); + } + + /// Same for `is_option_type`, which decides whether a field is treated as + /// optional. + #[rstest] + #[case::option(quote! { Option }, true)] + #[case::qualified_option(quote! { std::option::Option }, true)] + #[case::vec_not_option(quote! { Vec }, false)] + #[case::plain(quote! { u32 }, false)] + #[case::reference(quote! { &str }, false)] + #[case::array(quote! { [u8; 4] }, false)] + #[case::tuple(quote! { (u32, u32) }, false)] + #[case::unit(quote! { () }, false)] + fn is_option_type_only_matches_path_types_named_option( + #[case] tokens: TokenStream2, + #[case] expected: bool, + ) { + assert_eq!(is_option_type(&parse_type(tokens)), expected); + } + + /// `is_bool_type` keeps clap's valueless `SetTrue` idiom out of the required + /// classification. `Option` and `Vec` are *not* bare bools. + #[rstest] + #[case::bare_bool(quote! { bool }, true)] + #[case::qualified_bool(quote! { core::primitive::bool }, true)] + #[case::option_bool(quote! { Option }, false)] + #[case::vec_bool(quote! { Vec }, false)] + #[case::plain(quote! { u32 }, false)] + #[case::reference(quote! { &bool }, false)] + #[case::tuple(quote! { (bool, bool) }, false)] + #[case::unit(quote! { () }, false)] + fn is_bool_type_only_matches_the_bare_bool_path( + #[case] tokens: TokenStream2, + #[case] expected: bool, + ) { + assert_eq!(is_bool_type(&parse_type(tokens)), expected); + } +} diff --git a/crates/fgumi-cli-macros/tests/behavior.rs b/crates/fgumi-cli-macros/tests/behavior.rs new file mode 100644 index 000000000..71cafbac9 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/behavior.rs @@ -0,0 +1,698 @@ +//! Behavioral contract tests for `multi_options`. +//! +//! Where `smoke.rs` covers the three field-kind branches, this file pins the +//! properties that make the re-exposed flags *faithful* to the standalone +//! command: the declared clap default is the one that applies, help headings +//! do not leak onto the parent command, short/long help stay split, `cfg` +//! gating survives, aliases are namespaced, and the +//! `From` + `validate()` pair is a genuine round trip. +//! +//! Each fixture is built around an attribute shape a real fgumi options struct +//! actually uses; `real_world.rs` assembles those shapes into larger fixtures +//! modelled on the `Sort` and `GroupReadsByUmi` commands. + +use std::path::PathBuf; + +use clap::{Args, CommandFactory, Parser}; +use fgumi_cli_macros::multi_options; + +// ───────────────────────────────────────────────────────────────────────────── +// Declared clap defaults are authoritative +// ───────────────────────────────────────────────────────────────────────────── + +/// The `Default` impl below deliberately DISAGREES with every `default_value*` +/// attribute. A macro that re-derives defaults from `Struct::default()` would +/// hand the prefixed flag the `Default` value; preserving the original +/// attribute verbatim keeps the standalone and prefixed flags identical. +#[multi_options("drift", "Drift Options")] +#[derive(Args, Debug, Clone, PartialEq)] +pub struct DriftOptions { + /// String-form default. + #[arg(long, default_value = "42")] + pub string_defaulted: u32, + + /// Typed default. + #[arg(long, default_value_t = 7)] + pub typed_defaulted: u32, + + /// OS-string default — `default_value_os` must count as "has a default", + /// not be misclassified as a required field. + #[arg(long, default_value_os = "/tmp/os-default")] + pub os_defaulted: PathBuf, +} + +impl Default for DriftOptions { + fn default() -> Self { + // Every value here disagrees with the clap default above. + Self { + string_defaulted: 999, + typed_defaulted: 999, + os_defaulted: PathBuf::from("/tmp/wrong"), + } + } +} + +#[derive(Parser, Debug)] +struct DriftWrapper { + #[command(flatten)] + opts: MultiDriftOptions, +} + +#[test] +fn declared_clap_defaults_win_over_a_disagreeing_default_impl() { + let parsed = DriftWrapper::try_parse_from(["test-prog"]).expect("parse with all defaults"); + let opts = parsed.opts.validate().expect("validate"); + + assert_eq!(opts.string_defaulted, 42, "string-form default_value must be preserved verbatim"); + assert_eq!(opts.typed_defaulted, 7, "default_value_t must be preserved verbatim"); + assert_eq!( + opts.os_defaulted, + PathBuf::from("/tmp/os-default"), + "default_value_os must be preserved verbatim" + ); +} + +#[test] +fn default_value_os_field_is_not_treated_as_required() { + // A field misclassified as required would be wrapped in `Option` and + // rejected by `validate()` when omitted; it also would not carry the + // default. Parsing with no flags at all proves it is optional. + let parsed = DriftWrapper::try_parse_from(["test-prog"]).expect("parse"); + assert!(parsed.opts.validate().is_ok(), "default_value_os field must not be required"); +} + +/// Asserted per argument rather than against the rendered help text: a bare +/// `help.contains("42")` also passes when the value appears anywhere else in +/// the help, including on a different flag. +#[rstest::rstest] +#[case::string_form("drift::string-defaulted", "42")] +#[case::typed("drift::typed-defaulted", "7")] +#[case::os_string("drift::os-defaulted", "/tmp/os-default")] +fn prefixed_defaults_are_advertised_in_help(#[case] long: &str, #[case] expected: &str) { + let command = DriftWrapper::command(); + let arg = command + .get_arguments() + .find(|arg| arg.get_long() == Some(long)) + .unwrap_or_else(|| panic!("--{long} should be registered")); + let defaults: Vec = + arg.get_default_values().iter().map(|value| value.to_string_lossy().into_owned()).collect(); + assert_eq!( + defaults, + vec![expected.to_string()], + "--{long} must advertise its declared default" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Help headings do not leak onto the parent command +// ───────────────────────────────────────────────────────────────────────────── + +#[multi_options("stage", "Stage Options")] +#[derive(Args, Debug, Clone, PartialEq)] +pub struct StageOptions { + /// A stage knob. + #[arg(long, default_value_t = 1)] + pub knob: u32, +} + +impl Default for StageOptions { + fn default() -> Self { + Self { knob: 1 } + } +} + +#[test] +fn parent_args_declared_after_a_flattened_multi_keep_their_own_heading() { + /// A parent command that declares its own argument *after* flattening the + /// generated struct. With a struct-level `next_help_heading`, clap applies + /// the heading to every argument registered after it — silently filing + /// `--after-the-flatten` under "Stage Options". + #[derive(Parser, Debug)] + struct Wrapper { + #[command(flatten)] + stage: MultiStageOptions, + + /// Declared after the flatten; belongs to the parent, not the stage. + #[arg(long)] + after_the_flatten: Option, + } + + // Asserted on each argument's heading rather than on where it lands in the + // rendered help: help ordering is clap's to change, and a parent argument + // rendered before the heading may still carry it. + let command = Wrapper::command(); + let heading_of = |long: &str| { + command + .get_arguments() + .find(|arg| arg.get_long() == Some(long)) + .unwrap_or_else(|| panic!("--{long} should be registered")) + .get_help_heading() + .map(ToString::to_string) + }; + + assert_eq!( + heading_of("stage::knob"), + Some("Stage Options".to_string()), + "the stage's own argument must carry the stage heading" + ); + assert_eq!( + heading_of("after-the-flatten"), + None, + "--after-the-flatten belongs to the parent and must carry no stage heading" + ); +} + +// A parent that declares no doc comment of its own. The comment below is +// deliberately NOT a rustdoc comment: clap adopts a parent's own doc as its +// description, which would mask the very leak this test looks for. +// +// clap adopts a flattened `Args` struct's doc comment as the parent command's +// description whenever the parent has none. The generated companion carries +// rustdoc (crates that deny(missing_docs) require it), so the macro must reset +// `about` explicitly — otherwise an undocumented parent would describe itself +// with the companion's boilerplate, and with several stages flattened clap +// would arbitrarily pick whichever came first. +#[derive(Parser, Debug)] +struct UndocumentedParent { + #[command(flatten)] + opts: MultiStageOptions, +} + +#[test] +fn the_companions_own_docs_never_become_the_parents_description() { + // Asserted on the command's own metadata rather than on rendered help, so + // the test cannot be satisfied by clap merely laying the description out + // somewhere the substring check does not look. + let command = UndocumentedParent::command(); + let about = command.get_about().map(ToString::to_string); + let long_about = command.get_long_about().map(ToString::to_string); + + // The contract is that an undocumented parent stays undocumented, not + // merely that the companion's *current* boilerplate wording is absent — a + // substring check would start passing the moment that wording changed, + // leak and all. + for (label, description) in [("about", &about), ("long_about", &long_about)] { + assert_eq!( + *description, None, + "an undocumented parent must keep an unset {label}, got {description:?}" + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Field-level help metadata +// ───────────────────────────────────────────────────────────────────────────── + +/// A field-level `help_heading` cannot be namespaced per stage. Emitted after +/// the companion's own heading it would win, filing the field outside its +/// stage — and two stages declaring the same heading would merge into one +/// section naming neither. `help` carries no such ambiguity: it is the field's +/// own documentation and reads identically on both commands. +#[multi_options("meta", "Meta Options")] +#[derive(Args, Debug, Clone, PartialEq)] +pub struct MetaOptions { + /// Doc-derived help. + #[arg(long, help_heading = "Escape Hatch")] + pub own_heading: Option, + + /// Doc-derived help that clap must not prefer. + #[arg(long, help = "explicit help wins")] + pub own_help: Option, +} + +#[derive(Parser, Debug)] +struct MetaWrapper { + #[command(flatten)] + opts: MultiMetaOptions, +} + +#[test] +fn a_field_level_help_heading_cannot_escape_the_stages_heading() { + let command = MetaWrapper::command(); + let arg = command + .get_arguments() + .find(|arg| arg.get_long() == Some("meta::own-heading")) + .expect("--meta::own-heading should be registered"); + + assert_eq!( + arg.get_help_heading().map(ToString::to_string), + Some("Meta Options".to_string()), + "the stage's heading must win over the field's own" + ); +} + +#[test] +fn an_explicit_help_is_preserved_verbatim() { + let command = MetaWrapper::command(); + let arg = command + .get_arguments() + .find(|arg| arg.get_long() == Some("meta::own-help")) + .expect("--meta::own-help should be registered"); + + assert_eq!( + arg.get_help().map(ToString::to_string), + Some("explicit help wins".to_string()), + "an explicit help is the field's own documentation and must survive re-exposure" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Short/long help split survives re-exposure +// ───────────────────────────────────────────────────────────────────────────── + +#[multi_options("docs", "Docs Options")] +#[derive(Args, Debug, Clone, PartialEq)] +pub struct DocsOptions { + /// Short summary line. + /// + /// Longer explanation that clap shows only for `--help`, never for `-h`. + /// Real fgumi options structs carry several such paragraphs per field. + #[arg(long, default_value_t = 1)] + pub documented: u32, +} + +impl Default for DocsOptions { + fn default() -> Self { + Self { documented: 1 } + } +} + +#[derive(Parser, Debug)] +struct DocsWrapper { + #[command(flatten)] + opts: MultiDocsOptions, +} + +/// Returns the `--docs::documented` argument of the prefixed companion. +/// +/// The short/long split is asserted on `Arg::get_help()`/`get_long_help()` +/// rather than on `render_help()`: rendered help is clap's layout concern and +/// wraps to the terminal width once the `wrap_help` feature is anywhere in the +/// workspace feature graph, so a substring check there can fail while the +/// contract holds — and pass while it does not, since the text of one argument +/// is indistinguishable from any other's in the rendered block. +fn documented_arg(command: &clap::Command) -> &clap::Arg { + command + .get_arguments() + .find(|arg| arg.get_long() == Some("docs::documented")) + .expect("--docs::documented should be registered") +} + +#[test] +fn short_help_shows_only_the_first_doc_paragraph() { + let command = DocsWrapper::command(); + // clap trims the trailing period when it derives short help from a doc + // comment, so match the sentence without it. + let short = documented_arg(&command).get_help().map(ToString::to_string); + let short = short.expect("the documented field must carry short help"); + assert!(short.contains("Short summary line"), "short help should show the summary: {short}"); + assert!( + !short.contains("Longer explanation"), + "short help must not carry the long explanation: {short}" + ); +} + +#[test] +fn long_help_shows_every_doc_paragraph() { + let command = DocsWrapper::command(); + let long = documented_arg(&command).get_long_help().map(ToString::to_string); + let long = long.expect("the documented field must carry long help"); + assert!(long.contains("Short summary line."), "long help should show the summary: {long}"); + assert!( + long.contains("Longer explanation"), + "long help should show the full explanation: {long}" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// cfg-gated fields +// ───────────────────────────────────────────────────────────────────────────── + +/// `#[cfg(any())]` is never satisfied and `#[cfg(all())]` always is, so this +/// fixture exercises both sides of cfg forwarding without needing a feature +/// flag. A macro that drops `#[cfg]` emits a Multi field (and `validate()` / +/// `From` arms) for a field that does not exist on the original struct, which +/// does not compile — so merely building this file is most of the test. +#[multi_options("gated", "Gated Options")] +#[derive(Args, Debug, Clone, PartialEq)] +pub struct GatedOptions { + /// Always present. + #[arg(long, default_value_t = 1)] + pub always: u32, + + /// Compiled out. + #[cfg(any())] + #[arg(long, default_value_t = 2)] + pub never: u32, +} + +impl Default for GatedOptions { + fn default() -> Self { + Self { always: 1 } + } +} + +#[derive(Parser, Debug)] +struct GatedWrapper { + #[command(flatten)] + opts: MultiGatedOptions, +} + +#[test] +fn cfg_gated_out_field_is_absent_from_the_multi_struct() { + // Constructing the Multi struct with only the surviving field proves the + // gated-out field was not emitted. + let multi = MultiGatedOptions { gated_always: 5 }; + let opts = multi.validate().expect("validate"); + assert_eq!(opts.always, 5); + + assert!( + GatedWrapper::command().get_arguments().all(|arg| arg.get_long() != Some("gated::never")), + "the cfg-ed out field must not be registered as an argument" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Aliases are namespaced; short flags are never propagated +// ───────────────────────────────────────────────────────────────────────────── + +/// Mirrors `fgumi clip`'s `--ref` / `-r` shape: a long override, a short flag +/// and a long alias on one required field. `multi_aliased` additionally covers +/// the list spellings, which take a different code path to the single-valued +/// ones and would otherwise only be checked for "does not error". +#[multi_options("alias", "Alias Options")] +#[derive(Args, Debug, Clone, PartialEq)] +pub struct AliasOptions { + /// Reference fasta, with a short flag and a long alias. + #[arg(long = "reference", short = 'r', alias = "ref")] + pub reference: PathBuf, + + /// Carries every long-alias spelling at once. + #[arg( + long = "multi-aliased", + aliases = ["one", "two"], + visible_alias = "vis", + visible_aliases = ["vis-one", "vis-two"], + short_alias = 's', + default_value_t = 0 + )] + pub multi_aliased: u32, +} + +#[derive(Parser, Debug)] +struct AliasWrapper { + #[command(flatten)] + opts: MultiAliasOptions, +} + +#[test] +fn long_alias_is_namespaced_under_the_prefix() { + let parsed = AliasWrapper::try_parse_from(["test-prog", "--alias::ref", "/tmp/a.fa"]) + .expect("the prefixed alias should parse"); + let opts = parsed.opts.validate().expect("validate"); + assert_eq!(opts.reference, PathBuf::from("/tmp/a.fa")); +} + +/// Assert a parse failed *because clap does not know the flag* — the contract +/// under test — rather than for any reason at all. +/// +/// A bare `is_err()` would pass by accident: every companion field is defaulted, +/// absent-able, or staged as `Option`, so clap never fails a parse for a +/// missing value today, and a later fixture change could satisfy the assertion +/// with an unrelated failure. +#[track_caller] +fn assert_unknown_argument(result: Result, flag: &str) { + let err = result.map(|_| ()).expect_err(&format!("{flag} must be rejected")); + assert_eq!( + err.kind(), + clap::error::ErrorKind::UnknownArgument, + "{flag} must be rejected as an unknown argument, got {:?}: {err}", + err.kind() + ); +} + +#[test] +fn unprefixed_alias_does_not_leak_onto_the_parent_command() { + assert_unknown_argument( + AliasWrapper::try_parse_from(["test-prog", "--ref", "/tmp/a.fa"]), + "--ref", + ); +} + +#[test] +fn short_flag_is_not_propagated() { + assert_unknown_argument(AliasWrapper::try_parse_from(["test-prog", "-r", "/tmp/a.fa"]), "-r"); + assert_unknown_argument( + AliasWrapper::try_parse_from(["test-prog", "--alias::reference", "/tmp/a.fa", "-s", "1"]), + "-s", + ); +} + +/// Every long-alias spelling — `aliases`, `visible_alias`, `visible_aliases` — +/// must reach clap re-prefixed, not merely survive classification. +#[rstest::rstest] +#[case::alias_list_first("--alias::one")] +#[case::alias_list_second("--alias::two")] +#[case::visible_alias("--alias::vis")] +#[case::visible_alias_list_first("--alias::vis-one")] +#[case::visible_alias_list_second("--alias::vis-two")] +fn every_long_alias_spelling_is_namespaced(#[case] flag: &str) { + let parsed = + AliasWrapper::try_parse_from(["test-prog", "--alias::reference", "/tmp/a.fa", flag, "7"]) + .unwrap_or_else(|e| panic!("{flag} should parse: {e}")); + assert_eq!(parsed.opts.validate().expect("validate").multi_aliased, 7); +} + +/// The un-prefixed spellings must not reach the parent command, where two +/// stages re-exposing the same options struct would collide on them. +#[rstest::rstest] +#[case::alias_list_first("--one")] +#[case::alias_list_second("--two")] +#[case::visible_alias("--vis")] +#[case::visible_alias_list_first("--vis-one")] +#[case::visible_alias_list_second("--vis-two")] +fn no_long_alias_spelling_leaks_unprefixed(#[case] flag: &str) { + assert_unknown_argument( + AliasWrapper::try_parse_from(["test-prog", "--alias::reference", "/tmp/a.fa", flag, "7"]), + flag, + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Bare `bool` stays a valueless flag +// ───────────────────────────────────────────────────────────────────────────── + +#[multi_options("flag", "Flag Options")] +#[derive(Args, Debug, Clone, PartialEq)] +pub struct FlagOptions { + /// Bare bool — clap's `SetTrue` idiom, absent means `false`. + #[arg(long)] + pub enabled: bool, +} + +#[derive(Parser, Debug)] +struct FlagWrapper { + #[command(flatten)] + opts: MultiFlagOptions, +} + +#[test] +fn bare_bool_defaults_to_false_without_being_required() { + let parsed = FlagWrapper::try_parse_from(["test-prog"]).expect("parse with no flags"); + let opts = parsed.opts.validate().expect("a bare bool must not be required"); + assert!(!opts.enabled); +} + +#[test] +fn bare_bool_takes_no_value() { + let parsed = + FlagWrapper::try_parse_from(["test-prog", "--flag::enabled"]).expect("valueless flag"); + assert!(parsed.opts.validate().expect("validate").enabled); +} + +// ───────────────────────────────────────────────────────────────────────────── +// `required` on absent-able types is enforced by validate() +// ───────────────────────────────────────────────────────────────────────────── + +#[multi_options("req", "Req Options")] +#[derive(Args, Debug, Clone, PartialEq)] +pub struct RequiredOptions { + /// Required despite being `Option` on the standalone command. + #[arg(long, required = true)] + pub needed: Option, + + /// Required despite being `Vec` on the standalone command. + #[arg(long, required = true, action = clap::ArgAction::Append)] + pub needed_many: Vec, +} + +#[derive(Parser, Debug)] +struct RequiredWrapper { + #[command(flatten)] + opts: MultiRequiredOptions, +} + +#[test] +fn required_option_field_is_enforced_by_validate() { + // clap must not enforce it during parsing (staged validation owns that), + // but validate() must still refuse the missing value. + let parsed = RequiredWrapper::try_parse_from(["test-prog", "--req::needed-many", "1"]) + .expect("parse should succeed; required-ness is staged"); + let err = parsed.opts.validate().expect_err("validate must reject the missing Option field"); + let msg = format!("{err:#}"); + // `--req::needed` is a prefix of `--req::needed-many`, so match the whole + // " is required" phrase: a bare substring check would pass even if + // validate() named the wrong field. + assert!( + msg.contains("--req::needed is required"), + "error should name the Option field, not --req::needed-many: {msg}" + ); +} + +#[test] +fn required_vec_field_is_enforced_by_validate() { + let parsed = RequiredWrapper::try_parse_from(["test-prog", "--req::needed", "1"]) + .expect("parse should succeed; required-ness is staged"); + let err = parsed.opts.validate().expect_err("validate must reject the empty Vec field"); + let msg = format!("{err:#}"); + assert!( + msg.contains("--req::needed-many is required"), + "error should name the Vec field: {msg}" + ); +} + +#[test] +fn required_fields_validate_once_supplied() { + let parsed = RequiredWrapper::try_parse_from([ + "test-prog", + "--req::needed", + "3", + "--req::needed-many", + "4", + ]) + .expect("parse"); + let opts = parsed.opts.validate().expect("validate"); + assert_eq!(opts.needed, Some(3)); + assert_eq!(opts.needed_many, vec![4]); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Vec fields pass through; the struct `Default` is not consulted +// ───────────────────────────────────────────────────────────────────────────── + +/// `Default` returns a non-empty Vec, but clap never consults a struct's +/// `Default` — the standalone command yields an empty Vec when the flag is +/// omitted, so the prefixed flag must too. +#[multi_options("vecs", "Vec Options")] +#[derive(Args, Debug, Clone, PartialEq)] +pub struct VecOptions { + /// Repeatable values. + #[arg(long, action = clap::ArgAction::Append)] + pub values: Vec, +} + +impl Default for VecOptions { + fn default() -> Self { + Self { values: vec![7, 8, 9] } + } +} + +#[test] +fn omitted_vec_matches_the_standalone_command_not_the_struct_default() { + #[derive(Parser, Debug)] + struct Wrapper { + #[command(flatten)] + opts: MultiVecOptions, + } + #[derive(Parser, Debug)] + struct Standalone { + #[command(flatten)] + opts: VecOptions, + } + + let standalone = Standalone::try_parse_from(["test-prog"]).expect("parse standalone"); + let prefixed = Wrapper::try_parse_from(["test-prog"]).expect("parse prefixed"); + + assert!(standalone.opts.values.is_empty(), "standalone yields an empty Vec"); + assert_eq!( + prefixed.opts.validate().expect("validate").values, + standalone.opts.values, + "the prefixed flag must agree with the standalone command" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// From + validate() is a lossless round trip +// ───────────────────────────────────────────────────────────────────────────── + +/// Mirrors `GroupOptions`' shape: CLI fields plus `#[arg(skip)]` slots that the +/// command fills in itself. +#[multi_options("trip", "Trip Options")] +#[derive(Args, Debug, Clone, PartialEq)] +pub struct TripOptions { + /// A normal flag. + #[arg(long, default_value_t = 1)] + pub knob: u32, + + /// Skipped slot with an explicit expression. + #[arg(skip = 5u32)] + pub skipped_with_expr: u32, + + /// Bare skipped slot. + #[arg(skip)] + pub bare_skipped: u32, +} + +impl Default for TripOptions { + fn default() -> Self { + Self { knob: 1, skipped_with_expr: 5, bare_skipped: 11 } + } +} + +#[test] +fn round_trip_preserves_skip_field_values() { + // Values that match neither the skip expression nor the struct Default, so + // a re-derived value is distinguishable from a preserved one. + let original = TripOptions { knob: 2, skipped_with_expr: 100, bare_skipped: 200 }; + let multi: MultiTripOptions = original.clone().into(); + let back = multi.validate().expect("validate"); + assert_eq!(back, original, "From + validate must preserve skip-field values"); +} + +#[test] +fn parsed_skip_fields_fall_back_to_their_declared_value() { + #[derive(Parser, Debug)] + struct Wrapper { + #[command(flatten)] + opts: MultiTripOptions, + } + + let parsed = Wrapper::try_parse_from(["test-prog"]).expect("parse"); + let opts = parsed.opts.validate().expect("validate"); + assert_eq!(opts.skipped_with_expr, 5, "skip = expr supplies the parse-time value"); + assert_eq!(opts.bare_skipped, 11, "bare skip falls back to the struct Default"); +} + +#[test] +fn skip_fields_are_not_exposed_as_flags() { + #[derive(Parser, Debug)] + struct Wrapper { + #[command(flatten)] + opts: MultiTripOptions, + } + // Assert on the registered arguments, which is the actual contract; a help + // substring check would break on any doc comment that happens to use the word. + let command = Wrapper::command(); + let longs: Vec<&str> = command.get_arguments().filter_map(clap::Arg::get_long).collect(); + assert!(!longs.contains(&"trip::skipped-with-expr"), "skip fields must not become flags"); + assert!(!longs.contains(&"trip::bare-skipped"), "skip fields must not become flags"); + assert!(longs.contains(&"trip::knob"), "the non-skip field should still be registered"); +} + +#[test] +fn try_from_is_the_canonical_conversion() { + let original = TripOptions { knob: 3, skipped_with_expr: 4, bare_skipped: 5 }; + let multi = MultiTripOptions::from(original.clone()); + let back = TripOptions::try_from(multi).expect("TryFrom should succeed"); + assert_eq!(back, original); +} diff --git a/crates/fgumi-cli-macros/tests/compile_fail.rs b/crates/fgumi-cli-macros/tests/compile_fail.rs new file mode 100644 index 000000000..8a908dbc9 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/compile_fail.rs @@ -0,0 +1,13 @@ +//! Compile-fail coverage for the build-time diagnostics `multi_options` documents. +//! +//! The macro reports every unsupported input by returning a spanned `syn::Error` +//! and emitting it via `into_compile_error()`, so these surface as ordinary +//! compilation errors — they cannot be exercised with `#[should_panic]`, because +//! the macro runs while the test crate is being compiled, not while it runs. +//! `trybuild` compiles each case and diffs the emitted error against its +//! committed `.stderr`, which pins both the message and the span it points at. + +#[test] +fn documented_invalid_inputs_fail_to_compile() { + trybuild::TestCases::new().compile_fail("tests/ui/*.rs"); +} diff --git a/crates/fgumi-cli-macros/tests/real_world.rs b/crates/fgumi-cli-macros/tests/real_world.rs new file mode 100644 index 000000000..cbefbf9ba --- /dev/null +++ b/crates/fgumi-cli-macros/tests/real_world.rs @@ -0,0 +1,500 @@ +//! Parity tests over fixtures shaped like the fgumi options structs +//! `multi_options` exists to re-expose. +//! +//! The macro's classification rules only matter insofar as they hold for the +//! attribute *combinations* `runall` will actually annotate, so the fixtures +//! below are modelled on the two commands with the widest `#[arg(...)]` surface: +//! `Sort` (`src/lib/commands/sort.rs`) and `GroupReadsByUmi` +//! (`src/lib/commands/group.rs`). Between them they cover a string-form +//! `default_value` on a `Display`-less type, a `long` override paired with a +//! `short`, a repeatable `Vec`, a ranged `value_parser`, a hidden expert flag, +//! `#[arg(skip)]` slots with and without a declared value, a required +//! `value_enum`, and the `num_args = 0..=1` boolean form every fgumi flag uses. +//! +//! These are **fixtures, not mirrors**. `fgumi-cli-macros` sits below the fgumi +//! commands in the dependency graph — they will annotate their options structs +//! with `multi_options`, not the other way round — so its tests deliberately do +//! not reach into `fgumi_lib` for the production definitions: the value types are +//! local stand-ins, and the fields and defaults are not kept in lockstep (the +//! production `Sort`, for one, spells its memory default `"768M"`, not +//! `"768MiB"`). What is under test is the macro's contract, not fgumi's option +//! set — a production option change is expected to leave these tests untouched +//! and is covered by that command's own tests. What warrants a fixture here is a +//! genuinely new *attribute shape* reaching a struct the macro must re-expose. +//! +//! The central assertion is parity: parsing the standalone command and parsing +//! the prefixed `Multi*` companion with the same inputs must produce identical +//! option structs. That covers every classification rule at once — a dropped +//! default, a misclassified `bool`, a leaked alias or a lost `value_parser` all +//! surface as a field that disagrees. + +use std::path::PathBuf; + +use clap::{Args, CommandFactory, Parser, ValueEnum}; +use fgumi_cli_macros::multi_options; + +// ───────────────────────────────────────────────────────────────────────────── +// Local stand-ins for the fgumi value types +// ───────────────────────────────────────────────────────────────────────────── + +/// Stand-in for `fgumi_lib`'s `MemoryLimit`, which parses "768MiB"-style values +/// and deliberately does **not** implement `Display` — the reason the macro must +/// preserve the string-form `default_value` rather than rewriting it to +/// `default_value_t`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct MemoryLimit(pub u64); + +fn parse_memory(value: &str) -> Result { + let (digits, multiplier) = match value.strip_suffix("MiB") { + Some(digits) => (digits, 1024 * 1024), + None => match value.strip_suffix("GiB") { + Some(digits) => (digits, 1024 * 1024 * 1024), + None => (value, 1), + }, + }; + digits + .parse::() + .map(|n| MemoryLimit(n * multiplier)) + .map_err(|e| format!("invalid memory value {value:?}: {e}")) +} + +/// Stand-in for the `parse_bool` value parser shared by every fgumi boolean flag. +fn parse_bool(value: &str) -> Result { + match value { + "true" | "yes" | "t" | "y" => Ok(true), + "false" | "no" | "f" | "n" => Ok(false), + other => Err(format!("invalid boolean {other:?}")), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)] +pub enum SpillCodec { + #[default] + Zstd, + Bgzf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)] +pub enum Strategy { + #[default] + Identity, + Edit, + Adjacency, + Paired, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct SortOrderArg; + +// ───────────────────────────────────────────────────────────────────────────── +// `SortOptions` — string-form defaults, a `Display`-less value type, a long +// override with a short flag, a repeatable Vec, hidden expert flags, a skip slot +// ───────────────────────────────────────────────────────────────────────────── + +#[multi_options("sort", "Sort Options")] +#[derive(Args, Debug, Clone, PartialEq)] +pub struct SortOptions { + /// Maximum memory for in-memory sorting. + /// + /// Default is "768MiB" per thread (matching samtools' 768 MiB). Explicit + /// values like "512MiB", "1GiB", "4GiB" are per-thread when + /// --memory-per-thread is enabled (default). + /// + /// When the limit is reached, sorted chunks spill to temporary files. + #[arg(short = 'm', long = "max-memory", default_value = "768MiB", value_parser = parse_memory)] + pub max_memory: MemoryLimit, + + /// Scale memory limit by thread count (samtools behavior). + #[arg(long = "memory-per-thread", default_value = "true", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] + pub memory_per_thread: bool, + + /// Temporary directory for intermediate files. Repeatable. + #[arg(short = 'T', long = "tmp-dir", action = clap::ArgAction::Append)] + pub tmp_dirs: Vec, + + /// Compression level for temporary chunk files (0-9). + #[arg(long = "temp-compression", default_value = "1", value_parser = clap::value_parser!(u32).range(0..=9))] + pub temp_compression: u32, + + /// Codec for temporary spill files: `zstd` (default) or `bgzf`. + #[arg(long = "temp-codec", default_value = "zstd")] + pub temp_codec: SpillCodec, + + /// Worker threads for the accumulation/sort/spill phase (Phase 1). + #[arg(long = "sort-threads")] + pub sort_threads: Option, + + /// Phase-2 spill decompression granularity (expert tuning). + #[arg(long = "file-granularity", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool, hide = true)] + pub file_granularity: bool, + + /// Sort order (chain-builder slot), populated by the command, never by clap. + #[arg(skip)] + pub order: SortOrderArg, +} + +impl Default for SortOptions { + fn default() -> Self { + Self { + max_memory: MemoryLimit(768 * 1024 * 1024), + memory_per_thread: true, + tmp_dirs: Vec::new(), + temp_compression: 1, + temp_codec: SpillCodec::Zstd, + sort_threads: None, + file_granularity: false, + order: SortOrderArg, + } + } +} + +#[derive(Parser, Debug)] +struct StandaloneSort { + #[command(flatten)] + opts: SortOptions, +} + +#[derive(Parser, Debug)] +struct PrefixedSort { + #[command(flatten)] + opts: MultiSortOptions, +} + +#[test] +fn sort_options_defaults_match_between_standalone_and_prefixed() { + let standalone = StandaloneSort::try_parse_from(["fgumi"]).expect("standalone defaults"); + let prefixed = PrefixedSort::try_parse_from(["fgumi"]).expect("prefixed defaults"); + + assert_eq!( + prefixed.opts.validate().expect("validate"), + standalone.opts, + "every re-exposed default must equal the standalone command's default" + ); +} + +#[test] +fn sort_options_supplied_values_match_between_standalone_and_prefixed() { + let standalone = StandaloneSort::try_parse_from([ + "fgumi", + "--max-memory", + "4GiB", + "--memory-per-thread", + "false", + "--tmp-dir", + "/scratch/a", + "--tmp-dir", + "/scratch/b", + "--temp-compression", + "9", + "--temp-codec", + "bgzf", + "--sort-threads", + "8", + ]) + .expect("standalone parse"); + + let prefixed = PrefixedSort::try_parse_from([ + "fgumi", + "--sort::max-memory", + "4GiB", + "--sort::memory-per-thread", + "false", + "--sort::tmp-dir", + "/scratch/a", + "--sort::tmp-dir", + "/scratch/b", + "--sort::temp-compression", + "9", + "--sort::temp-codec", + "bgzf", + "--sort::sort-threads", + "8", + ]) + .expect("prefixed parse"); + + assert_eq!(prefixed.opts.validate().expect("validate"), standalone.opts); +} + +/// Assert a parse failed for the stated reason, not merely that it failed. A bare +/// `is_err()` would be satisfied by any unrelated failure a later fixture change +/// introduced. +#[track_caller] +fn assert_parse_error_kind( + result: Result, + expected: clap::error::ErrorKind, + what: &str, +) { + let err = result.map(|_| ()).expect_err(&format!("{what} must be rejected")); + assert_eq!(err.kind(), expected, "{what}: expected {expected:?}, got {:?}: {err}", err.kind()); +} + +#[test] +fn sort_options_value_parsers_are_still_enforced_on_the_prefixed_side() { + // `temp_compression`'s ranged value_parser and `max_memory`'s custom parser + // both arrive as `#[arg(...)]` metas the macro copies verbatim. The failure + // must come from the value parser, not from clap failing to know the flag. + assert_parse_error_kind( + PrefixedSort::try_parse_from(["fgumi", "--sort::temp-compression", "10"]), + clap::error::ErrorKind::ValueValidation, + "the ranged value_parser must reject 10", + ); + assert_parse_error_kind( + PrefixedSort::try_parse_from(["fgumi", "--sort::max-memory", "not-a-size"]), + clap::error::ErrorKind::ValueValidation, + "the custom value_parser must reject a malformed size", + ); +} + +#[test] +fn sort_options_hidden_flag_stays_hidden_when_re_exposed() { + // The contract is the `hide` setting on the registered argument, not the + // absence of a substring from rendered text. + let command = PrefixedSort::command(); + let arg = command + .get_arguments() + .find(|arg| arg.get_long() == Some("sort::file-granularity")) + .expect("the prefixed flag should still be registered"); + assert!(arg.is_hide_set(), "hide = true must be preserved onto the prefixed flag"); +} + +#[test] +fn sort_options_short_flags_are_not_propagated() { + // `-m` and `-T` belong to the standalone command; on runall they would + // collide with every other stage's short flags. + assert!(StandaloneSort::try_parse_from(["fgumi", "-m", "1GiB"]).is_ok()); + assert_parse_error_kind( + PrefixedSort::try_parse_from(["fgumi", "-m", "1GiB"]), + clap::error::ErrorKind::UnknownArgument, + "-m", + ); + assert_parse_error_kind( + PrefixedSort::try_parse_from(["fgumi", "-T", "/scratch"]), + clap::error::ErrorKind::UnknownArgument, + "-T", + ); +} + +#[test] +fn every_standalone_long_flag_has_a_prefixed_counterpart() { + let standalone_flags: Vec = StandaloneSort::command() + .get_arguments() + .filter_map(|arg| arg.get_long().map(ToString::to_string)) + .filter(|long| long != "help") + .collect(); + assert!(!standalone_flags.is_empty(), "fixture should declare long flags"); + + let prefixed_flags: Vec = PrefixedSort::command() + .get_arguments() + .filter_map(|arg| arg.get_long().map(ToString::to_string)) + .collect(); + + // Compared as sets, not one-directionally: a containment check passes even + // when the prefixed command exposes an extra flag the standalone one never + // declared. + let mut expected: Vec = + standalone_flags.iter().map(|long| format!("sort::{long}")).collect(); + let mut actual: Vec = + prefixed_flags.iter().filter(|long| *long != "help").cloned().collect(); + expected.sort(); + actual.sort(); + + assert_eq!( + actual, expected, + "the prefixed command must expose exactly the prefixed counterparts, no more and no less" + ); +} + +#[test] +fn skip_slot_is_not_exposed_but_survives_the_round_trip() { + // The contract is that no `--sort::order` argument is registered — not that + // the word "order" is absent from the help text, which any future doc + // comment could break while the contract still holds. + assert!( + PrefixedSort::command().get_arguments().all(|arg| arg.get_long() != Some("sort::order")), + "the skip slot must not become a flag" + ); + + let original = SortOptions { order: SortOrderArg, ..SortOptions::default() }; + let multi: MultiSortOptions = original.clone().into(); + assert_eq!(multi.validate().expect("validate"), original); +} + +// ───────────────────────────────────────────────────────────────────────────── +// `GroupOptions` — a required `value_enum`, several skip slots +// ───────────────────────────────────────────────────────────────────────────── + +#[multi_options("group", "Group Options")] +#[derive(Args, Debug, Clone, PartialEq)] +pub struct GroupOptions { + /// Minimum mapping quality. + #[arg(short = 'm', long = "min-map-q", default_value = "1")] + pub min_map_q: u8, + + /// Include non-PF reads. + #[arg(short = 'n', long = "include-non-pf-reads", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] + pub include_non_pf_reads: bool, + + /// UMI assignment strategy. Required on the standalone command. + #[arg(short = 's', long = "strategy", value_enum)] + pub strategy: Strategy, + + /// Minimum UMI length. + #[arg(short = 'l', long = "min-umi-length")] + pub min_umi_length: Option, + + /// Strategy actually used, resolved by the command. + #[arg(skip = Strategy::Identity)] + pub effective_strategy: Strategy, + + /// Edits actually used, resolved by the command. + #[arg(skip)] + pub effective_edits: u32, +} + +impl Default for GroupOptions { + fn default() -> Self { + Self { + min_map_q: 1, + include_non_pf_reads: false, + strategy: Strategy::Identity, + min_umi_length: None, + effective_strategy: Strategy::Identity, + effective_edits: 0, + } + } +} + +#[derive(Parser, Debug)] +struct StandaloneGroup { + #[command(flatten)] + opts: GroupOptions, +} + +#[derive(Parser, Debug)] +struct PrefixedGroup { + #[command(flatten)] + opts: MultiGroupOptions, +} + +#[test] +fn group_options_match_when_the_required_strategy_is_supplied() { + let standalone = StandaloneGroup::try_parse_from(["fgumi", "--strategy", "adjacency"]) + .expect("standalone parse"); + let prefixed = PrefixedGroup::try_parse_from(["fgumi", "--group::strategy", "adjacency"]) + .expect("prefixed parse"); + + assert_eq!(prefixed.opts.validate().expect("validate"), standalone.opts); +} + +#[test] +fn group_required_field_is_staged_not_enforced_by_clap() { + // The standalone command refuses to parse without --strategy. On the runall + // side clap must accept the parse so `validate()` can name the stage and the + // flag that is missing. + let err = StandaloneGroup::try_parse_from(["fgumi"]) + .map(|_| ()) + .expect_err("the standalone command requires --strategy"); + assert_eq!( + err.kind(), + clap::error::ErrorKind::MissingRequiredArgument, + "expected clap itself to demand --strategy, got {:?}: {err}", + err.kind() + ); + + let prefixed = PrefixedGroup::try_parse_from(["fgumi"]).expect("parse must be staged"); + let err = prefixed.opts.validate().expect_err("validate must reject the missing strategy"); + let msg = format!("{err:#}"); + assert!(msg.contains("--group::strategy"), "error should name the prefixed flag: {msg}"); + assert!(msg.contains("required when group is selected"), "error should name the stage: {msg}"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Two stages flattened side by side — the shape `runall` actually builds +// ───────────────────────────────────────────────────────────────────────────── + +/// `runall` flattens every stage into one command. This is where an un-prefixed +/// flag, a propagated short, or a leaked `next_help_heading` would surface as a +/// clap panic or a mis-filed argument. +#[derive(Parser, Debug)] +struct RunAllLike { + #[command(flatten)] + sort: MultiSortOptions, + + #[command(flatten)] + group: MultiGroupOptions, + + /// A flag belonging to runall itself, declared after both stages. + #[arg(long)] + threads: Option, +} + +#[test] +fn two_stages_and_a_parent_flag_coexist_in_one_command() { + let parsed = RunAllLike::try_parse_from([ + "fgumi", + "--sort::max-memory", + "2GiB", + "--group::strategy", + "paired", + "--threads", + "4", + ]) + .expect("a two-stage command must build and parse"); + + assert_eq!( + parsed.sort.validate().expect("sort").max_memory, + MemoryLimit(2 * 1024 * 1024 * 1024) + ); + assert_eq!(parsed.group.validate().expect("group").strategy, Strategy::Paired); + assert_eq!(parsed.threads, Some(4)); +} + +#[test] +fn each_stage_gets_its_own_help_heading_and_the_parent_keeps_its_own() { + // Asserted on each argument's own `help_heading` rather than on the byte + // offsets of headings within `render_long_help()`: rendered help is clap's + // layout concern — it depends on argument ordering and, with the + // `wrap_help` feature active anywhere in the workspace feature graph, on + // terminal width — so offset comparisons can hold while the headings are + // wrong, and break while they are right. + let command = RunAllLike::command(); + let heading_of = |long: &str| { + command + .get_arguments() + .find(|arg| arg.get_long() == Some(long)) + .unwrap_or_else(|| panic!("--{long} should be registered")) + .get_help_heading() + .map(ToString::to_string) + }; + + assert_eq!( + heading_of("sort::max-memory"), + Some("Sort Options".to_string()), + "a sort field must be filed under the sort stage's heading" + ); + assert_eq!( + heading_of("group::strategy"), + Some("Group Options".to_string()), + "a group field must be filed under the group stage's heading" + ); + assert_eq!( + heading_of("threads"), + None, + "the parent's own flag must not inherit a stage heading" + ); +} + +#[test] +fn stages_sharing_a_flag_name_do_not_collide() { + // Both stages declare a `-m` short and a min/max flag on the standalone side. + // Prefixing is what keeps them apart; a leak would make clap panic while + // building the command above, so reaching this assertion is the test. + let flags: Vec = RunAllLike::command() + .get_arguments() + .filter_map(|arg| arg.get_long().map(ToString::to_string)) + .collect(); + let mut sorted = flags.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(sorted.len(), flags.len(), "no flag name may be declared twice: {flags:?}"); +} diff --git a/crates/fgumi-cli-macros/tests/smoke.rs b/crates/fgumi-cli-macros/tests/smoke.rs new file mode 100644 index 000000000..075316df8 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/smoke.rs @@ -0,0 +1,412 @@ +//! Smoke test for the `multi_options` attribute macro. +//! +//! Exercises the field-kind branches the macro generates code for: +//! skipped (`#[arg(skip)]`), absent-able or defaulted (`Option`, +//! `Vec`, bare `bool`, any `default_value*`), and required +//! (everything else). Verifies that: +//! +//! * The original struct compiles unchanged with its bare flags. +//! * The generated `Multi` struct exposes the same fields +//! under `--::` names. +//! * `Multi::validate()` succeeds when required fields are +//! supplied and fails (with a helpful error) when they are not. +//! * The round-trip `From<>` impl preserves field values. + +use clap::{Args, Parser}; +use fgumi_cli_macros::multi_options; + +/// Standalone options struct annotated with `multi_options`. The +/// generated `MultiFooOptions` is what the smoke test pokes at. +/// +/// `Default` is hand-rolled here only because `bare_skipped`-style +/// fields need it; the CLI defaults come from the `#[arg(...)]` +/// attributes, which the macro copies verbatim. See +/// `behavior.rs::declared_clap_defaults_win_over_a_disagreeing_default_impl` +/// for the test that pins the two apart. +#[multi_options("foo", "Foo Options")] +#[derive(Args, Debug, Clone, PartialEq)] +pub struct FooOptions { + /// Optional knob (None when not passed). + #[arg(long)] + pub optional_knob: Option, + + /// Defaulted knob — keeps its `default_value_t` on both sides. + #[arg(long, default_value_t = 7)] + pub defaulted_knob: u32, + + /// Required knob — missing on the prefixed side fails `validate()`. + #[arg(long)] + pub required_knob: u32, +} + +impl Default for FooOptions { + fn default() -> Self { + Self { optional_knob: None, defaulted_knob: 7, required_knob: 0 } + } +} + +#[test] +fn multi_struct_validate_round_trips_when_all_fields_supplied() { + // Build a MultiFooOptions by hand (avoiding clap parsing so the + // test doesn't depend on a full clap Command harness). + let multi = MultiFooOptions { + foo_optional_knob: Some(42), + foo_defaulted_knob: 3, + foo_required_knob: Some(100), + }; + + let opts = multi.validate().expect("validate should succeed with all fields set"); + assert_eq!(opts.optional_knob, Some(42)); + assert_eq!(opts.defaulted_knob, 3); + assert_eq!(opts.required_knob, 100); +} + +#[test] +fn multi_struct_validate_fails_when_required_missing() { + let multi = + MultiFooOptions { foo_optional_knob: None, foo_defaulted_knob: 7, foo_required_knob: None }; + + let err = multi.validate().expect_err("validate should fail when required field is None"); + let msg = format!("{err:#}"); + assert!( + msg.contains("--foo::required-knob"), + "expected '--foo::required-knob' in error, got: {msg}" + ); + assert!(msg.contains("required when foo is selected"), "got: {msg}"); +} + +#[test] +fn from_original_round_trips_field_values() { + let original = FooOptions { optional_knob: Some(11), defaulted_knob: 22, required_knob: 33 }; + let multi: MultiFooOptions = original.clone().into(); + assert_eq!(multi.foo_optional_knob, Some(11)); + assert_eq!(multi.foo_defaulted_knob, 22); + assert_eq!(multi.foo_required_knob, Some(33)); + + let back = multi.validate().expect("round-trip validate should succeed"); + assert_eq!(back, original); +} + +#[test] +fn multi_struct_parses_prefixed_flags_via_clap() { + // Use a wrapper struct with `#[command(flatten)]` because + // `Args`-derived structs can only be parsed via a parent + // `Parser`-derived command. + #[derive(Parser, Debug)] + struct Wrapper { + #[command(flatten)] + foo_opts: MultiFooOptions, + } + + let wrapper = Wrapper::try_parse_from([ + "test-prog", + "--foo::required-knob", + "5", + "--foo::optional-knob", + "9", + ]) + .expect("parse should succeed"); + let opts = wrapper.foo_opts.validate().expect("validate after parse"); + assert_eq!(opts.required_knob, 5); + assert_eq!(opts.optional_knob, Some(9)); + assert_eq!(opts.defaulted_knob, 7); // unchanged +} + +/// Coverage for the `default_value` (string-form) and `Vec` +/// (pass-through) handling extensions. Mirrors the structure of +/// `FooOptions` but exercises the two paths separately: +/// +/// * `string_defaulted` uses `default_value = "..."` (the string +/// form parsed via `value_parser`); the macro must treat this as +/// "defaulted" not "required". +/// * `repeated_paths` is a `Vec` — the macro must treat Vec +/// like Option: pass it through with its type intact, since +/// clap collects an omitted repeatable flag as an empty Vec. +#[multi_options("bar", "Bar Options")] +#[derive(Args, Debug, Clone, PartialEq)] +pub struct BarOptions { + /// Defaulted via the string form of `default_value`. + #[arg(long, default_value = "42")] + pub string_defaulted: u32, + + /// Repeatable Vec field. + #[arg(long, action = clap::ArgAction::Append)] + pub repeated_paths: Vec, +} + +impl Default for BarOptions { + fn default() -> Self { + Self { string_defaulted: 42, repeated_paths: Vec::new() } + } +} + +#[test] +fn macro_treats_default_value_string_form_as_defaulted_not_required() { + // No string_defaulted on the cli → uses the Default. + #[derive(Parser, Debug)] + struct Wrapper { + #[command(flatten)] + bar_opts: MultiBarOptions, + } + let wrapper = Wrapper::try_parse_from(["test-prog"]).expect("parse with all defaults"); + let opts = wrapper.bar_opts.validate().expect("validate"); + assert_eq!(opts.string_defaulted, 42, "string-form default should apply"); + assert!(opts.repeated_paths.is_empty(), "Vec default is empty"); +} + +#[test] +fn macro_treats_vec_t_as_repeating_pass_through() { + #[derive(Parser, Debug)] + struct Wrapper { + #[command(flatten)] + bar_opts: MultiBarOptions, + } + let wrapper = Wrapper::try_parse_from([ + "test-prog", + "--bar::repeated-paths", + "1", + "--bar::repeated-paths", + "2", + "--bar::repeated-paths", + "3", + ]) + .expect("parse with three --repeated-paths"); + let opts = wrapper.bar_opts.validate().expect("validate"); + assert_eq!(opts.repeated_paths, vec![1, 2, 3]); +} + +/// Coverage for four attribute-handling fixes: +/// +/// * `skip_with_expr` uses `#[arg(skip = expr)]`. The macro must use +/// the provided expression verbatim in `validate()` rather than +/// hard-wiring `BazOptions::default().skip_with_expr`. +/// * `bare_skipped` uses a bare `#[arg(skip)]` (no `= expr`). The +/// macro must fall back to `BazOptions::default().bare_skipped` +/// (the struct default), NOT `u32::default()`. +/// * `conditionally_required` carries `#[arg(required = true)]`. The +/// macro must strip the `required = ...` name-value form so the +/// generated Multi field (an `Option`) is not forced by clap +/// during parsing — required-ness is handled by `validate()`. +/// * `tmp_dirs` carries `#[arg(long = "tmp-dir")]`, a `long` override +/// that differs from the kebab of the field name. The macro must +/// honor the override so the Multi flag is `--baz::tmp-dir`, NOT +/// `--baz::tmp-dirs`. +#[multi_options("baz", "Baz Options")] +#[derive(Args, Debug, Clone, PartialEq)] +pub struct BazOptions { + /// Skipped field with an explicit expression value. Not exposed on + /// the CLI; `validate()` must use this expression, not `Default`. + #[arg(skip = 99u32)] + pub skip_with_expr: u32, + + /// Bare-skipped field. Not exposed on the CLI; `validate()` must + /// pull its value from `BazOptions::default()`, not `u32::default()`. + #[arg(skip)] + pub bare_skipped: u32, + + /// Required (non-default) field that also carries `required = true`. + /// The Multi side must wrap this as `Option` and not propagate + /// `required = true` to clap. + #[arg(long, required = true)] + pub conditionally_required: u32, + + /// Repeatable field with a `long` override (`--tmp-dir`, not + /// `--tmp-dirs`). The Multi side must derive `--baz::tmp-dir`. + #[arg(long = "tmp-dir", action = clap::ArgAction::Append)] + pub tmp_dirs: Vec, +} + +impl Default for BazOptions { + fn default() -> Self { + // `skip_with_expr`'s Default is deliberately non-zero (and different + // from the `skip = 99u32` expression) so the test proves the macro + // emits the skip expression, not this Default value. `bare_skipped`'s + // Default is deliberately non-zero (and different from `u32::default()` + // == 0) so the bare-skip test proves the macro pulls from this struct + // Default rather than the field type's Default. + Self { + skip_with_expr: 1, + bare_skipped: 77, + conditionally_required: 0, + tmp_dirs: Vec::new(), + } + } +} + +/// A parent command for the Baz fixture; skip fields are carried on the Multi +/// struct but never exposed as flags, so they take their declared value at parse +/// time. +#[derive(Parser, Debug)] +struct BazWrapper { + #[command(flatten)] + baz_opts: MultiBazOptions, +} + +#[test] +fn skip_with_expr_uses_provided_expression_not_default() { + // The skip expression is `99`, while `BazOptions::default().skip_with_expr` + // is `1`. Parsing must yield `99`, proving the expression is honored. + let parsed = BazWrapper::try_parse_from(["test-prog", "--baz::conditionally-required", "5"]) + .expect("parse should succeed"); + let opts = parsed.baz_opts.validate().expect("validate should succeed"); + assert_eq!(opts.skip_with_expr, 99, "skip = 99 expression must be used verbatim"); + assert_eq!(opts.conditionally_required, 5); +} + +#[test] +fn bare_skip_falls_back_to_struct_default_not_type_default() { + // `bare_skipped` is `#[arg(skip)]` (no `= expr`), so the macro must populate + // it from `BazOptions::default().bare_skipped` (== 77), NOT `u32::default()` + // (== 0). + let parsed = BazWrapper::try_parse_from(["test-prog", "--baz::conditionally-required", "1"]) + .expect("parse should succeed"); + let opts = parsed.baz_opts.validate().expect("validate should succeed"); + assert_eq!( + opts.bare_skipped, 77, + "bare #[arg(skip)] must use the struct Default (77), not u32::default() (0)" + ); +} + +#[test] +fn skip_fields_are_carried_on_the_multi_struct_for_a_lossless_round_trip() { + // Values that match neither the skip expression nor the struct Default, so a + // re-derived value is distinguishable from a preserved one. + let original = BazOptions { + skip_with_expr: 1000, + bare_skipped: 2000, + conditionally_required: 3, + tmp_dirs: vec![1], + }; + let multi: MultiBazOptions = original.clone().into(); + assert_eq!(multi.validate().expect("validate"), original); +} + +#[test] +fn long_override_renames_multi_flag() { + // `tmp_dirs` carries `#[arg(long = "tmp-dir")]`, so the Multi flag must be + // `--baz::tmp-dir` (honoring the override), and `--baz::tmp-dirs` (the + // kebab of the field name) must be rejected. + #[derive(Parser, Debug)] + struct Wrapper { + #[command(flatten)] + baz_opts: MultiBazOptions, + } + + // The overridden flag name parses. + let wrapper = Wrapper::try_parse_from([ + "test-prog", + "--baz::conditionally-required", + "1", + "--baz::tmp-dir", + "3", + "--baz::tmp-dir", + "4", + ]) + .expect("--baz::tmp-dir should parse"); + let opts = wrapper.baz_opts.validate().expect("validate after parse"); + assert_eq!(opts.tmp_dirs, vec![3, 4]); + + // The field-name kebab (`--baz::tmp-dirs`) must NOT be a valid flag, proving + // the macro honored the `long = "tmp-dir"` override. Assert clap rejects it + // as an *unknown* argument specifically — a bare `is_err()` would also be + // satisfied by an unrelated parse failure. + let err = Wrapper::try_parse_from([ + "test-prog", + "--baz::conditionally-required", + "1", + "--baz::tmp-dirs", + "3", + ]) + .expect_err("--baz::tmp-dirs must be rejected (override renamed it to --baz::tmp-dir)"); + assert_eq!( + err.kind(), + clap::error::ErrorKind::UnknownArgument, + "expected an unknown-argument rejection, got {:?}: {err}", + err.kind() + ); +} + +#[test] +fn required_name_value_does_not_force_clap_requirement() { + // `required = true` on the original field must be stripped: parsing + // the Multi side WITHOUT the prefixed flag must succeed (clap must not + // enforce it), and the conditional requirement is surfaced by validate(). + #[derive(Parser, Debug)] + struct Wrapper { + #[command(flatten)] + baz_opts: MultiBazOptions, + } + + // No --baz::conditionally-required supplied: parse must succeed because + // the Multi field is Option and `required = true` was stripped. + let wrapper = + Wrapper::try_parse_from(["test-prog"]).expect("parse should succeed without required flag"); + let err = wrapper + .baz_opts + .validate() + .expect_err("validate should fail when conditionally-required is missing"); + let msg = format!("{err:#}"); + assert!( + msg.contains("--baz::conditionally-required"), + "expected '--baz::conditionally-required' in error, got: {msg}" + ); + + // Supplying it parses and validates. + let wrapper = Wrapper::try_parse_from(["test-prog", "--baz::conditionally-required", "7"]) + .expect("parse should succeed when flag supplied"); + let opts = wrapper.baz_opts.validate().expect("validate after parse"); + assert_eq!(opts.conditionally_required, 7); + assert_eq!(opts.skip_with_expr, 99); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Call-form (`Meta::List`) passthrough +// ───────────────────────────────────────────────────────────────────────────── + +/// `CALL_FORM_SENSITIVE_ARG_KEYS` rejects the `key(value)` call form only for the keys the +/// macro classifies (`long`, `short`, `required`, `skip`, the alias keys and every +/// `default_value*`). Every other call-form key is preserved verbatim onto the Multi struct — +/// this pins that it actually reaches clap and takes effect, rather than being silently dropped. +#[multi_options("qux", "Qux Options")] +#[derive(Args, Debug, Clone, PartialEq)] +pub struct QuxOptions { + /// Range-validated via the call form `value_parser(...)`, which arrives as a + /// `Meta::List` and is passed through untouched. + #[arg(long, default_value_t = 3, value_parser(clap::value_parser!(u32).range(0..=9)))] + pub bounded: u32, +} + +impl Default for QuxOptions { + fn default() -> Self { + Self { bounded: 3 } + } +} + +#[test] +fn call_form_value_parser_is_preserved_and_enforced_on_the_multi_struct() { + #[derive(Parser, Debug)] + struct Wrapper { + #[command(flatten)] + qux_opts: MultiQuxOptions, + } + + // In range: parses and round-trips. + let wrapper = Wrapper::try_parse_from(["test-prog", "--qux::bounded", "9"]) + .expect("in-range value should parse"); + assert_eq!(wrapper.qux_opts.validate().expect("validate").bounded, 9); + + // Out of range: the passed-through `value_parser` must still reject it. If the + // `Meta::List` were dropped during re-exposure, clap would happily accept 10. + let err = Wrapper::try_parse_from(["test-prog", "--qux::bounded", "10"]) + .expect_err("out-of-range value must be rejected by the preserved value_parser"); + assert_eq!( + err.kind(), + clap::error::ErrorKind::ValueValidation, + "10 must be rejected by the preserved value_parser, not merely mentioned in some other \ + error: {err}" + ); + + // The default survives too. + let wrapper = Wrapper::try_parse_from(["test-prog"]).expect("defaults should parse"); + assert_eq!(wrapper.qux_opts.validate().expect("validate").bounded, 3); +} diff --git a/crates/fgumi-cli-macros/tests/ui/bad_prefix.rs b/crates/fgumi-cli-macros/tests/ui/bad_prefix.rs new file mode 100644 index 000000000..194366db1 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/bad_prefix.rs @@ -0,0 +1,20 @@ +//! The prefix is spliced into both the flag name (`--::`) and the +//! generated field identifier (`_`), so a non-identifier prefix +//! must be rejected at the literal rather than panicking inside `format_ident!`. +use fgumi_cli_macros::multi_options; + +#[multi_options("", "Probe Options")] +#[derive(clap::Args, Debug, Clone)] +pub struct EmptyPrefixOpts { + #[arg(long)] + pub value: u32, +} + +#[multi_options("2fast", "Probe Options")] +#[derive(clap::Args, Debug, Clone)] +pub struct LeadingDigitOpts { + #[arg(long)] + pub value: u32, +} + +fn main() {} diff --git a/crates/fgumi-cli-macros/tests/ui/bad_prefix.stderr b/crates/fgumi-cli-macros/tests/ui/bad_prefix.stderr new file mode 100644 index 000000000..13f8bd27b --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/bad_prefix.stderr @@ -0,0 +1,11 @@ +error: multi_options: the prefix must not be empty (it would generate flags named `--::`) + --> tests/ui/bad_prefix.rs:6:17 + | +6 | #[multi_options("", "Probe Options")] + | ^^ + +error: multi_options: the prefix must start with an ASCII letter, got `2fast` — it also becomes the leading segment of the generated field identifier `_` + --> tests/ui/bad_prefix.rs:13:17 + | +13 | #[multi_options("2fast", "Probe Options")] + | ^^^^^^^ diff --git a/crates/fgumi-cli-macros/tests/ui/call_form_long.rs b/crates/fgumi-cli-macros/tests/ui/call_form_long.rs new file mode 100644 index 000000000..ade9030b4 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/call_form_long.rs @@ -0,0 +1,12 @@ +//! clap's `key(value)` call form arrives as `Meta::List` and slips past every classifier, +//! so it must be rejected for the keys the macro rewrites. +use fgumi_cli_macros::multi_options; + +#[multi_options("probe", "Probe Options")] +#[derive(clap::Args, Debug, Clone)] +pub struct CallFormOpts { + #[arg(long("renamed"))] + pub value: u32, +} + +fn main() {} diff --git a/crates/fgumi-cli-macros/tests/ui/call_form_long.stderr b/crates/fgumi-cli-macros/tests/ui/call_form_long.stderr new file mode 100644 index 000000000..91cb0e509 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/call_form_long.stderr @@ -0,0 +1,6 @@ +error: multi_options: field `value` uses the call form #[arg(long(…))]. Use the `long = …` name-value form (or bare `long`) — the macro only classifies those spellings and would mishandle the call form. + --> tests/ui/call_form_long.rs:8:5 + | +8 | / #[arg(long("renamed"))] +9 | | pub value: u32, + | |__________________^ diff --git a/crates/fgumi-cli-macros/tests/ui/command_flatten.rs b/crates/fgumi-cli-macros/tests/ui/command_flatten.rs new file mode 100644 index 000000000..21541a8fd --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/command_flatten.rs @@ -0,0 +1,18 @@ +//! Re-prefixing a clap-flattened struct's fields is out of scope, so +//! `#[command(flatten)]` must fail the build rather than silently drop the nesting. +use fgumi_cli_macros::multi_options; + +#[derive(clap::Args, Debug, Clone)] +pub struct Inner { + #[arg(long)] + pub inner_value: u32, +} + +#[multi_options("probe", "Probe Options")] +#[derive(clap::Args, Debug, Clone)] +pub struct FlattenOpts { + #[command(flatten)] + pub inner: Inner, +} + +fn main() {} diff --git a/crates/fgumi-cli-macros/tests/ui/command_flatten.stderr b/crates/fgumi-cli-macros/tests/ui/command_flatten.stderr new file mode 100644 index 000000000..09a5694b2 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/command_flatten.stderr @@ -0,0 +1,5 @@ +error: multi_options does not support #[command(flatten)] / #[command(subcommand)] on field `inner`. The nested struct's fields cannot be reached to prefix them; inline them directly. + --> tests/ui/command_flatten.rs:14:5 + | +14 | #[command(flatten)] + | ^^^^^^^^^^^^^^^^^^^ diff --git a/crates/fgumi-cli-macros/tests/ui/conditional_clap_attr.rs b/crates/fgumi-cli-macros/tests/ui/conditional_clap_attr.rs new file mode 100644 index 000000000..c7dc1a676 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/conditional_clap_attr.rs @@ -0,0 +1,13 @@ +//! The macro classifies fields from their literal `#[arg(...)]` attributes and +//! cannot evaluate a cfg predicate, so a clap attribute hidden behind +//! `#[cfg_attr]` would be ignored and the field silently misclassified. +use fgumi_cli_macros::multi_options; + +#[multi_options("probe", "Probe Options")] +#[derive(clap::Args, Debug, Clone)] +pub struct ConditionalOpts { + #[cfg_attr(unix, arg(long, default_value_t = 3))] + pub knob: u32, +} + +fn main() {} diff --git a/crates/fgumi-cli-macros/tests/ui/conditional_clap_attr.stderr b/crates/fgumi-cli-macros/tests/ui/conditional_clap_attr.stderr new file mode 100644 index 000000000..28ac1c420 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/conditional_clap_attr.stderr @@ -0,0 +1,5 @@ +error: multi_options: field `knob` hides a clap attribute behind #[cfg_attr(…, arg(…))]. The macro classifies fields from their literal #[arg(...)] attributes and cannot evaluate a cfg predicate, so this one would be ignored and the field misclassified. Apply #[cfg] to the field and write the #[arg(...)] unconditionally. + --> tests/ui/conditional_clap_attr.rs:9:5 + | +9 | #[cfg_attr(unix, arg(long, default_value_t = 3))] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/fgumi-cli-macros/tests/ui/cross_reference_arg.rs b/crates/fgumi-cli-macros/tests/ui/cross_reference_arg.rs new file mode 100644 index 000000000..b04645297 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/cross_reference_arg.rs @@ -0,0 +1,14 @@ +//! `requires` names another argument by its unprefixed id, which dangles once the Multi +//! struct renames fields, so it must fail the build. +use fgumi_cli_macros::multi_options; + +#[multi_options("probe", "Probe Options")] +#[derive(clap::Args, Debug, Clone)] +pub struct CrossRefOpts { + #[arg(long)] + pub first: u32, + #[arg(long, requires = "first")] + pub second: u32, +} + +fn main() {} diff --git a/crates/fgumi-cli-macros/tests/ui/cross_reference_arg.stderr b/crates/fgumi-cli-macros/tests/ui/cross_reference_arg.stderr new file mode 100644 index 000000000..79dbda5db --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/cross_reference_arg.stderr @@ -0,0 +1,6 @@ +error: multi_options: field `second` uses #[arg(requires …)], which references another argument by its unprefixed id. The Multi struct renames fields to `_`, so that id would dangle and clap would panic when it builds the runall command. Enforce this coupling in the command's validate()/resolve() instead (see AlignerOptions::resolve). + --> tests/ui/cross_reference_arg.rs:10:5 + | +10 | / #[arg(long, requires = "first")] +11 | | pub second: u32, + | |___________________^ diff --git a/crates/fgumi-cli-macros/tests/ui/generic_struct.rs b/crates/fgumi-cli-macros/tests/ui/generic_struct.rs new file mode 100644 index 000000000..f8d3fcdb4 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/generic_struct.rs @@ -0,0 +1,20 @@ +//! The companion struct and both conversion impls are emitted without generic +//! parameters, so a generic options struct would expand into code that cannot +//! compile — with every error naming the type parameter rather than the macro. +//! +//! The bounds are chosen so clap's own derive is satisfied; the only diagnostic +//! left is the macro's. +use fgumi_cli_macros::multi_options; + +#[multi_options("probe", "Probe Options")] +#[derive(clap::Args, Debug, Clone)] +pub struct GenericOpts +where + T: Clone + Send + Sync + 'static + std::str::FromStr, + ::Err: std::error::Error + Send + Sync + 'static, +{ + #[arg(long)] + pub value: T, +} + +fn main() {} diff --git a/crates/fgumi-cli-macros/tests/ui/generic_struct.stderr b/crates/fgumi-cli-macros/tests/ui/generic_struct.stderr new file mode 100644 index 000000000..8557edc7a --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/generic_struct.stderr @@ -0,0 +1,5 @@ +error: multi_options does not support generic structs: the generated Multi struct and its conversions are emitted without generic parameters, so the expansion would not compile. Use a concrete options struct. + --> tests/ui/generic_struct.rs:11:23 + | +11 | pub struct GenericOpts + | ^^^ diff --git a/crates/fgumi-cli-macros/tests/ui/legacy_clap_attr.rs b/crates/fgumi-cli-macros/tests/ui/legacy_clap_attr.rs new file mode 100644 index 000000000..53217b854 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/legacy_clap_attr.rs @@ -0,0 +1,15 @@ +//! Every classifier keys on `#[arg(...)]`, so clap's legacy `#[clap(...)]` +//! spelling would be silently ignored — a `#[clap(skip)]` field would be exposed +//! as a required CLI flag. Reject it instead. +use fgumi_cli_macros::multi_options; + +#[multi_options("probe", "Probe Options")] +#[derive(clap::Args, Debug, Clone)] +pub struct LegacyOpts { + #[clap(skip)] + pub hidden: u32, + #[arg(long)] + pub value: u32, +} + +fn main() {} diff --git a/crates/fgumi-cli-macros/tests/ui/legacy_clap_attr.stderr b/crates/fgumi-cli-macros/tests/ui/legacy_clap_attr.stderr new file mode 100644 index 000000000..0b203837c --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/legacy_clap_attr.stderr @@ -0,0 +1,5 @@ +error: multi_options does not support the legacy #[clap(...)] / #[structopt(...)] spelling: every classifier keys on #[arg(...)] and #[command(...)], so this attribute would be silently ignored and the field misclassified. Use the #[arg(...)] / #[command(...)] spelling. + --> tests/ui/legacy_clap_attr.rs:9:5 + | +9 | #[clap(skip)] + | ^^^^^^^^^^^^^ diff --git a/crates/fgumi-cli-macros/tests/ui/non_struct.rs b/crates/fgumi-cli-macros/tests/ui/non_struct.rs new file mode 100644 index 000000000..996ef9254 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/non_struct.rs @@ -0,0 +1,11 @@ +//! `multi_options` generates a companion struct from named fields, so applying it +//! to any other item — an enum here — must fail the build. +use fgumi_cli_macros::multi_options; + +#[multi_options("probe", "Probe Options")] +pub enum NotAStruct { + First, + Second, +} + +fn main() {} diff --git a/crates/fgumi-cli-macros/tests/ui/non_struct.stderr b/crates/fgumi-cli-macros/tests/ui/non_struct.stderr new file mode 100644 index 000000000..a1e58a966 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/non_struct.stderr @@ -0,0 +1,8 @@ +error: multi_options only supports structs with named fields + --> tests/ui/non_struct.rs:6:1 + | +6 | / pub enum NotAStruct { +7 | | First, +8 | | Second, +9 | | } + | |_^ diff --git a/crates/fgumi-cli-macros/tests/ui/positional_field.rs b/crates/fgumi-cli-macros/tests/ui/positional_field.rs new file mode 100644 index 000000000..5639f8caa --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/positional_field.rs @@ -0,0 +1,15 @@ +//! A positional argument has no flag name to prefix, and clap panics outright when +//! one carries a `long` — which the generated companion always emits. Several +//! stages flattened into one runall command would have ambiguous positionals +//! anyway, so both spellings must fail the build. +use fgumi_cli_macros::multi_options; + +#[multi_options("probe", "Probe Options")] +#[derive(clap::Args, Debug, Clone)] +pub struct PositionalOpts { + pub implicit_positional: u32, + #[arg(index = 2)] + pub explicit_positional: u32, +} + +fn main() {} diff --git a/crates/fgumi-cli-macros/tests/ui/positional_field.stderr b/crates/fgumi-cli-macros/tests/ui/positional_field.stderr new file mode 100644 index 000000000..b56a28ead --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/positional_field.stderr @@ -0,0 +1,12 @@ +error: multi_options: field `implicit_positional` declares neither `long` nor `short`, so clap treats it as a positional argument. A positional has no flag name to prefix, and several stages flattened into one runall command would have mutually ambiguous positionals. Add an explicit `#[arg(long)]` (or `#[arg(long = "...")]`) to the field. + --> tests/ui/positional_field.rs:10:5 + | +10 | pub implicit_positional: u32, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: multi_options: field `explicit_positional` uses #[arg(index …)], which makes it a positional argument. The Multi struct gives every field a prefixed `long`, and clap panics when a positional has one ("is a positional argument and can't have short or long name versions"). A positional cannot be namespaced per stage — expose it as a flag with `#[arg(long)]` instead. + --> tests/ui/positional_field.rs:11:5 + | +11 | / #[arg(index = 2)] +12 | | pub explicit_positional: u32, + | |________________________________^ diff --git a/crates/fgumi-cli-macros/tests/ui/private_visibility.rs b/crates/fgumi-cli-macros/tests/ui/private_visibility.rs new file mode 100644 index 000000000..49013fc00 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/private_visibility.rs @@ -0,0 +1,17 @@ +//! The generated companion inherits the annotated struct's visibility, so a +//! private options struct must not produce a `pub` companion that escapes its +//! module. +mod inner { + use fgumi_cli_macros::multi_options; + + #[multi_options("probe", "Probe Options")] + #[derive(clap::Args, Debug, Clone)] + struct PrivateOpts { + #[arg(long, default_value_t = 1)] + value: u32, + } +} + +fn main() { + let _ = inner::MultiPrivateOpts { probe_value: 1 }; +} diff --git a/crates/fgumi-cli-macros/tests/ui/private_visibility.stderr b/crates/fgumi-cli-macros/tests/ui/private_visibility.stderr new file mode 100644 index 000000000..1f1e6e378 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/private_visibility.stderr @@ -0,0 +1,12 @@ +error[E0603]: struct `MultiPrivateOpts` is private + --> tests/ui/private_visibility.rs:16:20 + | +16 | let _ = inner::MultiPrivateOpts { probe_value: 1 }; + | ^^^^^^^^^^^^^^^^ private struct + | +note: the struct `MultiPrivateOpts` is defined here + --> tests/ui/private_visibility.rs:7:5 + | + 7 | #[multi_options("probe", "Probe Options")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = note: this error originates in the attribute macro `multi_options` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/crates/fgumi-cli-macros/tests/ui/struct_level_command.rs b/crates/fgumi-cli-macros/tests/ui/struct_level_command.rs new file mode 100644 index 000000000..9c0af1a02 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/struct_level_command.rs @@ -0,0 +1,14 @@ +//! Struct-level clap configuration is not carried onto the generated companion, +//! so it must fail the build rather than silently apply to only one of the two +//! commands. +use fgumi_cli_macros::multi_options; + +#[multi_options("probe", "Probe Options")] +#[derive(clap::Args, Debug, Clone)] +#[command(next_help_heading = "Somewhere Else")] +pub struct StructLevelOpts { + #[arg(long)] + pub value: u32, +} + +fn main() {} diff --git a/crates/fgumi-cli-macros/tests/ui/struct_level_command.stderr b/crates/fgumi-cli-macros/tests/ui/struct_level_command.stderr new file mode 100644 index 000000000..c159954d2 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/struct_level_command.stderr @@ -0,0 +1,5 @@ +error: multi_options does not carry a struct-level #[command(...)] onto the generated Multi struct, so the standalone and re-exposed commands would silently diverge. Move the setting onto the individual #[arg(...)] attributes, or drop it. + --> tests/ui/struct_level_command.rs:8:1 + | +8 | #[command(next_help_heading = "Somewhere Else")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/fgumi-cli-macros/tests/ui/tuple_struct.rs b/crates/fgumi-cli-macros/tests/ui/tuple_struct.rs new file mode 100644 index 000000000..3c45dc343 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/tuple_struct.rs @@ -0,0 +1,7 @@ +//! `multi_options` requires named fields; a tuple struct must fail the build. +use fgumi_cli_macros::multi_options; + +#[multi_options("probe", "Probe Options")] +pub struct TupleOpts(pub u32); + +fn main() {} diff --git a/crates/fgumi-cli-macros/tests/ui/tuple_struct.stderr b/crates/fgumi-cli-macros/tests/ui/tuple_struct.stderr new file mode 100644 index 000000000..c250af561 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/tuple_struct.stderr @@ -0,0 +1,5 @@ +error: multi_options only supports structs with named fields + --> tests/ui/tuple_struct.rs:5:21 + | +5 | pub struct TupleOpts(pub u32); + | ^^^^^^^^^ diff --git a/crates/fgumi-cli-macros/tests/ui/unenforceable_required.rs b/crates/fgumi-cli-macros/tests/ui/unenforceable_required.rs new file mode 100644 index 000000000..db1074ba8 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/unenforceable_required.rs @@ -0,0 +1,15 @@ +//! `validate()` enforces required-ness by observing absence, which a defaulted +//! field and a bare `bool` never exhibit — so the requirement would be silently +//! unenforceable on the re-exposed flag. +use fgumi_cli_macros::multi_options; + +#[multi_options("probe", "Probe Options")] +#[derive(clap::Args, Debug, Clone)] +pub struct UnenforceableOpts { + #[arg(long, default_value_t = 3, required = true)] + pub defaulted: u32, + #[arg(long, required = true)] + pub toggled: bool, +} + +fn main() {} diff --git a/crates/fgumi-cli-macros/tests/ui/unenforceable_required.stderr b/crates/fgumi-cli-macros/tests/ui/unenforceable_required.stderr new file mode 100644 index 000000000..894e36f72 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/unenforceable_required.stderr @@ -0,0 +1,13 @@ +error: multi_options: field `defaulted` combines #[arg(required …)] with a default value or a bare `bool`. The generated field always holds a value — clap applies a declared default to `Option` and `Vec` as well — so the Multi side cannot distinguish "not supplied" from "supplied the default" and the requirement would be silently unenforceable. Drop `required`, or drop the default. + --> tests/ui/unenforceable_required.rs:9:5 + | + 9 | / #[arg(long, default_value_t = 3, required = true)] +10 | | pub defaulted: u32, + | |______________________^ + +error: multi_options: field `toggled` combines #[arg(required …)] with a default value or a bare `bool`. The generated field always holds a value — clap applies a declared default to `Option` and `Vec` as well — so the Multi side cannot distinguish "not supplied" from "supplied the default" and the requirement would be silently unenforceable. Drop `required`, or drop the default. + --> tests/ui/unenforceable_required.rs:11:5 + | +11 | / #[arg(long, required = true)] +12 | | pub toggled: bool, + | |_____________________^ diff --git a/crates/fgumi-cli-macros/tests/ui/where_only.rs b/crates/fgumi-cli-macros/tests/ui/where_only.rs new file mode 100644 index 000000000..239307fe0 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/where_only.rs @@ -0,0 +1,13 @@ +use fgumi_cli_macros::multi_options; + +#[multi_options("probe", "Probe Options")] +#[derive(clap::Args, Debug, Clone)] +pub struct WhereOnlyOpts +where + u32: Clone, +{ + #[arg(long)] + pub value: u32, +} + +fn main() {} diff --git a/crates/fgumi-cli-macros/tests/ui/where_only.stderr b/crates/fgumi-cli-macros/tests/ui/where_only.stderr new file mode 100644 index 000000000..79c2b94d2 --- /dev/null +++ b/crates/fgumi-cli-macros/tests/ui/where_only.stderr @@ -0,0 +1,6 @@ +error: multi_options does not support generic structs: the generated Multi struct and its conversions are emitted without generic parameters, so the expansion would not compile. Use a concrete options struct. + --> tests/ui/where_only.rs:6:1 + | +6 | / where +7 | | u32: Clone, + | |_______________^