Postguy
Contributing

Adding a feature

The path from engine API to automation tool, UI and parity entry, and how to test, fuzz and review it.

A feature is done when it works end to end and every layer has its proof. The order is fixed:

  1. Engine API.
  2. Automation tool with an end-to-end test.
  3. UI.
  4. Parity entry with evidence.
  5. Documentation page update.
This page is the short form of AGENTS.md sections 3, 4 and 5. Read Architecture first. Frontends reach the engine only through crates/engine, and nothing below L7 may depend on egui.

1. Engine API

  • Put the behaviour in the engine, or in the crate below it that owns the concern (http, grpc, ws, interop, script, store, and so on).
  • Return errors through the crate's error type and ?.
  • The UI and the automation tool must share this one implementation.

2. Automation tool

Every user-facing feature must be callable without the GUI. Tools live in crates/automation/src/tools.rs.

  • Add a ToolDef to the table returned by tools(). It has name (snake_case), title, description, input_schema and the read_only and destructive flags. Declare required and optional arguments with the req(...) and opt(...) helpers in the schema! macro.
  • Add the matching arm to dispatch(...). Unknown tools and unknown arguments are refused before any state changes.
  • Errors must say what was wrong and what is allowed.
  • Send every path argument through the session, so --root confines it. A path outside the root gets the same refusal whether or not it exists, including paths reached by nested dispatch.
  • Write atomically. Never discard unsaved work silently. Cap anything echoed to an agent (bodies are capped at 1 MiB).
  • Add an end-to-end test in crates/automation/tests/ (see tools.rs and engine_tools.rs there). The CLI side is exercised in apps/postguy-cli/tests/.
  • Check it yourself with postguy-cli:
cargo run -p postguy-cli -- tools
cargo run -p postguy-cli -- run --script steps.json --root DIR
AGENTS.md and the automation README refer to an "Adding a tool" section. That README currently has no such heading. This page and the existing tests are the reference. Tool tables for users are in Automation tools.

3. UI

Implement the interface in crates/ui-egui. These rules apply to every UI change:

  • Never add a repaint loop.
  • Never run I/O, parsing, formatting, searching or scripts on the UI thread.
  • Use Lucide icons from assets/icons/ and Poppins only (see Assets and clean-room).
  • Check undo/redo and dirty tracking, save fidelity and session restore.
  • Look at the result:
    • Launch postguy --control FILE. Use postguy-cli ui --control FILE screenshot --out x.png (plus inspect, command and set).
    • Or take a headless shot with cargo run -p postguy-ui-egui --example shot.

    The control channel refuses click, key and type today. See UI control channel and Known gaps.

4. Parity entry

Every user-facing feature has a [[feature]] entry in parity/postman-features.toml.

FieldMeaning
id, title, area, tierIdentity. Areas are A collections, B environments-and-variables, C http, D grpc, E websocket, F automation, G imports-and-exports, H ui-shell, I platform-packaging. Tiers are P0 to P3 and N/A.
statusplanned, partial, shipped or na.
notesWhat is missing. Mandatory honesty for partial. A reason for na.
commandsRegistry ids that must exist in crates/engine/src.
toolsTool names that must exist in crates/automation/src.
evidencePassing tests as path/to/file.rs::test_name.
  • shipped requires cited tests whose paths and names exist.
  • planned and na entries must not cite evidence.

Run the check:

cargo xtask parity            # validate and report
cargo xtask parity --partial  # list partial entries

"Shipped" means it exists and is tested, not that it is "as good as another tool". Say in notes what is still missing. Also make sure no other entry just became untrue.

The same facts show to users on Feature status.

5. Documentation

Update the matching page under docs/content/ in the same pull request as any user-facing change. See Writing the documentation.

Testing

Review every change at four zooms. Do it while you make the change, not at the end.

ZoomCheck
ClauseChecked arithmetic, get() instead of [i], char-boundary slicing, the right error through ?, < against <=, 0-based against 1-based seams, capped allocations.
FunctionIs it total? Name an input that panics. Walk empty, zero, max, malformed and cyclic inputs. Is the return type honest?
File or moduleTwo sources of truth, dead or duplicated code, smallest public surface, doc comments still true.
ProjectLayering, undo/redo and dirty tracking, save fidelity, session restore, --root confinement, a matching tool, a parity note that just became untrue.

Tests mirror the zooms: property, unit, crate integration, end to end. Scale the zoom to the change.

Prove a test fails before the fix by actually reverting the fix. A test nobody has seen fail is not evidence.

Hermetic tests

Tests never touch the internet. Use the loopback servers in crates/testkit (a dev-dependency, never shipped): HTTP, TLS, an OAuth token endpoint, gRPC with our own echo.proto, WebSocket and a proxy.

Fuzzing

cargo xtask fuzz --time 300

fuzz mutates committed seed files under fuzz/seeds/. It runs these targets in child processes through postguy-cli fuzz-target: url, vars, curl, data, openapi, har and script.

  • A panic, a fatal signal or a hang is a finding.
  • Findings are saved under fuzz-out/findings/, which is git-ignored.

Optional flags: --time SECONDS, and --seed N for a reproducible run. CI runs it nightly with --time 900.

To turn a finding into a regression test:

  1. Read the saved input and the why in the finding's info file.
  2. Reduce the input to a small synthetic sample. Never commit corpus-derived files.
  3. Write a test that feeds it to the failing function and asserts a clean Err or a handled result.
  4. Run the test and watch it fail (panic) before the fix.
  5. Fix, run it again, then commit the fix and the test together.

Never-crash rules

A malformed file, a hostile response, a bad tool argument, a corrupt settings file or a full disk must produce an error the user can act on. It must never crash and never lose work. This outranks feature work.

  • Non-test code uses Result. No unwrap(), expect(), panic!, unreachable!, todo! or unimplemented! unless provably infallible, with a comment saying why.
  • No unsafe. The workspace sets unsafe_code = "forbid".
  • Treat every input-derived number as hostile. Use get(), checked or saturating arithmetic, and cap allocations sized by the input.
  • Bound recursion with seen-sets or depth limits (JSON, $ref, proto messages, variable references, redirects).
  • Entry points that run untrusted input (scripts, importers, commands, file import and export) keep a last-resort catch_unwind guard. Keep panic = "unwind". Never set panic = "abort".
  • A clean crate carries #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]. New crates start with it.
  • Every crash fix comes with a small synthetic regression test that panicked before the fix.
Copyright © 2026