cargo-impish 0.6.11

The Contract gate for Rust, after Impatient Programming
cargo-impish-0.6.11 is not a library.

impish

crates.io docs.rs CI License: MIT

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

cargo install cargo-impish

Then run it as cargo impish. Contract tests use the impish crate's attribute, so add it as a dev-dependency:

[dev-dependencies]
impish = "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:

# add

`calc::add` adds two numbers.

## Obligations

### A1 — It adds
- **must:** return the sum of its arguments
- **kind:** functional
- **status:** active

```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:

#[impish::contract("add/A1")]
fn adds() {
    assert_eq!(calc::add(2, 3), 5);
}

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:

#[tokio::test]
#[impish::contract("add/A1")]
async fn adds_later() {
    assert_eq!(calc::add(2, 3), 5);
}

List the primitives in tests/contract/main.rs, which Cargo builds as the contract test target:

mod add;

Then:

cargo test               # runs the contract tests; a wrong tag won't compile
cargo impish check       # every obligation is proved, every test names one
cargo impish mutate add  # every obligation's tests catch a bug in the code

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. It is strict: every mutant must be caught, or declared in contract/<primitive>/mutants.md with a reason (0011):

$ 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
src/lib.rs: replace sub -> i32 with 1 survived: catch it in a contract test, or declare it in contract/calc/mutants.md
src/lib.rs: replace - with / in sub survived: catch it in a contract test, or declare it in contract/calc/mutants.md
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. mutate runs 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, so the run fails. Here the only test is sub(3, 2) == 1, and 3 / 2 and a constant 1 give 1 too. A second case, say sub(5, 2) == 3, kills both. A mutant that times out fails the same way: a hang is not a catch.

The run passes only when every obligation catches something and every mutant is caught or declared: Every mutant of calc is caught or declared.

Declaring mutants

Some mutants can't be caught, and some changes the Contract leaves open on purpose. Declare them in contract/<primitive>/mutants.md, in a fenced block under a ## heading that says why:

# Mutants calc leaves alone

## sub's result is only ever compared with zero

Callers check `sub(a, b) == 0`, and the Contract promises nothing more.

```unpromised
src/lib.rs: replace - with / in sub
```
  • equivalent: no program could tell the mutant from the original.
  • unpromised: behaviour changes, and the Contract leaves it open.

A line is the mutant's name as mutate prints it, without the line and column, so it still matches after the code above it moves. It covers every mutant with that name. Declared mutants still run, and the run prints them as SURVIVED, declared unpromised. A declaration fails the run when a contract test catches its mutant (… is declared equivalent, but calc/A1 catches it), when it matches no mutant (… matches no mutant), or when its block has no ## heading above it.

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:

[surface]
command = ["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. check won't pass until the test exists.
  • Changing a promise: edit must in 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. A3 retired and A4 active 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: [push, pull_request]
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: [{ cron: "0 2 * * *" }]
  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.