Skip to content

Latest commit

 

History

History
649 lines (429 loc) · 17.5 KB

File metadata and controls

649 lines (429 loc) · 17.5 KB

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:

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:

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:

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.

7. expect(...) And Observation

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

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:

{"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:

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:

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:

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:

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:

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:

{"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"

20. What A Raw run Response Looks Like

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.

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:

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:

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