impish
The Contract gate for Rust, after Impatient Programming. A sibling of flinch (Go) and covenant (Elixir).
A Contract is what a crate promises: numbered obligations, each proved by a tagged contract test. impish fails the build when the Contract stops being true: when an obligation has no test, a test names no obligation, a tag is wrong, or the tests catch none of the bugs put into the code they cover.
Why a Contract
A test suite says what the code does today. It doesn't say which of those behaviours are promises, and which are accidents that are free to change. When a test breaks, you can't tell whether you broke a promise or just a test.
A Contract writes the promises down, one numbered obligation each, next to the code they're about: what the code must do, and why. Every obligation is proved by a test that names it, and every contract test names an obligation. So the promises and their proofs can't drift apart. A reviewer reads the Contract to see what changed in a crate's promises, without reading every test.
This is how Impatient Programming keeps work fast and safe: a change is done when the Contract holds, and a gate, not a person, checks that it does. impish is that gate for Rust.
Install
Then run it as cargo impish. Contract tests use the impish crate's
attribute, so add it as a dev-dependency:
[]
= "0.6"
Every merge that changes what ships is a release, published to crates.io and listed under releases.
Quick start
A Contract is split into primitives, one directory each under contract/.
Write down what the primitive promises in contract/<primitive>/README.md:
`calc::add` adds two numbers.
- --
```covers
src/lib.rs::add
```
Prove each obligation in tests/contract/<primitive>.rs. The attribute names
the obligations the test proves, and on its own makes the function a test:
A test that already has #[test], #[tokio::test] or another attribute whose
path ends in test keeps it. Add the line under it, and the test still runs
once:
async
List the primitives in tests/contract/main.rs, which Cargo builds as the
contract test target:
Then:
Commands
| Command | What it does |
|---|---|
cargo impish check |
Holds the Contract to its tests. Reports every problem on stderr and exits 1, or says how many obligations held and exits 0. Reads only. |
cargo impish check --write |
Also writes contract/coverage.json, the published coverage. Once it exists, check fails when it's out of date. |
cargo impish mutate <primitive> |
Breaks the code the primitive's covers block names with cargo-mutants, on a copy of the project, and credits each kill to the obligations whose tests caught it. Fails on an obligation whose tests catch nothing. Needs cargo install cargo-mutants. |
When it fails
Both commands exit 0 when the Contract holds and 1 when it doesn't. They exit
2 when they can't tell, for example with no contract/ directory, a broken
impish.toml, or contract tests that don't build or are red before
mutate starts. In CI, 1 means fix the Contract or its tests; 2 means fix the
run.
check reports every problem at once, then exits 1:
$ cargo impish check
calc::subtracts names no obligation
calc/A2 has no test
calc/A3 has no test
The Contract is not met.
| Message | Fix |
|---|---|
<test> names no obligation |
Add #[impish::contract("<primitive>/A<n>")] under its #[test]. Every test in tests/contract/ proves something, or it belongs elsewhere. |
<primitive>/A<n> has no test |
Write a test that proves it, or retire the obligation if it's no longer a promise. |
contract/coverage.json is out of date |
Run cargo impish check --write and commit the result. |
…: covers <file>::<fn>, which doesn't exist |
The function was renamed or moved. Update the covers block. |
<file>:<line>: a test outside tests/contract/<primitive>.rs |
A test in main.rs, a helper, or a file with no contract/<name>/README.md. Give it a Contract, or move it out of tests/contract/. |
A tag that names no obligation stops at cargo test, before check runs:
error: `calc/A9`: contract/calc/README.md doesn't declare A9
--> tests/contract/calc.rs:1:20
|
1 | #[impish::contract("calc/A9")]
| ^^^^^^^^^
Reading the kill matrix
check proves every obligation has a test. mutate proves the tests would
notice a bug. It breaks the covered code one change at a time (a mutant), runs
the primitive's contract tests, and credits each failure to the obligations
the failing tests name:
$ cargo impish mutate calc
calc: 10 mutants of 2 functions, 3 tests
src/lib.rs:2:7: replace + with - in add killed by calc/A1
src/lib.rs:6:5: replace sub -> i32 with 1 SURVIVED
src/lib.rs:6:7: replace - with / in sub SURVIVED
…
8 killed, 2 survived, 0 timed out, 0 unviable
obligation killed unique
calc/A1 5 5
calc/A2 3 3
calc/A3 0 0
unique: caught by no other obligation's tests.
Tests outside the Contract weren't run; they may catch it too.
calc/A3 is hollow: its tests catch none of the mutants
The Contract is not met.
- killed: mutants this obligation's tests caught.
- unique: mutants only this obligation's tests caught, among the
Contract's tests.
mutateruns only those, so a test outside the Contract may catch a "unique" mutant too. An obligation with kills but no unique kills is proved only by bugs other obligations already catch. That's fine for a non-functional promise, but worth a look for a functional one. - hollow: an obligation with no kills at all. Its tests pass whatever the code does, so they prove nothing. Assert on the result, or retire the obligation.
- SURVIVED: a bug no contract test caught. Here the only test is
sub(3, 2) == 1, and3 / 2and a constant1give 1 too. A second case, saysub(5, 2) == 3, kills both.
The Contract format
An obligation is a ### A<n> — <title> block under ## Obligations with
must, kind and status fields and an optional why. Numbers are never
reused; an obligation that no longer holds is marked retired and needs no
test. kind is one of functional, non-functional, persisted-format, ordering
or security.
A README may also list the code it is about in a covers block of
<file>::<function> lines, and the surface its tests drive in a surface
block of <kind> <name> lines. To check surface lines against the real
surface, put a command that prints it in impish.toml:
[]
= ["sh", "scripts/surface.sh"]
impish holds itself to its own Contract: contract/README.md
describes the format in full, and each contract/<primitive>/README.md is a
working example.
Changing a Contract
A Contract only grows by append, so an obligation's tag means the same thing forever, in old commits, issues and PR reviews alike.
- Adding a promise: give it the next number, write its test first and
watch it fail, then make it pass.
checkwon't pass until the test exists. - Changing a promise: edit
mustin place when the promise is the same but better worded. When the behaviour itself changes, retire the old obligation and add a new one, so the history shows the promise changed. - Dropping a promise: set
status: retired. Keep the block and its number. Its tests must go too: a test naming a retired obligation fails to compile. - Never renumber. Leave gaps.
A3retired andA4active is a normal Contract.
Run cargo impish check --write after any change, and commit
contract/coverage.json with it.
In CI
A GitHub Actions workflow that gates every PR on the Contract, and publishes its coverage in the job summary:
name: CI
on:
permissions:
contents: read
jobs:
contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- run: cargo install cargo-impish --locked
- run: cargo test
- run: cargo impish check
- name: Contract coverage
run: |
jq -r '"**\(.obligations) active obligations**\n",
"| Primitive | Obligations |", "|---|---|",
(.primitives | to_entries[] | "| \(.key) | \(.value.obligations) |")' \
contract/coverage.json >> "$GITHUB_STEP_SUMMARY"
mutate runs every test once per mutant, so it is slow for PRs. Run it
nightly, one job per primitive:
name: Kill matrix
on:
schedule:
workflow_dispatch:
permissions:
contents: read
jobs:
primitives:
runs-on: ubuntu-latest
outputs:
list: ${{ steps.ls.outputs.list }}
steps:
- uses: actions/checkout@v7
- id: ls
run: echo "list=$(for d in contract/*/; do basename "$d"; done | jq -Rnc '[inputs]')" >> "$GITHUB_OUTPUT"
mutate:
needs: primitives
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
primitive: ${{ fromJson(needs.primitives.outputs.list) }}
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- run: cargo install cargo-impish cargo-mutants --locked
- run: cargo impish mutate "${{ matrix.primitive }}"
impish's own CI and kill matrix go further: pinned actions, sharding and a per-test matrix.
Contributing
ARCHITECTURE.md says where each part lives and which guardrails fire on a change; docs/decisions says why.
License
MIT; see LICENSE.