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
change wwyytslsunvxswqwpoutmmwlywpmwkpr
commit 507df988976b1066a3616ae22595131423be06d9
author Alpha Chen <alpha@kejadlen.dev>
date
parent yllqyxul
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