Rename scaffold-rust-binary skill to rust-binary
Reframed from scaffolding instructions to a preferences doc — usable
for both new projects and updating existing ones to current standards.
- Replace proptest with hegeltest (Hypothesis-backed property testing)
- Add clap_complete for shell completion generation
- Add --completions flag pattern to the entrypoint
- Add setup-uv CI step (hegeltest dependency)
- Rewrite property-testing.md for the hegeltest API
Assisted-by: Claude Opus 4.6 via pi
diff --git a/ai/skills/scaffold-rust-binary/SKILL.md b/ai/skills/rust-binary/SKILL.md
similarity index 76%
rename from ai/skills/scaffold-rust-binary/SKILL.md
rename to ai/skills/rust-binary/SKILL.md
index c39b12d2..b1dbf8a3 100644
--- a/ai/skills/scaffold-rust-binary/SKILL.md
+++ b/ai/skills/rust-binary/SKILL.md
@@ -1,23 +1,23 @@
---
-name: scaffold-rust-binary
-description: Use when creating a new Rust binary project from scratch — scaffolding the crate, justfile, CI, coverage, release pipeline, and test harness
+name: rust-binary
+description: Use when creating or updating a Rust binary project — covers preferred crate layout, dependencies, justfile, CI, coverage, release pipeline, and test harness
disable-model-invocation: true
---
-# Creating a Rust Binary Project
+# Rust binary preferences
-Scaffold a single-crate Rust binary with library code, a justfile, 100% coverage enforcement, property testing, mutation testing, GitHub Actions CI, and CalVer releases.
+Standards and preferences for Rust binary projects. Use this when scaffolding a new binary or bringing an existing one up to current standards.
*This is a self-improving skill — see the `self-improving-skills` skill.*
Read these companion files when working on their specific concerns:
-- `property-testing.md` — proptest strategies, roundtrip patterns, regression files
+- `property-testing.md` — hegeltest generators, composite generators, roundtrip patterns
- `mutation-testing.md` — cargo-mutants, `.cargo/mutants.toml`, exclusion workflow
- `versioning.md` — build.rs, CalVer, `<NAME>_VERSION` env var
- `release.md` — release workflow, DotSlash
-## Project Structure
+## Project structure
Single crate with both library and binary targets. All domain logic lives in the library; the binary is a thin CLI shell.
@@ -25,27 +25,27 @@ Single crate with both library and binary targets. All domain logic lives in the
project/
├── Cargo.toml
├── build.rs # Sets <NAME>_VERSION for --version
-├── Cargo.lock # committed — it's a binary
+├── Cargo.lock # Committed — it's a binary.
├── justfile
├── .gitignore # /target
├── .cargo/
-│ └── mutants.toml # Excludes equivalent/unreachable mutations
+│ └── mutants.toml # Excludes equivalent/unreachable mutations.
├── .github/workflows/
│ ├── ci.yml
│ └── release.yml
├── src/
-│ ├── lib.rs # Library root — re-exports modules
-│ ├── error.rs # thiserror enum
+│ ├── lib.rs # Library root — re-exports modules.
+│ ├── error.rs # thiserror enum.
│ ├── (domain modules)
│ └── bin/<name>/
-│ ├── main.rs # Entrypoint, clap, thin dispatch
-│ ├── output.rs # Human/JSON output helpers
-│ └── commands/ # One module per subcommand group
+│ ├── main.rs # Entrypoint, clap, thin dispatch.
+│ ├── output.rs # Human/JSON output helpers.
+│ └── commands/ # One module per subcommand group.
│ └── mod.rs
├── tests/
-│ ├── cli.rs # Integration tests via assert_cmd
-│ └── property.rs # Proptest roundtrip / invariant tests
-└── migrations/ # If using a database
+│ ├── cli.rs # Integration tests via assert_cmd.
+│ └── property.rs # Hegeltest property tests.
+└── migrations/ # If using a database.
```
## Cargo.toml
@@ -62,6 +62,7 @@ path = "src/bin/<name>/main.rs"
[dependencies]
clap = { version = "*", features = ["derive", "env"] }
+clap_complete = "*"
color-eyre = "*"
thiserror = "*"
tokio = { version = "*", features = ["full"] }
@@ -69,8 +70,8 @@ tracing-subscriber = { version = "*", features = ["env-filter"] }
[dev-dependencies]
assert_cmd = "*"
+hegeltest = "*"
predicates = "*"
-proptest = "*"
tempfile = "*"
```
@@ -79,17 +80,20 @@ Key choices:
- `edition = "2024"` — latest stable edition.
- Unpinned dependencies (`"*"`) — `Cargo.lock` is committed (it's a binary), so builds are reproducible. Unpinned versions mean `cargo update` gets the latest compatible releases without editing `Cargo.toml`.
- `clap` with `derive` + `env` — declarative CLI with env var fallbacks.
+- `clap_complete` — shell completion generation for bash, zsh, fish, etc.
- `color-eyre` — pretty error reports in the binary.
- `thiserror` — structured errors in the library.
+- `hegeltest` — property-based testing built on the Hypothesis engine, with built-in shrinking.
-## Entrypoint Pattern
+## Entrypoint pattern
```rust
// src/bin/<name>/main.rs
mod commands;
mod output;
-use clap::{Parser, Subcommand};
+use clap::{CommandFactory, Parser, Subcommand};
+use clap_complete::Shell;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
#[derive(Parser)]
@@ -98,8 +102,12 @@ struct Cli {
#[arg(long, global = true)]
json: bool,
+ /// Generate shell completions and exit.
+ #[arg(long, value_enum)]
+ completions: Option<Shell>,
+
#[command(subcommand)]
- command: Commands,
+ command: Option<Commands>,
}
#[derive(Subcommand)]
@@ -120,7 +128,23 @@ async fn main() -> color_eyre::Result<()> {
.init();
let cli = Cli::parse();
- match cli.command {
+
+ if let Some(shell) = cli.completions {
+ clap_complete::generate(
+ shell,
+ &mut Cli::command(),
+ "<name>",
+ &mut std::io::stdout(),
+ );
+ return Ok(());
+ }
+
+ let Some(command) = cli.command else {
+ Cli::command().print_help()?;
+ return Ok(());
+ };
+
+ match command {
Commands::Example { command } => {
commands::example::run(command, cli.json).await?;
}
@@ -129,7 +153,15 @@ async fn main() -> color_eyre::Result<()> {
}
```
-## Error Pattern
+The `--completions` flag generates shell completions to stdout:
+
+```bash
+<name> --completions zsh > _<name>
+<name> --completions bash > <name>.bash
+<name> --completions fish > <name>.fish
+```
+
+## Error pattern
Library errors use `thiserror`. The binary uses `color_eyre::Result`.
@@ -197,7 +229,7 @@ mutants:
set -uo pipefail
cargo mutants --timeout-multiplier 3 -j4
rc=$?
- # 0 = all caught, 3 = timeouts (infinite loops from mutants, still caught)
+ # 0 = all caught, 3 = timeouts (infinite loops from mutants, still caught).
if [ "$rc" -eq 0 ] || [ "$rc" -eq 3 ]; then
exit 0
fi
@@ -217,7 +249,7 @@ Key design:
- Exclusion markers — `cov-excl-line`, `cov-excl-start`/`cov-excl-stop` for structurally unreachable code. The `unreachable!` macro is also excluded by default.
- `mutants` tolerates exit code 3 — `cargo mutants` returns 3 for timeouts (infinite loops caused by mutations). These count as caught because the mutant broke the program.
-## CI Workflow
+## CI workflow
```yaml
# .github/workflows/ci.yml
@@ -235,6 +267,7 @@ jobs:
- uses: actions/checkout@v4
- run: rustup component add clippy rustfmt llvm-tools
- run: cargo install grcov cargo-mutants just
+ - uses: astral-sh/setup-uv@v6
- run: cargo fmt --check
- run: just clippy coverage
- run: just mutants
@@ -242,6 +275,8 @@ jobs:
`cargo fmt --check` instead of `just fmt` — CI should fail on unformatted code, not silently fix it.
+The `setup-uv` step is required because hegeltest uses `uv` to manage its Hypothesis backend.
+
## Testing
Unit tests live in each module, using `tempfile` for isolation when state is involved.
@@ -264,7 +299,7 @@ fn shows_help() {
}
```
-Property tests in `tests/property.rs` use proptest for roundtrip and invariant checks. See `property-testing.md`.
+Property tests in `tests/property.rs` use hegeltest. See `property-testing.md`.
Mutation testing via `just mutants` catches code that tests execute but don't verify. See `mutation-testing.md`.
@@ -275,7 +310,9 @@ rustup component add clippy rustfmt llvm-tools
cargo install grcov cargo-mutants just
```
-## Quick Reference
+hegeltest also requires [`uv`](https://docs.astral.sh/uv/) on `PATH` — it manages the Hypothesis engine automatically.
+
+## Quick reference
| Task | Command |
|------|---------|
diff --git a/ai/skills/scaffold-rust-binary/mutation-testing.md b/ai/skills/rust-binary/mutation-testing.md
similarity index 100%
rename from ai/skills/scaffold-rust-binary/mutation-testing.md
rename to ai/skills/rust-binary/mutation-testing.md
diff --git a/ai/skills/rust-binary/property-testing.md b/ai/skills/rust-binary/property-testing.md
new file mode 100644
index 00000000..794eed17
--- /dev/null
+++ b/ai/skills/rust-binary/property-testing.md
@@ -0,0 +1,119 @@
+# Property testing
+
+`tests/property.rs` uses hegeltest to verify invariants across randomly generated inputs. Hegeltest is built on the Hypothesis engine — it generates test data, finds failures, and shrinks to minimal counterexamples automatically.
+
+Hegeltest requires [`uv`](https://docs.astral.sh/uv/) on `PATH` (it manages its Hypothesis backend via uv).
+
+## Basic usage
+
+Test functions take a `TestCase` parameter and draw values from generators:
+
+```rust
+use hegel::TestCase;
+use hegel::generators::{integers, text, vecs};
+
+#[hegel::test]
+fn roundtrip_serialization(tc: TestCase) {
+ let input = tc.draw(integers::<i32>());
+ let serialized = my_crate::serialize(&input).unwrap();
+ let deserialized: i32 = my_crate::deserialize(&serialized).unwrap();
+ assert_eq!(input, deserialized);
+}
+```
+
+## Custom generators with `#[hegel::composite]`
+
+Define reusable generators for domain types using `#[hegel::composite]`:
+
+```rust
+use hegel::TestCase;
+use hegel::generators::{integers, text, booleans, optional};
+
+#[derive(Debug, PartialEq)]
+struct MyStruct {
+ name: String,
+ count: i32,
+ enabled: bool,
+ ratio: f64,
+}
+
+#[hegel::composite]
+fn my_struct_generator(tc: TestCase) -> MyStruct {
+ let name = tc.draw(text());
+ let count = tc.draw(integers::<i32>());
+ let enabled = tc.draw(booleans());
+ let ratio = tc.draw(
+ hegel::generators::floats::<f64>()
+ .allow_nan(false)
+ .allow_infinity(false),
+ );
+ MyStruct { name, count, enabled, ratio }
+}
+
+#[hegel::test]
+fn roundtrip(tc: TestCase) {
+ let val = tc.draw(my_struct_generator());
+ let serialized = my_crate::serialize(&val).unwrap();
+ let deserialized: MyStruct = my_crate::deserialize(&serialized).unwrap();
+ assert_eq!(val, deserialized);
+}
+```
+
+Composite generators can feed earlier draws into later ones — for example, only generating a `driving_license` field when `age >= 18`.
+
+## Available generators
+
+| Generator | Produces | Constraints |
+|-----------|----------|-------------|
+| `integers::<T>()` | Any integer type | `.min_value(n)`, `.max_value(n)` |
+| `floats::<T>()` | f32, f64 | `.allow_nan(bool)`, `.allow_infinity(bool)` |
+| `text()` | String | — |
+| `binary()` | `Vec<u8>` | — |
+| `booleans()` | bool | — |
+| `vecs(gen)` | `Vec<T>` | `.min_size(n)`, `.max_size(n)` |
+| `hashsets(gen)` | `HashSet<T>` | `.min_size(n)`, `.max_size(n)` |
+| `hashmaps(k, v)` | `HashMap<K,V>` | `.min_size(n)`, `.max_size(n)` |
+| `arrays::<N>(gen)` | `[T; N]` | — |
+| `optional(gen)` | `Option<T>` | — |
+| `one_of(gens)` | Union of generators | — |
+| `sampled_from(slice)` | Element from a slice | — |
+| `just(value)` | Constant value | — |
+| `from_regex(pat)` | Strings matching a regex | — |
+| `emails()` | Email addresses | — |
+| `urls()` | URLs | — |
+| `dates()` | Dates | — |
+| `datetimes()` | DateTimes | — |
+
+## Patterns
+
+- Roundtrip serialization for every type the library handles.
+- All integer widths (i8 through u64) roundtrip without truncation.
+- Optional fields with `optional(gen)` cover both `Some` and `None` paths.
+- Collections with `vecs(gen).min_size(0).max_size(10)` exercise empty and populated cases.
+- Enum variants with `one_of![gen1, gen2, ...]` cover each arm.
+- Nested structs via composite generators at multiple depths to catch recursive serialization bugs.
+
+## Debugging
+
+Use `tc.note()` to attach debug info that only appears when replaying the minimal failing example:
+
+```rust
+#[hegel::test]
+fn some_property(tc: TestCase) {
+ let x = tc.draw(integers::<i32>());
+ let y = tc.draw(integers::<i32>());
+ tc.note(&format!("x + y = {}", x + y));
+ assert_eq!(x + y, y + x);
+}
+```
+
+## Adjusting test cases
+
+Default is 100 iterations. Override with the `test_cases` attribute:
+
+```rust
+#[hegel::test(test_cases = 500)]
+fn thorough_check(tc: TestCase) {
+ // ...
+}
+```
diff --git a/ai/skills/scaffold-rust-binary/release.md b/ai/skills/rust-binary/release.md
similarity index 100%
rename from ai/skills/scaffold-rust-binary/release.md
rename to ai/skills/rust-binary/release.md
diff --git a/ai/skills/scaffold-rust-binary/versioning.md b/ai/skills/rust-binary/versioning.md
similarity index 100%
rename from ai/skills/scaffold-rust-binary/versioning.md
rename to ai/skills/rust-binary/versioning.md
diff --git a/ai/skills/scaffold-rust-binary/property-testing.md b/ai/skills/scaffold-rust-binary/property-testing.md
deleted file mode 100644
index b20c251b..00000000
--- a/ai/skills/scaffold-rust-binary/property-testing.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# Property Testing
-
-`tests/property.rs` uses proptest to verify invariants across randomly generated inputs. The most common pattern is roundtrip testing: serialize a value, deserialize it, and confirm equality.
-
-Define reusable strategies for domain types, then compose them:
-
-```rust
-use proptest::prelude::*;
-
-/// Strategy for strings safe to roundtrip through your format.
-fn safe_string() -> impl Strategy<Value = String> {
- "[a-zA-Z0-9 _.,:;!?-]{0,64}"
-}
-
-/// Strategy for finite f64 (excludes NaN and infinity).
-fn finite_f64() -> impl Strategy<Value = f64> {
- prop::num::f64::ANY.prop_filter("finite floats only", |f| f.is_finite())
-}
-
-fn my_struct_strategy() -> impl Strategy<Value = MyStruct> {
- (safe_string(), any::<i32>(), any::<bool>(), finite_f64()).prop_map(
- |(name, count, enabled, ratio)| MyStruct { name, count, enabled, ratio },
- )
-}
-
-proptest! {
- #[test]
- fn roundtrip(val in my_struct_strategy()) {
- let serialized = my_crate::serialize(&val).unwrap();
- let deserialized: MyStruct = my_crate::deserialize(&serialized).unwrap();
- prop_assert_eq!(val, deserialized);
- }
-}
-```
-
-## Patterns
-
-- Roundtrip serialization for every type the library handles
-- All integer widths (i8 through u64) roundtrip without truncation
-- Optional fields with `prop::option::of(...)` cover both `Some` and `None` paths
-- Collections with `prop::collection::vec(strategy, 0..10)` exercise empty and populated cases
-- Enum variants with `prop_oneof![...]` cover each arm
-- Nested structs at multiple depths to catch recursive serialization bugs
-
-## Regression Files
-
-Proptest regression files (`*.proptest-regressions`) record failing seeds. Commit them so CI reproduces past failures.