Split rust-binary into scaffolding and binary references
scaffolding.md holds project shape shared by all binaries; binary.md
has CLI specifics plus the release workflow folded in from release.md.
Errors now derive miette::Diagnostic alongside thiserror so library
errors carry their own diagnostic codes.
Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
diff --git a/ai/skills/rust-binary/SKILL.md b/ai/skills/rust-binary/SKILL.md
deleted file mode 100644
index 74a342a0..00000000
--- a/ai/skills/rust-binary/SKILL.md
+++ /dev/null
@@ -1,333 +0,0 @@
----
-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
----
-
-# Rust binary preferences
-
-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` — 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
-
-Single crate with both library and binary targets. All domain logic lives in the library; the binary is a thin CLI shell.
-
-```
-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
-├── src/
-│ ├── 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.
-│ └── mod.rs
-├── tests/
-│ ├── cli.rs # Integration tests via assert_cmd.
-│ └── property.rs # Hegeltest property tests.
-└── migrations/ # If using a database.
-```
-
-## Cargo.toml
-
-```toml
-[package]
-name = "<name>"
-version = "0.1.0"
-edition = "2024"
-
-[[bin]]
-name = "<name>"
-path = "src/bin/<name>/main.rs"
-
-[dependencies]
-clap = { version = "*", features = ["derive", "env"] }
-clap_complete = "*"
-color-eyre = "*"
-thiserror = "*"
-tokio = { version = "*", features = ["full"] }
-tracing-subscriber = { version = "*", features = ["env-filter"] }
-
-[dev-dependencies]
-assert_cmd = "*"
-hegeltest = "*"
-predicates = "*"
-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.
-- `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
-
-```rust
-// src/bin/<name>/main.rs
-mod commands;
-mod output;
-
-use clap::{CommandFactory, Parser, Subcommand};
-use clap_complete::Shell;
-use tracing_subscriber::{EnvFilter, fmt, prelude::*};
-
-#[derive(Parser)]
-#[command(name = "<name>", about = "<description>")]
-struct Cli {
- #[arg(long, global = true)]
- json: bool,
-
- /// Generate shell completions and exit.
- #[arg(long, value_enum)]
- completions: Option<Shell>,
-
- #[command(subcommand)]
- command: Option<Commands>,
-}
-
-#[derive(Subcommand)]
-enum Commands {
- #[command(visible_alias = "x")]
- Example {
- #[command(subcommand)]
- command: commands::example::ExampleCommands,
- },
-}
-
-#[tokio::main]
-async fn main() -> color_eyre::Result<()> {
- color_eyre::install()?;
- tracing_subscriber::registry()
- .with(fmt::layer())
- .with(EnvFilter::from_default_env())
- .init();
-
- let cli = Cli::parse();
-
- 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?;
- }
- }
- Ok(())
-}
-```
-
-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`.
-
-```rust
-// src/error.rs
-#[derive(Debug, thiserror::Error)]
-pub enum MyError {
- #[error("not found: {0}")]
- NotFound(String),
- #[error("io error: {0}")]
- Io(#[from] std::io::Error),
-}
-```
-
-## justfile
-
-```just
-default: all
-
-fmt:
- cargo fmt --all
-
-check:
- cargo check --workspace
-
-clippy:
- cargo clippy --workspace -- -D warnings
-
-coverage:
- #!/usr/bin/env bash
- set -euo pipefail
- export RUSTFLAGS="-Cinstrument-coverage"
- export CARGO_TARGET_DIR="target/coverage"
- export LLVM_PROFILE_FILE="target/coverage/profraw/%p-%m.profraw"
- rm -rf target/coverage
- cargo test --workspace -q
- REPORT=$(grcov target/coverage/profraw \
- --binary-path ./target/coverage/debug/ \
- -s . \
- -t covdir \
- --ignore-not-existing \
- --keep-only 'src/**' \
- --ignore 'src/bin/**' \
- --excl-line 'cov-excl-line|unreachable!' \
- --excl-start 'cov-excl-start' \
- --excl-stop 'cov-excl-stop')
- echo "$REPORT" | jq -r '
- def files:
- to_entries[] | .value |
- if .children then .children | files
- else "\(.name): \(.coveragePercent)% (\(.linesCovered)/\(.linesTotal))"
- end;
- .children | files
- '
- COVERAGE=$(echo "$REPORT" | jq '.coveragePercent')
- echo ""
- echo "Total: ${COVERAGE}%"
- if [ "$(echo "$COVERAGE < 100" | bc -l)" -eq 1 ]; then
- echo "ERROR: Coverage is below 100%"
- 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:
- cargo install --locked --path .
-```
-
-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. 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
-
-```yaml
-# .github/workflows/ci.yml
-name: CI
-
-on:
- push:
- branches: [main]
- pull_request:
-
-jobs:
- ci:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@SHA # v4
- with:
- persist-credentials: false
- - run: rustup component add clippy rustfmt llvm-tools
- - run: cargo install grcov cargo-mutants just
- - uses: astral-sh/setup-uv@SHA # v6
- - run: cargo fmt --check
- - run: just clippy coverage
- - run: just mutants
-```
-
-Pin all actions to commit SHAs with a version comment (`@<sha> # v4`). Resolve the SHA for each tag at generation time:
-
-```bash
-git ls-remote https://github.com/<owner>/<repo> <tag> | cut -f1
-```
-
-`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.
-
-Integration tests in `tests/cli.rs` exercise the compiled binary end-to-end via `assert_cmd`:
-
-```rust
-// tests/cli.rs
-use assert_cmd::Command;
-use assert_cmd::cargo::cargo_bin_cmd;
-use tempfile::tempdir;
-
-fn cmd() -> Command {
- Command::from(cargo_bin_cmd!("<name>"))
-}
-
-#[test]
-fn shows_help() {
- cmd().arg("--help").assert().success();
-}
-```
-
-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`.
-
-## Prerequisites
-
-```bash
-rustup component add clippy rustfmt llvm-tools
-cargo install grcov cargo-mutants just
-```
-
-hegeltest also requires [`uv`](https://docs.astral.sh/uv/) on `PATH` — it manages the Hypothesis engine automatically.
-
-## Quick reference
-
-| Task | Command |
-|------|---------|
-| 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/rust-binary/release.md b/ai/skills/rust-binary/release.md
deleted file mode 100644
index 5e928068..00000000
--- a/ai/skills/rust-binary/release.md
+++ /dev/null
@@ -1,104 +0,0 @@
-# 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: {}
-
-jobs:
- build:
- if: >-
- github.event_name == 'workflow_dispatch'
- || github.event.workflow_run.conclusion == 'success'
- runs-on: macos-latest
- permissions:
- contents: write # create GitHub release
- steps:
- - uses: actions/checkout@SHA # v4
- with:
- persist-credentials: false
-
- - name: Calculate version
- id: version
- run: |
- CALVER=$(date -u +"%Y-%m-%d")
- SHORT_SHA="${GITHUB_SHA::7}"
- echo "version=${CALVER}+${SHORT_SHA}" >> $GITHUB_OUTPUT
-
- - name: Build
- run: |
- RAMEKIN_VERSION="${STEPS_VERSION_OUTPUTS_VERSION}" cargo build --release
- tar -czf <name>-aarch64-apple-darwin.tar.gz -C target/release <name>
- env:
- STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }}
-
- - name: Publish
- run: |
- VERSION="${STEPS_VERSION_OUTPUTS_VERSION}"
- gh release create "v${VERSION}" \
- --title "v${VERSION}" \
- --generate-notes \
- --target "${GITHUB_SHA}" \
- <name>-aarch64-apple-darwin.tar.gz
- env:
- GH_TOKEN: ${{ github.token }}
- STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }}
-```
-
-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
- permissions:
- contents: write # upload release assets
- steps:
- - uses: actions/checkout@SHA # v4
- with:
- persist-credentials: false
-
- - name: Generate DotSlash file
- uses: facebook/dotslash-publish-release@SHA # 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/rust/binary.md b/ai/skills/rust/binary.md
new file mode 100644
index 00000000..9b5db6ab
--- /dev/null
+++ b/ai/skills/rust/binary.md
@@ -0,0 +1,259 @@
+# Rust CLI binaries
+
+Project-type-specific guidance for command-line tools. Read
+`scaffolding.md` first for shared project shape, then return here for
+CLI-specific additions and the release workflow.
+
+## Cargo.toml additions
+
+On top of the baseline in `scaffolding.md`:
+
+```toml
+[dependencies]
+clap = { version = "*", features = ["derive", "env"] }
+clap_complete = "*"
+
+[dev-dependencies]
+assert_cmd = "*"
+predicates = "*"
+```
+
+- `clap` with `derive` + `env` — declarative CLI with env var fallbacks.
+- `clap_complete` — shell completion generation.
+- `assert_cmd` + `predicates` — integration tests against the compiled
+ binary.
+
+## Source layout
+
+```
+src/bin/<name>/
+├── main.rs # Entrypoint, clap, thin dispatch.
+├── output.rs # Human/JSON output helpers.
+└── commands/ # One module per subcommand group.
+ └── mod.rs
+```
+
+## Entrypoint pattern
+
+```rust
+// src/bin/<name>/main.rs
+mod commands;
+mod output;
+
+use clap::{CommandFactory, Parser, Subcommand};
+use clap_complete::Shell;
+use miette::IntoDiagnostic as _;
+use tracing_subscriber::{EnvFilter, fmt, prelude::*};
+
+#[derive(Parser)]
+#[command(name = "<name>", about = "<description>")]
+struct Cli {
+ #[arg(long, global = true)]
+ json: bool,
+
+ /// Generate shell completions and exit.
+ #[arg(long, value_enum)]
+ completions: Option<Shell>,
+
+ #[command(subcommand)]
+ command: Option<Commands>,
+}
+
+#[derive(Subcommand)]
+enum Commands {
+ #[command(visible_alias = "x")]
+ Example {
+ #[command(subcommand)]
+ command: commands::example::ExampleCommands,
+ },
+}
+
+#[tokio::main]
+async fn main() -> miette::Result<()> {
+ miette::set_panic_hook();
+ tracing_subscriber::registry()
+ .with(fmt::layer())
+ .with(EnvFilter::from_default_env())
+ .init();
+
+ let cli = Cli::parse();
+
+ 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().into_diagnostic()?;
+ return Ok(());
+ };
+
+ match command {
+ Commands::Example { command } => {
+ commands::example::run(command, cli.json).await?;
+ }
+ }
+ Ok(())
+}
+```
+
+Notes:
+
+- `miette::Result<()>` is the binary's return type. Errors surface
+ with their diagnostic codes and any `#[diagnostic(help(...))]`
+ guidance.
+- `miette::set_panic_hook()` makes panics render with the same
+ fancy formatting as recoverable errors.
+- The `--completions` flag generates shell completions to stdout:
+
+ ```bash
+ <name> --completions zsh > _<name>
+ <name> --completions bash > <name>.bash
+ <name> --completions fish > <name>.fish
+ ```
+
+## Integration testing
+
+`tests/cli.rs` exercises the compiled binary end-to-end via
+`assert_cmd`:
+
+```rust
+use assert_cmd::Command;
+use assert_cmd::cargo::cargo_bin_cmd;
+use tempfile::tempdir;
+
+fn cmd() -> Command {
+ Command::from(cargo_bin_cmd!("<name>"))
+}
+
+#[test]
+fn shows_help() {
+ cmd().arg("--help").assert().success();
+}
+```
+
+## Release
+
+CalVer (`YYYY-MM-DD+SHORT_SHA`) with automatic releases on green
+main builds. Wire `<NAME>_VERSION` via `build.rs` — see
+`versioning.md`.
+
+```yaml
+# .github/workflows/release.yml
+name: Release
+
+on:
+ workflow_dispatch:
+ workflow_run:
+ workflows: [CI]
+ types: [completed]
+ branches: [main]
+
+permissions: {}
+
+jobs:
+ build:
+ if: >-
+ github.event_name == 'workflow_dispatch'
+ || github.event.workflow_run.conclusion == 'success'
+ runs-on: macos-latest
+ permissions:
+ contents: write # create GitHub release
+ steps:
+ - uses: actions/checkout@SHA # v4
+ with:
+ persist-credentials: false
+
+ - name: Calculate version
+ id: version
+ run: |
+ CALVER=$(date -u +"%Y-%m-%d")
+ SHORT_SHA="${GITHUB_SHA::7}"
+ echo "version=${CALVER}+${SHORT_SHA}" >> $GITHUB_OUTPUT
+
+ - name: Build
+ run: |
+ <NAME>_VERSION="${STEPS_VERSION_OUTPUTS_VERSION}" cargo build --release
+ tar -czf <name>-aarch64-apple-darwin.tar.gz -C target/release <name>
+ env:
+ STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }}
+
+ - name: Publish
+ run: |
+ VERSION="${STEPS_VERSION_OUTPUTS_VERSION}"
+ gh release create "v${VERSION}" \
+ --title "v${VERSION}" \
+ --generate-notes \
+ --target "${GITHUB_SHA}" \
+ <name>-aarch64-apple-darwin.tar.gz
+ env:
+ GH_TOKEN: ${{ github.token }}
+ STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }}
+```
+
+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
+ permissions:
+ contents: write # upload release assets
+ steps:
+ - uses: actions/checkout@SHA # v4
+ with:
+ persist-credentials: false
+
+ - name: Generate DotSlash file
+ uses: facebook/dotslash-publish-release@SHA # 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.
+
+### Local install
+
+For local distribution and dogfooding:
+
+```bash
+just install # cargo install --locked --path .
+```
diff --git a/ai/skills/rust-binary/mutation-testing.md b/ai/skills/rust/mutation-testing.md
similarity index 100%
rename from ai/skills/rust-binary/mutation-testing.md
rename to ai/skills/rust/mutation-testing.md
diff --git a/ai/skills/rust-binary/property-testing.md b/ai/skills/rust/property-testing.md
similarity index 100%
rename from ai/skills/rust-binary/property-testing.md
rename to ai/skills/rust/property-testing.md
diff --git a/ai/skills/rust/scaffolding.md b/ai/skills/rust/scaffolding.md
new file mode 100644
index 00000000..d4885b63
--- /dev/null
+++ b/ai/skills/rust/scaffolding.md
@@ -0,0 +1,264 @@
+# Project scaffolding
+
+Standards for laying out a Rust project, common to CLIs and servers.
+Read `binary.md` or `server.md` for project-type-specific additions
+on top of this file.
+
+## Project structure
+
+Single crate with both library and binary targets. All domain logic
+lives in the library; the binary is a thin shell.
+
+```
+project/
+├── Cargo.toml
+├── build.rs # Sets <NAME>_VERSION (→ versioning.md).
+├── Cargo.lock # Committed — it's a binary.
+├── justfile
+├── .gitignore # /target
+├── .cargo/
+│ └── mutants.toml # Excludes equivalent/unreachable mutations.
+├── .github/workflows/
+│ ├── ci.yml
+│ └── release.yml # Binaries only — see binary.md.
+├── src/
+│ ├── lib.rs # Library root — re-exports modules.
+│ ├── error.rs # thiserror + miette::Diagnostic enum.
+│ ├── (domain modules)
+│ └── bin/<name>/
+│ ├── main.rs # Entrypoint — see binary.md or server.md.
+│ └── ...
+├── tests/
+│ ├── cli.rs # CLI integration tests via assert_cmd.
+│ │ # (or api.rs for server integration tests)
+│ └── property.rs # Hegeltest property tests (→ property-testing.md).
+└── migrations/ # If using a database.
+```
+
+## Cargo.toml
+
+```toml
+[package]
+name = "<name>"
+version = "0.1.0"
+edition = "2024"
+
+[[bin]]
+name = "<name>"
+path = "src/bin/<name>/main.rs"
+
+[dependencies]
+fs-err = "*"
+miette = { version = "*", features = ["fancy"] }
+thiserror = "*"
+tokio = { version = "*", features = ["full"] }
+tracing = "*"
+tracing-subscriber = { version = "*", features = ["env-filter"] }
+
+[dev-dependencies]
+hegeltest = "*"
+tempfile = "*"
+```
+
+Project-type-specific deps (`clap` for CLIs, `axum` for servers) go in
+`binary.md` / `server.md` and are added on top of this baseline.
+
+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`.
+- `miette` with the `fancy` feature — pretty diagnostic rendering for
+ binaries; the library still derives `miette::Diagnostic` even when
+ the consumer is not the binary (e.g., when running tests).
+- `thiserror` — structured errors in the library.
+- `fs-err` — `std::fs` with paths in error messages (→ `style.md`).
+- `hegeltest` — property-based testing built on the Hypothesis engine,
+ with built-in shrinking.
+
+## Error pattern
+
+Library errors derive both `thiserror::Error` and `miette::Diagnostic`.
+The binary returns `miette::Result<()>`.
+
+```rust
+// src/error.rs
+use miette::Diagnostic;
+
+#[derive(Debug, thiserror::Error, Diagnostic)]
+pub enum MyError {
+ #[error("not found: {0}")]
+ #[diagnostic(
+ code(myapp::not_found),
+ help("check the input identifier and try again"),
+ )]
+ NotFound(String),
+
+ #[error("io error: {0}")]
+ #[diagnostic(code(myapp::io))]
+ Io(#[from] std::io::Error),
+}
+```
+
+The diagnostic codes and help text live on the variant itself, so
+errors carry their own labels and source spans through tests, other
+binaries, and HTTP handlers.
+
+## justfile
+
+```just
+default: all
+
+fmt:
+ cargo fmt --all
+
+check:
+ cargo check --workspace
+
+clippy:
+ cargo clippy --workspace -- -D warnings
+
+coverage:
+ #!/usr/bin/env bash
+ set -euo pipefail
+ export RUSTFLAGS="-Cinstrument-coverage"
+ export CARGO_TARGET_DIR="target/coverage"
+ export LLVM_PROFILE_FILE="target/coverage/profraw/%p-%m.profraw"
+ rm -rf target/coverage
+ cargo test --workspace -q
+ REPORT=$(grcov target/coverage/profraw \
+ --binary-path ./target/coverage/debug/ \
+ -s . \
+ -t covdir \
+ --ignore-not-existing \
+ --keep-only 'src/**' \
+ --ignore 'src/bin/**' \
+ --excl-line 'cov-excl-line|unreachable!' \
+ --excl-start 'cov-excl-start' \
+ --excl-stop 'cov-excl-stop')
+ echo "$REPORT" | jq -r '
+ def files:
+ to_entries[] | .value |
+ if .children then .children | files
+ else "\(.name): \(.coveragePercent)% (\(.linesCovered)/\(.linesTotal))"
+ end;
+ .children | files
+ '
+ COVERAGE=$(echo "$REPORT" | jq '.coveragePercent')
+ echo ""
+ echo "Total: ${COVERAGE}%"
+ if [ "$(echo "$COVERAGE < 100" | bc -l)" -eq 1 ]; then
+ echo "ERROR: Coverage is below 100%"
+ 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:
+ cargo install --locked --path .
+```
+
+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. 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
+
+```yaml
+# .github/workflows/ci.yml
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ ci:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@SHA # v4
+ with:
+ persist-credentials: false
+ - run: rustup component add clippy rustfmt llvm-tools
+ - run: cargo install grcov cargo-mutants just
+ - uses: astral-sh/setup-uv@SHA # v6
+ - run: cargo fmt --check
+ - run: just clippy coverage
+ - run: just mutants
+```
+
+Pin all actions to commit SHAs with a version comment (`@<sha> # v4`).
+Resolve the SHA for each tag at generation time:
+
+```bash
+git ls-remote https://github.com/<owner>/<repo> <tag> | cut -f1
+```
+
+`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.
+
+Integration tests differ by project type — see `binary.md` or
+`server.md` for templates.
+
+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`.
+
+## Prerequisites
+
+```bash
+rustup component add clippy rustfmt llvm-tools
+cargo install grcov cargo-mutants just
+```
+
+hegeltest also requires [`uv`](https://docs.astral.sh/uv/) on `PATH`
+— it manages the Hypothesis engine automatically.
+
+## Quick reference
+
+| Task | Command |
+|------|---------|
+| 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/rust-binary/versioning.md b/ai/skills/rust/versioning.md
similarity index 100%
rename from ai/skills/rust-binary/versioning.md
rename to ai/skills/rust/versioning.md