# Observer Rust HOWTO
This manual explains how to use the Rust provider micro-library in `lib/rust` as a real end-to-end Observer integration.
It is not just a macro reference.
It is a user manual for the whole process:
- how to author tests in Rust
- how deterministic lowering works
- why proc-macro discovery is not the contract
- how provider hosts expose `list` and `run`
- how Observer derives inventory from the host
- how suites run against that inventory
- how to interpret the passing and failing starter examples
If you are new to this surface, read this file before reading the individual Rust examples.
## Quick Start: First 5 Commands
If you want the fastest path to the Rust integration model, start in `lib/rust/starter/` and run:
```sh
make list
make inventory
cat tests.inv
make host-run TARGET='ledger/rejects-overdraft'
make verify
```
That sequence shows the whole contract in order:
- raw provider `list`
- derived canonical inventory
- the exact exported names Observer will use
- one direct provider-target execution
- full snapshot verification of the end-to-end flow
## Choose A Rust Path
Choose the standalone host path when:
- you want a dedicated provider binary
- the application does not already own a CLI surface
- `./host list` and `./host observe ...` are acceptable developer entrypoints
Choose the embedded path when:
- the application already owns `main()`
- you want `myapp observe list` and `myapp observe --target ...`
- Observer should integrate through an app-owned CLI namespace instead of replacing it
Use `lib/rust/starter/` to learn the standalone path first.
Use `lib/rust/starter-embedded/` immediately after that if the real product already has its own CLI.
## 1. What This Library Is
`lib/rust` is a Rust-facing provider micro-library for Observer.
Its job is to let you:
- write tests in ordinary Rust
- collect those tests through a human-first surface
- lower them deterministically into explicit registrations
- expose those registrations through the standard Observer provider protocol
- run Observer suites against the resulting canonical inventory
The important boundary is this:
- authoring is Rust-native
- execution contract is Observer-native
You do not write Observer suites in Rust.
You write Rust tests, then Observer discovers them through the provider host and runs suites against canonical inventory names.
## 2. The Core Mental Model
There are three layers:
1. your Rust code under test
2. your Rust-authored Observer tests
3. the Observer provider host boundary
The normal flow is:
1. write Rust functions
2. write `describe!(...)`, `test!(...)`, or `it!(...)` registrations
3. call `collect_tests(...)`
4. let the library lower those authored tests into explicit registrations
5. run the host with `list` to expose canonical test names and targets
6. let `observer derive-inventory` lower that list into `tests.inv`
7. run `observer run --inventory ... --suite ... --config ...`
This is why the examples are split into:
- direct Rust snippets in `lib/rust/examples/*.rs`
- real end-to-end starter projects in `lib/rust/starter/` and `lib/rust/starter-failure/`
## 3. The Determinism Gate
This library is intentionally human-first, but the determinism gate still wins.
That means:
- authored tests may use `describe!(...)`, `test!(...)`, and `expect(...)`
- the library may derive an identity from explicit suite path plus title
- optional explicit `id = ...` may override that derived identity
- the host boundary still publishes only explicit resolved registrations
What is not allowed as the contract:
- module scanning
- function-name discovery
- source-location-derived external identity
- compiler-order-dependent implicit registration
- proc macros that hide or change the explicit published inventory contract
The normative rule is simple:
- human-first authoring is fine
- heuristic discovery is not
## 4. The Four Practical Operating Modes
There are four practical ways to use this library.
### 4.1 Mode A: Plain collection and direct execution
This is the smallest local smoke path.
Representative file:
- `lib/rust/examples/example_smoke.rs`
This mode demonstrates:
- collection with `collect_tests(...)`
- deterministic lowering and sorting
- direct `run_test(...)`
It does not demonstrate the full Observer CLI flow.
### 4.2 Mode B: Standalone provider host mode
This is the canonical provider-host path.
Representative file:
- `lib/rust/examples/host_example.rs`
This mode exposes:
- `list`
- `run --target <target> --timeout-ms <u32>`
- `observe --target <target> --timeout-ms <u32>`
Use this when:
- you want a dedicated provider host binary
- you are integrating Rust tests into Observer as a standalone provider
### 4.3 Mode C: Embedded host mode
This is the preferred path when your application already has its own CLI.
Representative file:
- `lib/rust/examples/host_embed_example.rs`
In this mode:
- your app keeps its own `main()`
- you route `observe ...` to Observer host dispatch
- normal app behavior remains intact outside the `observe` command
### 4.4 Mode D: Full Observer CLI workflow
This is the mode real users care about most.
Representative directories:
- `lib/rust/starter/`
- `lib/rust/starter-embedded/`
- `lib/rust/starter-embedded-failure/`
- `lib/rust/starter-failure/`
In this mode you do the whole chain:
- compile the provider host with Cargo
- inspect raw host `list` output
- derive inventory with `observer derive-inventory`
- write and run an Observer suite
- verify canonical report snapshots
This is the mode to learn first if your goal is real adoption.
## 5. The Authoring Surface
The intended authored surface is:
```rust
describe!("ledger", {
test!("rejects overdraft", |ctx| {
expect(true).to_be_truthy();
ctx.stdout("denied overdraft\n");
});
});
```
The common path is:
- `describe!(...)` for grouping
- `test!(...)` or `it!(...)` for human titles
- `expect(...)` for assertions
- `ctx.observe()` for bounded observation
## 6. Derived Identity vs Explicit `id`
By default, the library derives identity mechanically from explicit registration data:
- suite path
- test title
- duplicate occurrence order within the registration stream
That means a test like:
```rust
describe!("ledger", {
test!("rejects overdraft", |_ctx| {});
});
```
resolves to the canonical inventory name:
```text
ledger :: rejects overdraft
```
If you want a refactor-stable external identity, give the test an explicit `id`:
```rust
- canonical identity
- inventory bytes
- suite hash
- report semantics other than adding observational records
## 8. What `collect_tests(...)` Actually Does
`collect_tests(...)` is not runtime discovery.
It is deterministic lowering.
The library records authored registrations in the order the authoring API defines, resolves identities, validates duplicates, and sorts the materialized registration set before host exposure.
That is why the library satisfies the determinism gate while still feeling ergonomic.
## 9. Provider Host Commands
The Rust library owns the standard provider host transport too.
### 9.1 `list`
`list` emits one JSON object containing:
- provider id
- sorted tests
- canonical names and targets
Representative shape:
```json
{"provider":"rust","tests":[{"name":"ledger/rejects-overdraft","target":"ledger/rejects-overdraft"}]}
```
### 9.2 `run`
`run --target <target> --timeout-ms <u32>` executes one published target and emits one JSON object containing at least:
- provider id
- target
- exit
- stdout as base64
- stderr as base64
For developer-facing usage, prefer `observe`. `run` remains available for compatibility with the standardized provider boundary.
## 10. Standalone Host Example
The direct-host example is in:
- `lib/rust/examples/host_example.rs`
Its shape is intentionally small:
```rust
let tests = collect_tests(|| {
describe!("pkg", {
test!("smoke test", id = "pkg::smoke", |ctx| {
ctx.stdout("ok\n");
expect(true).to_be_truthy();
});
});
})
.expect("collection should validate");
let exit_code = match observer_host_main("rust", &tests) {
Ok(()) => 0,
Err(error) => {
eprintln!("{error}");
2
}
};
```
## 11. Embedded Host Example
The embedded-host example is in:
- `lib/rust/examples/host_embed_example.rs`
This is the preferred path when the app already owns its CLI and you want:
```text
myapp observe list
myapp observe --target pkg::embedded-smoke --timeout-ms 1000
```
## 12. The Real Observer CLI Flow
The real end-to-end flow is:
1. build the Rust host
2. inspect raw `list`
3. derive inventory
4. write or inspect `tests.obs`
5. run the suite
6. compare hashes and report snapshots
That is what the starters are for.
## 13. The Passing Starter
`lib/rust/starter/` is the passing reference project.
It shows:
- ordinary Rust code under test in `src/lib.rs`
- Rust-authored Observer tests in `src/bin/ledger-observer-host.rs`
- a standalone provider host binary
- `observer.toml`
- `tests.obs`
- checked-in inventory and report snapshots
## 14. The Embedded Starter
`lib/rust/starter-embedded/` is the app-owned CLI companion.
It shows the same provider contract, but with one important difference:
- the built binary is an application first
- the Observer provider path is routed only when the app is invoked as `observe ...`
This is the project-shaped reference for teams that already own their CLI surface and do not want a dedicated provider-host binary.
## 15. The Embedded Failing Starter
`lib/rust/starter-embedded-failure/` is the failing companion for the app-owned CLI path.
It keeps the same `observe` routing model as `starter-embedded/`, but adds one intentionally wrong exported test so the failing path is as obvious as the passing path.
## 16. The Failing Starter
`lib/rust/starter-failure/` is the failing companion.
It keeps the same real provider flow but adds one intentionally wrong exported test:
- `ledger/broken-running-total`
That makes the whole chain easier to understand because you can compare the passing and failing starters side by side.
## 17. `observer.toml` For Rust Providers
The starter config shape is:
```toml
version = "0"
[providers.rust]
command = "./build/target/debug/ledger-observer-host"
cwd = "."
inherit_env = false
```
That tells Observer exactly which host binary to invoke.
The embedded starter uses the same config model, but adds provider args so Observer calls the app through its routed command namespace:
```toml
version = "0"
[providers.rust]
command = "./build/target/debug/ledger-app"
args = ["observe"]
cwd = "."
inherit_env = false
```
## 18. Writing `tests.obs` For Rust Providers
The starters use the simple suite surface.
Representative shape:
```observer
test prefix: "ledger/" timeoutMs: 1000: expect exit = 0.
test "ledger/rejects-overdraft" timeoutMs: 1000: [
expect exit = 0.
expect out contains "denied overdraft".
].
```
The suite talks only about canonical inventory names, not Rust module names or function symbols.
## 19. What A Raw `list` Response Looks Like
From `lib/rust/starter/`, `make list` yields a provider response shaped like:
```json
{"provider":"rust","tests":[
{"name":"format/renders-balance-line","target":"format/renders-balance-line"},
{"name":"ledger/applies-ordered-postings","target":"ledger/applies-ordered-postings"},
{"name":"ledger/rejects-overdraft","target":"ledger/rejects-overdraft"}
]}
```
Observer then lowers that into inventory lines shaped like:
```text
#format/renders-balance-line provider: "rust" target: "format/renders-balance-line"
#ledger/applies-ordered-postings provider: "rust" target: "ledger/applies-ordered-postings"
#ledger/rejects-overdraft provider: "rust" target: "ledger/rejects-overdraft"
```
## 20. What A Raw `run` Response Looks Like
A passing target looks like this shape:
```json
{"provider":"rust","target":"ledger/rejects-overdraft","exit":0,"out_b64":"ZGVuaWVkIG92ZXJkcmFmdAo=","err_b64":""}
```
A failing target looks like this shape:
```json
{"provider":"rust","target":"ledger/broken-running-total","exit":1,"out_b64":"","err_b64":"Li4u"}
```
That means:
- the provider call itself succeeded structurally
- the target ran
- the test outcome failed
This distinction matters when the suite later says `expect exit = 0`.
### 20.1 Passing Walkthrough: Raw Host To Suite Verdict
Use `lib/rust/starter/` for this walkthrough.
The flow is:
1. build the host
2. inspect `list`
3. derive inventory
4. run one target directly
5. run the suite
At the raw host boundary, `make list` gives you the published canonical names and targets.
Observer then derives inventory entries for those names.
Now the suite talks only about those canonical names.
For example, this suite item:
```observer
test "ledger/rejects-overdraft" timeoutMs: 1000: [
expect exit = 0.
expect out contains "denied overdraft".
].
```
drives Observer to:
1. resolve `ledger/rejects-overdraft` in inventory
2. call the provider host with `run --target ledger/rejects-overdraft --timeout-ms 1000`
3. decode the returned `out_b64`
4. assert over the canonical run result
### 20.2 Failing Walkthrough: Raw Host Failure To Suite Failure
Use `lib/rust/starter-failure/` for this walkthrough.
The key target is:
```text
ledger/broken-running-total
```
At the host boundary, a direct run returns a normal structured response with a failing exit code.
That means:
- the provider host did its job
- the target really ran
- the Rust test itself failed
When Observer later runs the suite, it records a normal `run` action for that case with `exit = 1`.
The suite then fails because its assertion contract says `expect exit = 0`.
## 21. How To Start A New Rust Integration
1. Write ordinary Rust code under test.
2. Add authored tests using `describe!(...)`, `test!(...)`, and `expect(...)`.
3. Decide whether derived identity is enough or whether you need explicit `id`.
4. Call `collect_tests(...)` and validate the resulting set.
5. Expose a standalone host or embedded `observe` subcommand.
6. Confirm raw provider behavior with `list` and direct target runs.
7. Add `observer.toml`.
8. Derive inventory.
9. Write suites against canonical inventory names.
10. Freeze hashes and report snapshots.
## 22. Common Mistakes
### Mistake 1: treating macros as discovery magic
The macros are only the authored surface.
The contract is the lowered explicit registration set.
### Mistake 2: treating Rust symbols as the external contract
Observer runs canonical published names and targets.
It does not care what your internal function names were.
### Mistake 3: skipping the raw host check
If you have not checked raw `list` and raw direct target execution, you are debugging too high in the stack.
### Mistake 4: using derived identity when you really need a stable external id
If renaming suite labels or titles should not change the published contract, use explicit `id`.
### Mistake 5: hiding the provider path inside app-specific CLI behavior
Make the `observe` routing point explicit if the application owns the outer CLI.
### Mistake 6: not snapshotting the failing path
A failing example is not second-class.
That is why `starter-failure/` exists.
### Troubleshooting Checklist
If the integration is not working, check the layers in this order.
1. Collection: confirm `collect_tests(...)` succeeds and the host binary builds.
2. Raw host list: run `./build/target/debug/ledger-observer-host list` and confirm tests appear in sorted order.
3. Raw host run: run `./build/target/debug/ledger-observer-host observe --target <target> --timeout-ms 1000` and confirm you get valid JSON.
4. Inventory derivation: run `observer derive-inventory --config observer.toml --provider rust > tests.inv` and inspect the resulting names.
5. Suite contract: confirm `tests.obs` refers to canonical inventory names, not Rust module or function names you remember informally.
6. Failure category: decide whether the problem is host failure, target failure, or suite assertion failure before changing code.
7. Snapshot drift: regenerate inventory hash, suite hash, and report snapshots only after the behavior is understood and accepted.
## 23. Quick Reference
### Authoring forms
- `collect_tests(...)`
- `describe!(...)`
- `test!(...)`
- `it!(...)`
- `expect(...)`
### Observation helpers
- `ctx.observe().metric(...)`
- `ctx.observe().vector(...)`
- `ctx.observe().tag(...)`
### Host helpers
- `observer_host_main(...)`
- `observer_host_main_from(...)`
- `observer_host_dispatch(...)`
- `observer_host_dispatch_embedded(...)`
- `observer_host_handles_command(...)`
## 24. What To Read Next
- `lib/rust/README.md`
- `lib/rust/starter/README.md`
- `lib/rust/starter-embedded/README.md`
- `lib/rust/starter-embedded-failure/README.md`
- `lib/rust/starter-failure/README.md`
- `specs/12-rust-provider-determinism.md`
- `specs/13-provider-authoring.md`