Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,38 @@ 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: |
probe_dir=$(mktemp -d)
trap 'rm -rf "$probe_dir"' EXIT
for shell in bash zsh fish pwsh; do
if [[ "$shell" == pwsh ]]; then
probe="$probe_dir/$shell.ps1"
else
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
run: mise run coverage
45 changes: 42 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,56 @@ 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: |
probe_dir=$(mktemp -d)
trap 'rm -rf "$probe_dir"' EXIT
for shell in bash zsh fish pwsh; do
if [[ "$shell" == pwsh ]]; then
probe="$probe_dir/$shell.ps1"
else
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
Comment thread
jdx marked this conversation as resolved.
- run: mise r build
- run: mise r test
# Run here rather than in a job of its own: the corpus vectors carry KDL
Expand Down
6 changes: 3 additions & 3 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
7 changes: 4 additions & 3 deletions docs/rust/help.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -77,6 +77,7 @@ Usage: ex [OPTIONS] <FILE>
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.
24 changes: 12 additions & 12 deletions docs/rust/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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

Expand Down
17 changes: 11 additions & 6 deletions tasks/release-plz
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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/* \
Expand Down
5 changes: 4 additions & 1 deletion usage-rs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
6 changes: 4 additions & 2 deletions usage-rs/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
18 changes: 18 additions & 0 deletions usage-rs/tests/facade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,21 @@ 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");
};
// `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}"
);
}
Comment thread
cursor[bot] marked this conversation as resolved.
Loading