From 87ccfcaa5faa28ca0b6f3cfe4be4e69d98d51c31 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 18:39:55 +0000 Subject: [PATCH 1/5] feat(lib): ship usage-rs as the one-crate rust default Publish the facade in the release path so adopters depend on one package, and enable diagnostics by default so clap-shaped errors work out of the box. Co-authored-by: jdx --- cli/Cargo.toml | 6 +++--- docs/rust/help.md | 7 ++++--- docs/rust/index.md | 24 ++++++++++++------------ tasks/release-plz | 17 +++++++++++------ usage-rs/Cargo.toml | 5 ++++- usage-rs/src/lib.rs | 6 ++++-- usage-rs/tests/facade.rs | 13 +++++++++++++ 7 files changed, 51 insertions(+), 27 deletions(-) diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 2ddc98c3f..77fc39b79 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -42,9 +42,9 @@ tera = "2" thiserror = "2" tokio = { version = "1", features = ["rt", "macros", "io-std"] } # The CLI is the facade's first adopter: `usage` parses its own command line with -# the parser it ships. `diagnostics` includes spec emission for `--usage-spec` and -# the errors a person needs when a command line does not parse. -usage-rs = { workspace = true, features = ["diagnostics"] } +# the parser it ships. Facade defaults already include diagnostics (and therefore +# `spec`) for `--usage-spec` and clap-shaped parse errors. +usage-rs = { workspace = true } usage-lib = { workspace = true, features = ["clap", "docs", "unstable_choices_env"] } xx = "2" diff --git a/docs/rust/help.md b/docs/rust/help.md index 1ccfbe5e1..b3eab7d58 100644 --- a/docs/rust/help.md +++ b/docs/rust/help.md @@ -66,8 +66,8 @@ same collision. `MissingSubcommand`, `ArgRequiresDoubleDash`, and more — plus `Help` and `Version` as described above. -`render_failure(spec, argv, &err)` turns any of them into the message users see. With the -`diagnostics` feature enabled the message is clap-shaped: +`render_failure(spec, argv, &err)` turns any of them into the message users see. Facade defaults +include `diagnostics`, so the message is clap-shaped out of the box: ``` error: unexpected argument '--wat' found @@ -77,6 +77,7 @@ Usage: ex [OPTIONS] For more information, try '--help'. ``` -Without `diagnostics`, it falls back to the `Debug` form of the error — fine for internal tools, +Without `diagnostics` (for example after `default-features = false`, or when depending on +`usage-argv` alone), it falls back to the `Debug` form of the error — fine for internal tools, not what you want to ship. `parse()` prints the rendered failure to **stderr** and exits **2**, clap's status, so scripts that check for it keep working. diff --git a/docs/rust/index.md b/docs/rust/index.md index 636071a61..6f56ffcf9 100644 --- a/docs/rust/index.md +++ b/docs/rust/index.md @@ -45,20 +45,23 @@ whole comment becomes the long help shown by `--help`. ## Installation -Add `usage-rs` to your `Cargo.toml`, aliased to `usage`: +One dependency. Add `usage-rs` to your `Cargo.toml`, aliased to `usage`: ```toml [dependencies] usage = { package = "usage-rs", version = "5" } ``` -The alias is supported directly — the derive resolves its runtime through the package name, so -depending on `usage-rs` under any name works. `usage-rs` is a facade over two crates you can also -use directly: +That is the whole install: derives, the argv runtime, help, and clap-shaped errors come with the +defaults. The alias is supported directly — the derive resolves its runtime through the package +name, so depending on `usage-rs` under any name works. + +`usage-rs` is a facade. Applications should depend on it alone. The split underneath stays +available for low-level adopters that want a thinner surface: | Crate | Role | | -------------- | -------------------------------------------------------------------------- | -| `usage-rs` | The facade an application depends on; re-exports the whole runtime | +| `usage-rs` | The one package an application depends on; re-exports the whole runtime | | `usage-derive` | The derive macros: `Cli`, `Args`, `Subcommands`, `ValueEnum` | | `usage-argv` | The zero-allocation, zero-dependency runtime the derive emits code against | @@ -68,15 +71,12 @@ use directly: | ------------- | :-----: | ------------------------------------------------------------ | | `spec` | ✅ | Spec metadata and `to_kdl()`; gates the derives | | `help` | ✅ | `-h` / `--help` page rendering | +| `diagnostics` | ✅ | clap-shaped error messages from `render_failure` | | `completions` | | Shell completion scripts and the runtime completion protocol | -| `diagnostics` | | clap-shaped error messages from `render_failure` | - -Two footguns worth knowing up front: -- Without `diagnostics`, parse failures print as a `Debug`-formatted error rather than the - friendly clap-shaped message. Enable it for anything user-facing. -- `#[usage(completion)]` without the `completions` feature is a deliberate `compile_error!` that - tells you which feature to add. +`#[usage(completion)]` without the `completions` feature is a deliberate `compile_error!` that +tells you which feature to add. To drop diagnostics (or help) from a binary that does not want +them, turn defaults off and re-enable only what you need — or depend on `usage-argv` directly. ## Parse entry points diff --git a/tasks/release-plz b/tasks/release-plz index 752aec196..27abf4028 100755 --- a/tasks/release-plz +++ b/tasks/release-plz @@ -38,8 +38,10 @@ cur_version="$(cargo pkgid usage-lib | cut -d# -f2 | cut -d@ -f2)" if ! echo "$released_versions" | grep -q "^v$cur_version$"; then echo "Releasing $cur_version" if [ "${usage_dry_run:-}" != 1 ]; then + # Dependency order: argv and derive first, then the facade that re-exports them. publish_crate_if_needed usage-argv publish_crate_if_needed usage-derive + publish_crate_if_needed usage-rs publish_crate_if_needed usage-lib publish_crate_if_needed clap_usage publish_crate_if_needed usage-cli @@ -51,16 +53,19 @@ fi # usage-lib's current version is already released, so clap_usage's dependency # is satisfied; catch it up if it is behind. usage-argv rides the shared version -# and depends on nothing, so the same catch-up applies to it. +# and depends on nothing, so the same catch-up applies to it. usage-rs is the +# one package applications are documented to depend on; without this catch-up it +# sat at the 0.0.0 placeholder while argv/derive moved to 5.x. publish_crate_if_needed usage-argv publish_crate_if_needed usage-derive +publish_crate_if_needed usage-rs publish_crate_if_needed clap_usage # Anchor the range before filtering paths. A release tag may point at a commit that only # changes root release files; letting the path filter hide that tag can make cliff propose # a version lower than the one already published. release_range="v$cur_version..HEAD" -version="$(git cliff "$release_range" --bumped-version --include-path 'lib/**' --include-path 'cli/**' --include-path 'argv/**' --include-path 'derive/**')" +version="$(git cliff "$release_range" --bumped-version --include-path 'lib/**' --include-path 'cli/**' --include-path 'argv/**' --include-path 'derive/**' --include-path 'usage-rs/**')" if [ "v$cur_version" == "$version" ]; then echo "No library changes since $version; nothing to release" @@ -69,17 +74,17 @@ fi if [ "${usage_dry_run:-}" == 1 ]; then echo "version: $version" - changelog="$(git cliff "$release_range" --tag "$version" --strip all --include-path 'lib/**' --include-path 'cli/**' --include-path 'argv/**' --include-path 'derive/**')" + changelog="$(git cliff "$release_range" --tag "$version" --strip all --include-path 'lib/**' --include-path 'cli/**' --include-path 'argv/**' --include-path 'derive/**' --include-path 'usage-rs/**')" echo "changelog: $changelog" exit 0 fi # Generate changelog using git-cliff (LLM editorialization happens in release.yml after merge) -git cliff "$release_range" --tag "$version" --prepend CHANGELOG.md --include-path 'lib/**' --include-path 'cli/**' --include-path 'argv/**' --include-path 'derive/**' +git cliff "$release_range" --tag "$version" --prepend CHANGELOG.md --include-path 'lib/**' --include-path 'cli/**' --include-path 'argv/**' --include-path 'derive/**' --include-path 'usage-rs/**' # Get the unreleased notes for PR body # Strip version header since PR title already has version -PR_BODY="$(git cliff "$release_range" --tag "$version" --strip all --include-path 'lib/**' --include-path 'cli/**' --include-path 'argv/**' --include-path 'derive/**' | tail -n +3)" +PR_BODY="$(git cliff "$release_range" --tag "$version" --strip all --include-path 'lib/**' --include-path 'cli/**' --include-path 'argv/**' --include-path 'derive/**' --include-path 'usage-rs/**' | tail -n +3)" cargo set-version "${version#v}" --exclude clap_usage --exclude usage-conformance mise run render @@ -90,7 +95,7 @@ cargo update aube update mise run lint-fix git add \ - {./,argv/,cli/,derive/,lib/}Cargo.* \ + {./,argv/,cli/,derive/,lib/,usage-rs/}Cargo.* \ package.json aube-lock.yaml \ cli/usage.usage.kdl \ docs/cli/reference/* \ diff --git a/usage-rs/Cargo.toml b/usage-rs/Cargo.toml index 753cc9290..4396ac617 100644 --- a/usage-rs/Cargo.toml +++ b/usage-rs/Cargo.toml @@ -15,7 +15,10 @@ usage-argv = { workspace = true } usage-derive = { workspace = true, optional = true } [features] -default = ["spec", "help"] +# Applications get a usable CLI out of the box: parse tables, help, and +# clap-shaped errors. Completions stay opt-in. Low-level adopters that want only +# the binding runtime keep depending on `usage-argv` directly (no defaults). +default = ["spec", "help", "diagnostics"] spec = ["usage-argv/spec", "dep:usage-derive"] help = ["spec"] completions = ["spec", "usage-argv/complete"] diff --git a/usage-rs/src/lib.rs b/usage-rs/src/lib.rs index 943577499..361b005e1 100644 --- a/usage-rs/src/lib.rs +++ b/usage-rs/src/lib.rs @@ -1,7 +1,9 @@ //! The facade for building compiled Rust CLIs with usage. //! -//! Depend on `usage-rs` under the short crate name `usage`; the derive macros and their runtime -//! then come from one versioned package, while cold-path functionality stays behind features: +//! Depend on `usage-rs` under the short crate name `usage`. That is the one package an +//! application needs: derive macros, the argv runtime, help, and clap-shaped errors ship in the +//! defaults. Completions stay behind a feature; low-level adopters that want only the binding +//! runtime keep depending on `usage-argv` directly. //! //! ```toml //! [dependencies] diff --git a/usage-rs/tests/facade.rs b/usage-rs/tests/facade.rs index a74567a9f..d32b4890e 100644 --- a/usage-rs/tests/facade.rs +++ b/usage-rs/tests/facade.rs @@ -48,3 +48,16 @@ fn unit_subcommands_use_the_facade_derive() { let cli = Ex::parse_from(&[OsStr::new("version")]).expect("valid unit subcommand"); assert!(matches!(cli.command, Command::Version)); } + +#[test] +fn defaults_render_clap_shaped_parse_errors() { + let argv = [OsStr::new("--wat")]; + let Err(err) = Ex::parse_from(&argv) else { + panic!("unknown flag should fail"); + }; + let message = usage::render_failure(Ex::spec(), &argv, &err); + assert!( + message.contains("unexpected argument '--wat'"), + "defaults should enable diagnostics; got:\n{message}" + ); +} From 1fbb45428d698b3a5ff0da3fbc8b17df98b6fb66 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 18:52:53 +0000 Subject: [PATCH 2/5] test(lib): assert facade diagnostics with plain style render_failure colours via Style::auto when stderr is a TTY or CLICOLOR_FORCE is set, which embeds ANSI codes in the quoted token and flakes a literal substring check. Co-authored-by: jdx --- usage-rs/tests/facade.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/usage-rs/tests/facade.rs b/usage-rs/tests/facade.rs index d32b4890e..516c5eb7c 100644 --- a/usage-rs/tests/facade.rs +++ b/usage-rs/tests/facade.rs @@ -55,7 +55,16 @@ fn defaults_render_clap_shaped_parse_errors() { let Err(err) = Ex::parse_from(&argv) else { panic!("unknown flag should fail"); }; - let message = usage::render_failure(Ex::spec(), &argv, &err); + // `render_failure` colours via `Style::auto()` when stderr is a TTY or + // `CLICOLOR_FORCE` is set, which would put ANSI codes inside the quotes and + // break a literal substring check. Plain style is what a pipe (and this + // assertion) wants. + let message = usage::diagnostic::render( + Ex::spec(), + &argv, + &err, + usage::diagnostic::Style::PLAIN, + ); assert!( message.contains("unexpected argument '--wat'"), "defaults should enable diagnostics; got:\n{message}" From b3f9590693f2ab63886b9246fe7e8c67d09f50b0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 18:58:13 +0000 Subject: [PATCH 3/5] ci: retry stalled apt mirrors when installing shells Same fix as #1036: per-connection apt timeouts so a hanging azure mirror does not burn the step budget before archive.ubuntu.com is tried. Co-authored-by: jdx --- .github/workflows/coverage.yml | 18 +++++++++++++++--- .github/workflows/test.yml | 32 +++++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 7409b9931..8fc7b4f3e 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -30,13 +30,25 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Install cargo-llvm-cov uses: taiki-e/install-action@4c7e9f3bb4ca35f54341be8fc8d3608f71e4d24e # zizmor: ignore[impostor-commit] cargo-llvm-cov (tag-only ref by design) + # See the note in test.yml: the shell tests refuse to skip under CI, so this fixes + # the flaky mirror rather than the consequence of it. - name: Install shells for completion integration tests - timeout-minutes: 3 + timeout-minutes: 5 run: | - sudo apt-get update - sudo apt-get install -y zsh fish + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15 update + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15 install -y zsh fish if ! command -v pwsh >/dev/null 2>&1; then sudo snap install powershell --classic fi + - name: Report which shells are available + if: always() + run: | + for shell in bash zsh fish pwsh; do + if command -v "$shell" >/dev/null; then + echo "$shell: $(command -v "$shell")" + else + echo "::warning::$shell is missing, so its completion tests cannot run" + fi + done - name: Generate code coverage run: mise run coverage diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c1fbf0cb..d184365ad 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,17 +30,43 @@ jobs: with: shared-key: test - uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4 + # Retries and a longer wait, rather than being allowed to fail. `cli`'s completion + # tests refuse to skip a missing shell when `CI` is set — see + # `skip_if_shell_missing` — which is a deliberate policy this step exists to keep: + # a run that quietly tested one shell instead of four is worse than a run that + # stopped. So the flakiness is what gets fixed, not the consequence of it. + # + # The failure mode, seen three times in an afternoon, is the azure mirror that + # GitHub's runners list first *stalling* rather than refusing. Retries alone did not + # help — an eight-minute attempt with them timed out the same way — because retrying + # a connection that hangs just hangs again. What was missing is a per-connection + # timeout: with one, apt gives up on the stalled mirror in seconds and moves to the + # next entry in `/etc/apt/apt-mirrors.txt`, which is the archive.ubuntu.com that was + # answering fine throughout. - name: Install shells for completion integration tests - timeout-minutes: 3 + timeout-minutes: 5 run: | - sudo apt-get update - sudo apt-get install -y zsh fish + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15 update + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15 install -y zsh fish # pwsh is pre-installed on GitHub ubuntu-latest images. Self-heal # if a future image drops it so the integration test still runs # (rather than panicking under CI=1). if ! command -v pwsh >/dev/null 2>&1; then sudo snap install powershell --classic fi + # A step of its own, and `always()`, because the interesting time to hear which + # shells are present is the run where the one above did not finish — and a `set -e` + # script says nothing after the command that failed it. + - name: Report which shells are available + if: always() + run: | + for shell in bash zsh fish pwsh; do + if command -v "$shell" >/dev/null; then + echo "$shell: $(command -v "$shell")" + else + echo "::warning::$shell is missing, so its completion tests cannot run" + fi + done - run: mise r build - run: mise r test # Run here rather than in a job of its own: the corpus vectors carry KDL From b18d84ee0ec9e125cba7be02c41f36d8a4f06f64 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 19:50:53 +0000 Subject: [PATCH 4/5] style(lib): format facade diagnostics test Co-authored-by: jdx --- usage-rs/tests/facade.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/usage-rs/tests/facade.rs b/usage-rs/tests/facade.rs index 516c5eb7c..05ffcd865 100644 --- a/usage-rs/tests/facade.rs +++ b/usage-rs/tests/facade.rs @@ -59,12 +59,8 @@ fn defaults_render_clap_shaped_parse_errors() { // `CLICOLOR_FORCE` is set, which would put ANSI codes inside the quotes and // break a literal substring check. Plain style is what a pipe (and this // assertion) wants. - let message = usage::diagnostic::render( - Ex::spec(), - &argv, - &err, - usage::diagnostic::Style::PLAIN, - ); + let message = + usage::diagnostic::render(Ex::spec(), &argv, &err, usage::diagnostic::Style::PLAIN); assert!( message.contains("unexpected argument '--wat'"), "defaults should enable diagnostics; got:\n{message}" From db5622ac6cd4b7e84599076c284fb38203d3b128 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:34:33 +0000 Subject: [PATCH 5/5] chore: verify shells can run scripts --- .github/workflows/coverage.yml | 19 ++++++++++++++++--- .github/workflows/test.yml | 19 ++++++++++++++++--- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 8fc7b4f3e..965abbdf2 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -43,11 +43,24 @@ jobs: - name: Report which shells are available if: always() run: | + probe_dir=$(mktemp -d) + trap 'rm -rf "$probe_dir"' EXIT for shell in bash zsh fish pwsh; do - if command -v "$shell" >/dev/null; then - echo "$shell: $(command -v "$shell")" + if [[ "$shell" == pwsh ]]; then + probe="$probe_dir/$shell.ps1" else - echo "::warning::$shell is missing, so its completion tests cannot run" + probe="$probe_dir/$shell" + fi + printf 'echo ok\n' > "$probe" + if [[ "$shell" == pwsh ]]; then + output=$(pwsh -NoProfile -NonInteractive -File "$probe" 2>/dev/null) && usable=true || usable=false + else + output=$("$shell" "$probe" 2>/dev/null) && usable=true || usable=false + fi + if [[ "$usable" == true && "$output" == ok ]]; then + echo "$shell: $(command -v "$shell") (script probe passed)" + else + echo "::warning::$shell cannot run a script, so its completion tests cannot run" fi done - name: Generate code coverage diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d184365ad..df3d1f087 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,11 +60,24 @@ jobs: - name: Report which shells are available if: always() run: | + probe_dir=$(mktemp -d) + trap 'rm -rf "$probe_dir"' EXIT for shell in bash zsh fish pwsh; do - if command -v "$shell" >/dev/null; then - echo "$shell: $(command -v "$shell")" + if [[ "$shell" == pwsh ]]; then + probe="$probe_dir/$shell.ps1" else - echo "::warning::$shell is missing, so its completion tests cannot run" + probe="$probe_dir/$shell" + fi + printf 'echo ok\n' > "$probe" + if [[ "$shell" == pwsh ]]; then + output=$(pwsh -NoProfile -NonInteractive -File "$probe" 2>/dev/null) && usable=true || usable=false + else + output=$("$shell" "$probe" 2>/dev/null) && usable=true || usable=false + fi + if [[ "$usable" == true && "$output" == ok ]]; then + echo "$shell: $(command -v "$shell") (script probe passed)" + else + echo "::warning::$shell cannot run a script, so its completion tests cannot run" fi done - run: mise r build