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
listandrun - 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.
If you want the fastest path to the Rust integration model, start in lib/rust/starter/ and run:
make list
make inventory
cat tests.inv
make host-run TARGET='ledger/rejects-overdraft'
make verifyThat 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 the standalone host path when:
- you want a dedicated provider binary
- the application does not already own a CLI surface
./host listand./host observe ...are acceptable developer entrypoints
Choose the embedded path when:
- the application already owns
main() - you want
myapp observe listandmyapp 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.
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.
There are three layers:
- your Rust code under test
- your Rust-authored Observer tests
- the Observer provider host boundary
The normal flow is:
- write Rust functions
- write
describe!(...),test!(...), orit!(...)registrations - call
collect_tests(...) - let the library lower those authored tests into explicit registrations
- run the host with
listto expose canonical test names and targets - let
observer derive-inventorylower that list intotests.inv - 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/andlib/rust/starter-failure/
This library is intentionally human-first, but the determinism gate still wins.
That means:
- authored tests may use
describe!(...),test!(...), andexpect(...) - 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
There are four practical ways to use this library.
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.
This is the canonical provider-host path.
Representative file:
lib/rust/examples/host_example.rs
This mode exposes:
listrun --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
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
observecommand
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
listoutput - 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.
The intended authored surface is:
describe!("ledger", {
test!("rejects overdraft", |ctx| {
expect(true).to_be_truthy();
ctx.stdout("denied overdraft\n");
});
});The common path is:
describe!(...)for groupingtest!(...)orit!(...)for human titlesexpect(...)for assertionsctx.observe()for bounded observation
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:
describe!("ledger", {
test!("rejects overdraft", |_ctx| {});
});resolves to the canonical inventory name:
ledger :: rejects overdraft
If you want a refactor-stable external identity, give the test an explicit id:
test!("rejects overdraft", id = "ledger/rejects-overdraft", |_ctx| {});In this first cut, the resolved identity is used for both:
- canonical inventory name
- provider execution target
The runnable starters use explicit ids deliberately so the end-to-end contract is mechanically obvious.
expect(...) is for authored behavior assertions.
Representative forms currently include:
to_be(...)to_equal(...)to_contain(...)to_match(...)to_be_truthy()to_be_falsy().not()inversion before the final matcher call
Observation is explicit and bounded:
let mut observe = ctx.observe();
assert!(observe.metric("wall_time_ns", 104233.0));
assert!(observe.vector("request_latency_ns", &[1000.0, 1100.0, 980.0]));
assert!(observe.tag("resource_path", "fixtures/config.json"));Observation does not change:
- canonical identity
- inventory bytes
- suite hash
- report semantics other than adding observational records
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.
The Rust library owns the standard provider host transport too.
list emits one JSON object containing:
- provider id
- sorted tests
- canonical names and targets
Representative shape:
{"provider":"rust","tests":[{"name":"ledger/rejects-overdraft","target":"ledger/rejects-overdraft"}]}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.
The direct-host example is in:
lib/rust/examples/host_example.rs
Its shape is intentionally small:
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
}
};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:
myapp observe list
myapp observe --target pkg::embedded-smoke --timeout-ms 1000
The real end-to-end flow is:
- build the Rust host
- inspect raw
list - derive inventory
- write or inspect
tests.obs - run the suite
- compare hashes and report snapshots
That is what the starters are for.
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.tomltests.obs- checked-in inventory and report snapshots
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.
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.
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.
The starter config shape is:
version = "0"
[providers.rust]
command = "./build/target/debug/ledger-observer-host"
cwd = "."
inherit_env = falseThat 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:
version = "0"
[providers.rust]
command = "./build/target/debug/ledger-app"
args = ["observe"]
cwd = "."
inherit_env = falseThe starters use the simple suite surface.
Representative shape:
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.
From lib/rust/starter/, make list yields a provider response shaped like:
{"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:
#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"
A passing target looks like this shape:
{"provider":"rust","target":"ledger/rejects-overdraft","exit":0,"out_b64":"ZGVuaWVkIG92ZXJkcmFmdAo=","err_b64":""}A failing target looks like this shape:
{"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.
Use lib/rust/starter/ for this walkthrough.
The flow is:
- build the host
- inspect
list - derive inventory
- run one target directly
- 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:
test "ledger/rejects-overdraft" timeoutMs: 1000: [
expect exit = 0.
expect out contains "denied overdraft".
].
drives Observer to:
- resolve
ledger/rejects-overdraftin inventory - call the provider host with
run --target ledger/rejects-overdraft --timeout-ms 1000 - decode the returned
out_b64 - assert over the canonical run result
Use lib/rust/starter-failure/ for this walkthrough.
The key target is:
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.
- Write ordinary Rust code under test.
- Add authored tests using
describe!(...),test!(...), andexpect(...). - Decide whether derived identity is enough or whether you need explicit
id. - Call
collect_tests(...)and validate the resulting set. - Expose a standalone host or embedded
observesubcommand. - Confirm raw provider behavior with
listand direct target runs. - Add
observer.toml. - Derive inventory.
- Write suites against canonical inventory names.
- Freeze hashes and report snapshots.
The macros are only the authored surface.
The contract is the lowered explicit registration set.
Observer runs canonical published names and targets.
It does not care what your internal function names were.
If you have not checked raw list and raw direct target execution, you are debugging too high in the stack.
If renaming suite labels or titles should not change the published contract, use explicit id.
Make the observe routing point explicit if the application owns the outer CLI.
A failing example is not second-class.
That is why starter-failure/ exists.
If the integration is not working, check the layers in this order.
- Collection: confirm
collect_tests(...)succeeds and the host binary builds. - Raw host list: run
./build/target/debug/ledger-observer-host listand confirm tests appear in sorted order. - Raw host run: run
./build/target/debug/ledger-observer-host observe --target <target> --timeout-ms 1000and confirm you get valid JSON. - Inventory derivation: run
observer derive-inventory --config observer.toml --provider rust > tests.invand inspect the resulting names. - Suite contract: confirm
tests.obsrefers to canonical inventory names, not Rust module or function names you remember informally. - Failure category: decide whether the problem is host failure, target failure, or suite assertion failure before changing code.
- Snapshot drift: regenerate inventory hash, suite hash, and report snapshots only after the behavior is understood and accepted.
collect_tests(...)describe!(...)test!(...)it!(...)expect(...)
ctx.observe().metric(...)ctx.observe().vector(...)ctx.observe().tag(...)
observer_host_main(...)observer_host_main_from(...)observer_host_dispatch(...)observer_host_dispatch_embedded(...)observer_host_handles_command(...)
lib/rust/README.mdlib/rust/starter/README.mdlib/rust/starter-embedded/README.mdlib/rust/starter-embedded-failure/README.mdlib/rust/starter-failure/README.mdspecs/12-rust-provider-determinism.mdspecs/13-provider-authoring.md