Rename scaffold-rust-binary skill and expand content
Renamed from scaffolding-rust-binary to match verb form used by
other skills. Added disable-model-invocation, companion file
references, and detailed justfile/CI/testing sections.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/ai/skills/scaffolding-rust-binary/SKILL.md b/ai/skills/scaffold-rust-binary/SKILL.md
similarity index 64%
rename from ai/skills/scaffolding-rust-binary/SKILL.md
rename to ai/skills/scaffold-rust-binary/SKILL.md
index 78b7933f..c39b12d2 100644
--- a/ai/skills/scaffolding-rust-binary/SKILL.md
+++ b/ai/skills/scaffold-rust-binary/SKILL.md
@@ -1,14 +1,22 @@
---
-name: scaffolding-rust-binary
+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
+disable-model-invocation: true
---
# Creating a Rust Binary Project
-Scaffold a single-crate Rust binary with library code, a justfile, 100% coverage enforcement, GitHub Actions CI, and CalVer releases.
+Scaffold a single-crate Rust binary with library code, a justfile, 100% coverage enforcement, property testing, mutation testing, GitHub Actions CI, and CalVer releases.
*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
+- `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
Single crate with both library and binary targets. All domain logic lives in the library; the binary is a thin CLI shell.
@@ -16,9 +24,12 @@ 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
├── justfile
├── .gitignore # /target
+├── .cargo/
+│ └── mutants.toml # Excludes equivalent/unreachable mutations
├── .github/workflows/
│ ├── ci.yml
│ └── release.yml
@@ -32,7 +43,8 @@ project/
│ └── commands/ # One module per subcommand group
│ └── mod.rs
├── tests/
-│ └── cli.rs # Integration tests via assert_cmd
+│ ├── cli.rs # Integration tests via assert_cmd
+│ └── property.rs # Proptest roundtrip / invariant tests
└── migrations/ # If using a database
```
@@ -58,15 +70,17 @@ tracing-subscriber = { version = "*", features = ["env-filter"] }
[dev-dependencies]
assert_cmd = "*"
predicates = "*"
+proptest = "*"
tempfile = "*"
```
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.
-- **`color-eyre`** — pretty error reports in the binary.
-- **`thiserror`** — structured errors in the library.
+
+- `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.
+- `color-eyre` — pretty error reports in the binary.
+- `thiserror` — structured errors in the library.
## Entrypoint Pattern
@@ -159,7 +173,7 @@ coverage:
--ignore-not-existing \
--keep-only 'src/**' \
--ignore 'src/bin/**' \
- --excl-line 'cov-excl-line' \
+ --excl-line 'cov-excl-line|unreachable!' \
--excl-start 'cov-excl-start' \
--excl-stop 'cov-excl-stop')
echo "$REPORT" | jq -r '
@@ -178,6 +192,17 @@ coverage:
exit 1
fi
+mutants:
+ #!/usr/bin/env bash
+ set -uo pipefail
+ cargo mutants --timeout-multiplier 3 -j4
+ rc=$?
+ # 0 = all caught, 3 = timeouts (infinite loops from mutants, still caught)
+ if [ "$rc" -eq 0 ] || [ "$rc" -eq 3 ]; then
+ exit 0
+ fi
+ exit "$rc"
+
all: fmt clippy coverage
install:
@@ -185,10 +210,12 @@ install:
```
Key design:
-- **`coverage` uses a separate `CARGO_TARGET_DIR`** — prevents instrumented and non-instrumented artifacts from mixing, which causes phantom uncovered lines with grcov.
-- **100% coverage on library code only** — `--keep-only 'src/**' --ignore 'src/bin/**'`. Binary code is tested via integration tests but not measured.
-- **`covdir` output** — machine-readable JSON, parsed with `jq` for a clean summary.
-- **Exclusion markers** — `cov-excl-line`, `cov-excl-start`/`cov-excl-stop` for structurally unreachable code.
+
+- `coverage` uses a separate `CARGO_TARGET_DIR` — prevents instrumented and non-instrumented artifacts from mixing, which causes phantom uncovered lines with grcov.
+- 100% coverage on library code only — `--keep-only 'src/**' --ignore 'src/bin/**'`. Binary code is tested via integration tests but not measured.
+- `covdir` output — machine-readable JSON, parsed with `jq` for a clean summary.
+- 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
@@ -207,75 +234,19 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: rustup component add clippy rustfmt llvm-tools
- - run: cargo install grcov
- - run: cargo install just
+ - run: cargo install grcov cargo-mutants just
- run: cargo fmt --check
- run: just clippy coverage
+ - run: just mutants
```
`cargo fmt --check` instead of `just fmt` — CI should fail on unformatted code, not silently fix it.
-## Release Workflow
-
-CalVer (`YYYY-MM-DD+SHORT_SHA`) with automatic releases on green main builds.
-
-```yaml
-# .github/workflows/release.yml
-name: Release
-
-on:
- workflow_dispatch:
- workflow_run:
- workflows: [CI]
- types: [completed]
- branches: [main]
-
-permissions:
- contents: write
-
-jobs:
- build:
- if: >-
- github.event_name == 'workflow_dispatch'
- || github.event.workflow_run.conclusion == 'success'
- runs-on: macos-latest
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Calculate version
- id: version
- run: |
- CALVER=$(date -u +"%Y-%m-%d")
- SHORT_SHA=$(git rev-parse --short HEAD)
- echo "version=${CALVER}+${SHORT_SHA}" >> $GITHUB_OUTPUT
-
- - name: Build
- run: |
- cargo build --release
- tar -czf <name>-aarch64-apple-darwin.tar.gz -C target/release <name>
-
- - name: Publish
- run: |
- VERSION="${{ steps.version.outputs.version }}"
- git tag "v${VERSION}"
- git push origin "v${VERSION}"
- gh release create "v${VERSION}" \
- --title "v${VERSION}" \
- --generate-notes \
- <name>-aarch64-apple-darwin.tar.gz
- env:
- GH_TOKEN: ${{ github.token }}
-```
-
-Adjust `runs-on` and archive name for target platform. Add matrix builds for cross-platform.
-
## Testing
-**Library tests**: Unit tests in each module, using `tempfile` for isolation when state is involved.
+Unit tests live in each module, using `tempfile` for isolation when state is involved.
-**Integration tests**: `tests/cli.rs` exercises the compiled binary end-to-end via `assert_cmd`.
+Integration tests in `tests/cli.rs` exercise the compiled binary end-to-end via `assert_cmd`:
```rust
// tests/cli.rs
@@ -293,11 +264,15 @@ fn shows_help() {
}
```
+Property tests in `tests/property.rs` use proptest for roundtrip and invariant checks. See `property-testing.md`.
+
+Mutation testing via `just mutants` catches code that tests execute but don't verify. See `mutation-testing.md`.
+
## Prerequisites
```bash
rustup component add clippy rustfmt llvm-tools
-cargo install grcov just
+cargo install grcov cargo-mutants just
```
## Quick Reference
@@ -307,6 +282,7 @@ cargo install grcov just
| Format | `just fmt` |
| Lint | `just clippy` |
| Coverage | `just coverage` |
+| Mutation testing | `just mutants` |
| All checks | `just all` |
| Install from source | `just install` |
| Find uncovered lines | Change `-t covdir` to `-t markdown` in justfile |
diff --git a/ai/skills/scaffold-rust-binary/mutation-testing.md b/ai/skills/scaffold-rust-binary/mutation-testing.md
new file mode 100644
index 00000000..df347a35
--- /dev/null
+++ b/ai/skills/scaffold-rust-binary/mutation-testing.md
@@ -0,0 +1,32 @@
+# Mutation Testing
+
+`cargo mutants` modifies your source code (replacing operators, deleting calls, changing return values) and checks that at least one test fails for each mutation. Surviving mutants indicate code that tests execute but don't actually verify.
+
+Run mutation testing separately from coverage — it's slow:
+
+```bash
+just mutants
+```
+
+## Excluding Equivalent Mutants
+
+Some mutations produce equivalent programs. For example, changing `> 1` to `>= 1` when a prior branch already handled `== 1`. These can't be caught because the mutant behaves identically to the original.
+
+Document exclusions in `.cargo/mutants.toml` with regex patterns and a comment explaining why each is equivalent or unreachable:
+
+```toml
+# Equivalent mutant: the `== 1` branch above already handles len 1,
+# so `> 1` and `>= 1` are equivalent in the else-if position.
+exclude_re = [
+ "my_file\\.rs:\\d+:\\d+: replace > with >= in some_function",
+]
+```
+
+Every exclusion needs a comment. Undocumented exclusions hide real gaps.
+
+## Workflow
+
+1. Run `just mutants` after reaching 100% coverage.
+2. Surviving mutants mean tests reach the code but don't assert on its behavior. Add targeted assertions.
+3. If a mutant is equivalent (same behavior) or unreachable (dead code behind `unreachable!`), add it to `.cargo/mutants.toml` with an explanation.
+4. CI enforces zero missed mutants — a surviving mutant fails the build.
diff --git a/ai/skills/scaffold-rust-binary/property-testing.md b/ai/skills/scaffold-rust-binary/property-testing.md
new file mode 100644
index 00000000..b20c251b
--- /dev/null
+++ b/ai/skills/scaffold-rust-binary/property-testing.md
@@ -0,0 +1,47 @@
+# 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.
diff --git a/ai/skills/scaffold-rust-binary/release.md b/ai/skills/scaffold-rust-binary/release.md
new file mode 100644
index 00000000..6ba4f6aa
--- /dev/null
+++ b/ai/skills/scaffold-rust-binary/release.md
@@ -0,0 +1,97 @@
+# Release Workflow
+
+CalVer (`YYYY-MM-DD+SHORT_SHA`) with automatic releases on green main builds.
+
+```yaml
+# .github/workflows/release.yml
+name: Release
+
+on:
+ workflow_dispatch:
+ workflow_run:
+ workflows: [CI]
+ types: [completed]
+ branches: [main]
+
+permissions:
+ contents: write
+
+jobs:
+ build:
+ if: >-
+ github.event_name == 'workflow_dispatch'
+ || github.event.workflow_run.conclusion == 'success'
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Calculate version
+ id: version
+ run: |
+ CALVER=$(date -u +"%Y-%m-%d")
+ SHORT_SHA=$(git rev-parse --short HEAD)
+ echo "version=${CALVER}+${SHORT_SHA}" >> $GITHUB_OUTPUT
+
+ - name: Build
+ run: |
+ cargo build --release
+ tar -czf <name>-aarch64-apple-darwin.tar.gz -C target/release <name>
+
+ - name: Publish
+ run: |
+ VERSION="${{ steps.version.outputs.version }}"
+ git tag "v${VERSION}"
+ git push origin "v${VERSION}"
+ gh release create "v${VERSION}" \
+ --title "v${VERSION}" \
+ --generate-notes \
+ <name>-aarch64-apple-darwin.tar.gz
+ env:
+ GH_TOKEN: ${{ github.token }}
+```
+
+Adjust `runs-on` and archive name for target platform. Add matrix builds for cross-platform.
+
+The build job exposes `outputs.version` so downstream jobs can reference the tag.
+
+## DotSlash
+
+[DotSlash](https://dotslash-cli.com) generates a small launcher file that fetches and caches the real binary on first run. Add a config file and a second job to the release workflow.
+
+```json
+// .github/dotslash-config.json
+{
+ "outputs": {
+ "<name>": {
+ "platforms": {
+ "macos-aarch64": {
+ "regex": "^<name>-aarch64-apple-darwin\\.tar\\.gz$",
+ "path": "<name>"
+ }
+ }
+ }
+ }
+}
+```
+
+Add a `dotslash` job to `release.yml` after the build job:
+
+```yaml
+ dotslash:
+ needs: build
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Generate DotSlash file
+ uses: facebook/dotslash-publish-release@v1
+ with:
+ config: .github/dotslash-config.json
+ tag: v${{ needs.build.outputs.version }}
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+```
+
+This attaches a `<name>` DotSlash file to the release. Users download it, make it executable, and the binary self-updates from GitHub releases.
diff --git a/ai/skills/scaffold-rust-binary/versioning.md b/ai/skills/scaffold-rust-binary/versioning.md
new file mode 100644
index 00000000..053adfb8
--- /dev/null
+++ b/ai/skills/scaffold-rust-binary/versioning.md
@@ -0,0 +1,44 @@
+# Versioning
+
+A `build.rs` sets a single `<NAME>_VERSION` env var. CI sets it to CalVer; local builds default to `dev+<commit>`.
+
+```rust
+// build.rs
+use std::process::Command;
+
+fn main() {
+ let version = std::env::var("<NAME>_VERSION").unwrap_or_else(|_| {
+ let commit = cmd("git", &["rev-parse", "--short", "HEAD"]);
+ format!("dev+{commit}")
+ });
+ println!("cargo:rustc-env=<NAME>_VERSION={version}");
+}
+
+fn cmd(program: &str, args: &[&str]) -> String {
+ Command::new(program)
+ .args(args)
+ .output()
+ .ok()
+ .and_then(|o| String::from_utf8(o.stdout).ok())
+ .map(|s| s.trim().to_string())
+ .unwrap_or_else(|| "unknown".into())
+}
+```
+
+In the binary, read it as a constant and pass it to clap:
+
+```rust
+const VERSION: &str = env!("<NAME>_VERSION");
+
+#[derive(Parser)]
+#[command(version = VERSION)]
+struct Cli { /* ... */ }
+```
+
+The release workflow sets the env var during build:
+
+```yaml
+ - name: Build
+ run: |
+ <NAME>_VERSION="${{ steps.version.outputs.version }}" cargo build --release
+```