See DESIGN.md for the architectural overview and README.md for the workspace layout.
- Rust toolchain — pinned by
rust-toolchain.toml; rustup installs it automatically on the firstcargoinvocation. This keeps localcargo fmt/cargo clippyoutput identical to the CI lint job (rustfmt formatting drifts across releases, so an unpinned toolchain can disagree with CI). Bump the pin together with thelint-and-testtoolchain in.github/workflows/ci.yml. - protoc v27+ — the test suite includes editions-syntax protos (
edition = "2023"). Theeditions_2024.prototest requires protoc v32+ (the first release that accepts edition 2024 without--experimental_editions) — it's skipped with a cargo warning on older versions. Ubuntu's aptprotobuf-compiler(v21.12) is too old. Runtask install-protocto download the CI-matched version into.local/(requiresghauthenticated), then usetask test-local.
CHANGELOG.md is a generated file — do not edit it directly. Each user-visible change adds a fragment file under .changes/unreleased/ instead, so concurrent PRs never conflict on the changelog. Create one with:
task changelog-newThis prompts for a kind (Breaking changes, Added, Changed, Deprecated, Removed, Fixed, Security) and a multi-line body, then writes .changes/unreleased/<kind>-<timestamp>.yaml. Commit that file with your change. The body is the full Keep a Changelog bullet text (the bullet marker and two-space hanging indent are added at batch time, so write the body flush-left). Write it as flowing prose — one line per paragraph, no hard line-wraps. Hard-wrapped text has to be re-flowed by hand on every later edit and produces noisy diffs; the renderer wraps it for you, and MD013 is off so there is no line-length limit to satisfy.
Non-interactive (the prompt requires a TTY, so use this form from scripts and agent sessions; -b accepts a multi-line body):
task changelog-new -- -k Fixed -b '**Short description of the fix** (#NNN).
Further detail goes on its own line, however long — the bullet and its hanging indent are added at batch time.'At release time, task changelog-batch -- <version> rolls the fragments into .changes/<version>.md and task changelog-merge regenerates CHANGELOG.md. CI verifies that CHANGELOG.md matches changie merge output, so a direct edit will fail the check-changelog job.
A new workspace crate is published to crates.io unless its Cargo.toml sets publish = false. If it should be released, add it to .github/publish-order.txt after the crates it depends on — that file is the single source of truth for what the release publishes and in what order.
task check-publish-coverage verifies that the list matches the workspace and that each crate is listed after the workspace crates it depends on. CI runs the same check on every pull request, so the PR that introduces the problem fails: a missing entry would otherwise be skipped silently at release time, and a misplaced one would fail the publish part-way through. (buffa 0.9.0 shipped with buffa-remote-derive announced in the changelog but absent from crates.io, because nothing compared the list to the workspace.)
Note that the first publish of any new crate cannot use Trusted Publishing — crates.io cannot configure it for a crate that does not exist yet — so it needs a one-time API token, after which trusted publishing is configured and later releases run over OIDC.
Keep each change to ≤ 250 lines net (additions minus deletions, excluding test files) wherever possible. If a task naturally exceeds that, split it into focused, self-contained PRs or commits.
Every change must include unit tests. Target ≥ 80% line coverage for new code. Reaching 100% is not required when the remaining paths would require artificial or contrived tests, but coverage gaps must be intentional and justified. Tests live in #[cfg(test)] modules colocated with the code they test; integration tests go under tests/.
- Run
task lintandtask testbefore every commit (ortask verifyto also run the conformance suite). - Prefer
thiserrorfor library error types; avoid.unwrap()in non-test, non-provably-safe code. - Every
unsafeblock requires a// SAFETY:comment explaining the invariant. - Public API items require doc comments (
///); include# Errors/# Panics/# Safetysections where applicable. - Keep dependencies minimal; use Cargo feature flags to avoid pulling in unnecessary transitive deps.
All code generation uses quote! blocks rather than string manipulation. prettyplease formats the final TokenStream into readable Rust source.
Key rules:
- Regular
//comments are not tokens and are silently dropped byquote!. Only usequote!for code structure. Inject comments as raw strings before or after the formatted output (e.g. the// @generatedfile header inlib.rs). - Doc comments (
///) survive becausequote!treats them as#[doc = "..."]attributes. Uselet doc = format!("..."); quote! { #[doc = #doc] ... }when the content is dynamic. - Identifiers must go through
format_ident!(orIdent::new_rawfor Rust keywords). Never interpolate raw strings as identifiers. - Type paths from the descriptor context (e.g.
"my_pkg::MyMessage") must be parsed withsyn::parse_str::<syn::Type>before interpolation intoquote!. - The pipeline in
lib.rsis: accumulateTokenStream→syn::parse2::<syn::File>→prettyplease::unparse→ prepend file-header string. no_std-safe type paths: Generated code must compile in bothstdandno_stdcontexts.String,Vec, andBoxare not in theno_stdprelude (even withextern crate alloc) — they must always be emitted as::buffa::alloc::string::String,::buffa::alloc::vec::Vec,::buffa::alloc::boxed::Box. Use theImportResolvermethods (resolver.string(),resolver.vec(),resolver.boxed()) which handle this. Onlycoreprelude types (Option,Result,Default, etc.) can be emitted as bare names. Seeimports.rsfor details.- Re-exports for codegen:
buffare-exportsalloc,bytes, and (under thejsonfeature)serde_jsonas#[doc(hidden)]items so generated code can reference::buffa::alloc::*,::buffa::bytes::*, and::buffa::serde_json::*without the consumer crate declaring those deps. These re-exports are load-bearing for every downstream crate's build — do not remove them or change their visibility.serdeis the deliberate exception: the#[derive(::serde::Serialize)]macro emitsextern crate serde as _serde;, so consumers ofjson=truecodegen must depend onserdedirectly regardless.
The protobuf conformance suite runs via Docker. Use task conformance (or task verify to run lint + tests + conformance). It requires Docker and uses the pre-built tools image ghcr.io/anthropics/buffa/tools:v33.5 which bundles conformance_test_runner (from protobuf v33.5) and the test .proto files.
If the tools image pull fails (403 Forbidden from GHCR), build it locally first:
task tools-image-local # builds for local platform only, ~5 min
task conformance # now uses the locally-built imageWithout Docker: the suite can also run natively. conformance_test_runner
talks to the testee over stdin/stdout pipes, so no container plumbing is
needed — the runner just has to be built from protobuf source once:
task conformance-tools-local # one-time: cmake-builds the runner into .local/bin,
# populates conformance/protos/ (~10-20 min)
task conformance-local # eight runs, same failure lists as the Docker pathRequires cmake, a C++ toolchain, and protoc v30+ on PATH or $PROTOC
(task install-protoc). Set CONFORMANCE_OUT=<dir> to tee per-run logs.
Understanding the output: The conformance runner executes eight runs (std, no_std, via-view, via-lazy, view-json, via-reflect, via-vtable, via-table), each producing two suites:
- Binary + JSON suite — expects thousands of successes (~5500 std and no_std; 5,549 table). The via-view and via-lazy runs only handle binary→binary (~2800); the view-json, via-reflect, and via-vtable runs handle binary→JSON (and via-reflect also binary→binary).
- Text format suite — 883 successes for std, no_std and table (the full suite); via-view, via-lazy, view-json, via-reflect, and via-vtable show
0 successes, 883 skipped(those modes have noTextFormatpath).
So a healthy run shows 16 CONFORMANCE SUITE PASSED lines.
The Dockerfile builds three binaries: one with default features (std), one with --no-default-features (no_std) and one with --features table (table). The std binary is reused for the view/reflect runs by setting an env var:
- via-view (
BUFFA_VIA_VIEW=1) — binary input throughdecode_view → to_owned_message → encode, verifying owned/view decoder parity. - via-lazy (
BUFFA_VIA_LAZY=1) — binary input throughdecode_lazy → to_owned_message → encodeon the lazy view family (lazy_views(true)), verifying the lazy decoder (record arms, fragment merge, budget capture) against the corpus. - view-json (
BUFFA_VIEW_JSON=1) — binary→JSON throughdecode_view → serde_json::to_string(&view), verifying the generated viewSerializeimpls (and the hand-written WKT viewSerializeimpls inbuffa-types). - via-reflect (
BUFFA_VIA_REFLECT=1) — binary/JSON I/O throughDynamicMessage's descriptor-driven codec and reflective serde, verifying the runtime reflection codec independently of any generated type. - via-vtable (
BUFFA_VIA_VTABLE=1) — binary→JSON: decode the view, walk its vtableReflectMessagesurface to rebuild aDynamicMessage, then serialize to JSON. Verifies the generatedimpl ReflectMessage for FooView. It reusesDynamicMessage's JSON serializer (which passes the corpus cleanly under via-reflect), so any failure isolates a bug in the vtableget/has/for_each_setsurface. Requires the conformance crate'sreflectfeature, so it is absent from the no_std binary.
via-table is a separate binary, built with --features table (no env var), that runs the whole std run — binary, JSON and text — against test messages generated with CodecStrategy::Table. It exercises the table codec's decode, size and encode paths, oneofs, maps and the bridge to well-known-type children on the proto3, editions and nested proto2 messages. The messages the table cannot handle run through the unrolled codec instead: messages with extension ranges under JSON, MessageSet messages, group types and the messages that hold them. That includes the top-level TestAllTypesProto2, which via-table therefore does not exercise on the table. buffa-build prints a cargo:warning naming the messages that fell back, and a compile-time check in conformance/src/main.rs fails the build if the messages that must use the table stop doing so. The table feature needs Rust 1.77; conformance/Cargo.toml says why.
Expected failures are listed in conformance/known_failures.txt (std binary+JSON), conformance/known_failures_nostd.txt (no_std binary+JSON), conformance/known_failures_view.txt (via-view), conformance/known_failures_lazy.txt (via-lazy), conformance/known_failures_view_json.txt (view-json), conformance/known_failures_reflect.txt (via-reflect), conformance/known_failures_view_vtable.txt (via-vtable), conformance/known_failures_table.txt (via-table; currently empty), and conformance/known_failures_text.txt (text format — shared between std, no_std and table; currently empty). The text list is passed via --text_format_failure_list since the runner validates each suite's list independently. When a previously-failing test starts passing, remove it from the relevant file; when a new test is expected to fail, add it.
Capturing output: To save per-run logs for analysis, mount a directory and set CONFORMANCE_OUT:
docker run --rm -v /tmp/conf:/out -e CONFORMANCE_OUT=/out buffa-conformance
# logs: /tmp/conf/conformance-{std,nostd,view,lazy,view-json,reflect,vtable,table}.logUpgrading the protobuf version: bump TOOLS_IMAGE in Taskfile.yml and PROTOC_VERSION in .github/workflows/ci.yml, then:
task tools-image # rebuild and push the multi-arch tools image
task vendor-bootstrap-protos # re-fetch buffa-descriptor/protos/ from the new release tag
task gen-bootstrap-types # regenerate checked-in descriptor types
task gen-option-types # regenerate buffa-proto-options, which embeds descriptor.protoCommit the refreshed buffa-descriptor/protos/, buffa-descriptor/src/generated/, and buffa-proto-options/src/generated/ alongside the version bump.
These sets of generated code are checked into the repo and must be regenerated whenever codegen output changes (e.g. changes to imports.rs, message.rs, oneof.rs, etc.):
-
Bootstrap descriptor types (
buffa-descriptor/src/generated/): Used by codegen itself to parse.protodescriptors. Regenerate withtask gen-bootstrap-types. The source protos are vendored inbuffa-descriptor/protos/(pinned; refresh withtask vendor-bootstrap-protoswhen bumping the protobuf version), so output is independent of your local protoc's bundled includes — only a protoc binary ≥ v27 is needed. Only needs regeneration when a codegen change affects the descriptor types themselves — most changes don't. -
Well-known types (
buffa-types/src/generated/):Timestamp,Duration,Any,Struct/Value,FieldMask,Empty, wrappers, plusApi/Type/Enum/SourceContext. Checked in (rather than generated at build time) so that consumers ofbuffa-typesdon't needprotocor thebuffa-build/buffa-codegentoolchain. Regenerate withtask gen-wkt-types. The WKT.protosources are vendored inbuffa-types/protos/(not read from the protoc installation) so the output is pinned. This is the one most likely to need regeneration — WKTs use views, unknown-field preservation, and thearbitraryderive, so almost any codegen output-format change touches them. If in doubt, run it and checkgit status. -
Custom option types (
buffa-proto-options/src/generated/): the messages and extensions ofbuffa-proto-options/protos/buffa/ext/options.proto. Checked in so thatbuffa-codegencan depend onbuffa-proto-optionswithout first generating it. Regenerate withtask gen-option-types(requires protoc v27 or later on PATH or in$PROTOC;descriptor.protois read frombuffa-descriptor/protos/). Run it after an edit tooptions.proto, after a codegen output-format change, and aftertask vendor-bootstrap-protos. The types are generated with every impl kind (views, JSON, text,arbitrary, reflection), so most output changes reach them, and the reflection impls embeddescriptor.proto. -
Logging example (
examples/logging/src/gen/): Regenerate withtask gen-logging-example(requiresbufon PATH).
The bsr-quickstart example's examples/bsr-quickstart/src/gen/ is the exception: it holds output from the published BSR plugin, so codegen changes do not regenerate it. Once the buf.build/anthropics/buffa plugin for a new release is published, bump the plugin pin in its buf.gen.yaml and the buffa / buffa-types versions in its Cargo.toml together, then run task gen-bsr-quickstart-example.
CI (check-generated-code job) will fail if checked-in generated code is stale.
task check-nostd adds the bare-metal thumbv7em-none-eabihf target on demand (it runs rustup target add itself, which is idempotent), so it needs no separate setup.
For the 32-bit tasks, run task install-targets first to install the additional rustup target:
i686-unknown-linux-gnu— 32-bit x86 Linux (fortask check-32bit/task test-32bit;test-32bitalso needsgcc-multilib)
task install-targets also installs thumbv7em-none-eabihf for convenience; the check-32bit / test-32bit tasks have preconditions that print a clear error if the 32-bit target is missing.
GitHub Actions CI (.github/workflows/ci.yml) runs on every push to main and on all pull requests. Jobs:
- lint-and-test — clippy,
cargo test --workspace, clippy and tests forbuffa-testwith itsarbitraryfeature (task test-arbitrarylocally), and strict rustdoc (task doclocally), on the pinned toolchain - lint-markdown — markdownlint over all
*.md(config:.markdownlint.json) - msrv-check —
cargo check --workspaceon Rust 1.75 (the declaredrust-version), then the table codec's tests on Rust 1.77, the oldest compiler that builds it - check-nostd — no_std (host + bare-metal ARM) and 32-bit compilation checks
- check-generated-code — regenerates the bootstrap descriptor types, the well-known types, and the
buffa-proto-optionsoption types, and fails if any checked-in copy is stale - conformance — builds the tools and conformance Docker images, runs the full protobuf conformance suite
task bench runs the combined benchmark suite for the dev loop — all message shapes in one binary, with criterion comparing against your previous run. task bench-prost / task bench-prost-bytes run the prost equivalents on the same machine for buffa-vs-prost comparison.
For the per-shape performance history (benchmarks/history/), each message is measured in isolation — built with only its own decoder compiled — so that adding or removing a benchmark message cannot perturb another's numbers through the compiler's global inlining decisions. Run one shape isolated with task bench-iso -- <message> (for example task bench-iso -- media_frame); it builds only that message at the reproducible, layout-normalized profile (lto=true, codegen-units=1, plus 64-byte block alignment via -align-all-nofallthru-blocks=6, which removes the build-time code-layout lottery — see benchmarks/history/annotations.md). The available shapes are the per-message features in benchmarks/buffa/Cargo.toml.
Isolation needs one proto file per message, so each shape is also defined under benchmarks/proto/iso/. These mirror the message blocks in bench_messages.proto, which stays the single shape source for every other consumer (prost, the cross-implementation harnesses, dataset generation). task check-iso-protos asserts the two stay field-identical — run it after editing any benchmark message.
The history is reproducible because every release has a committed branch carrying the exact harness used to measure it. When you tag a release vX.Y.Z, create and push historical-benchmark/vX.Y.Z from that tag:
git branch historical-benchmark/vX.Y.Z vX.Y.Z
git push origin historical-benchmark/vX.Y.ZReleases cut from main already carry the isolated harness, so this branch is just a stable pointer to measure from. The pre-isolation back-catalogue (v0.1.0–v0.7.1) has the harness retrofitted onto each of these branches instead. Either way, historical-benchmark/* is the source of truth for rebuilding any cell of the per-shape history — see benchmarks/history/DESIGN.md.