Status: implemented Implemented in: 2026-03-24 App: prototext
The current test suite for prototext consists of:
prototext-core (47 tests) covering individual formatting
functions, helpers, and schema parsing.prototext/tests/roundtrip.rs (21 tests) covering
enum rendering, NaN encoding, subnormals, and fixture round-trips.The fixture round-trip test (fixture_roundtrip_annotated) reads
pre-committed .pb files from prototext/fixtures/cases/ and verifies
that render_as_text produces a stable output — but it does not
verify that render_as_bytes(render_as_text(wire)) == wire. It tests
text stability, not losslessness.
More critically, the existing fixtures were generated by the Python
protocraft library and committed as binary files. There is no in-repo
tool that can generate new fixtures, extend the suite, or verify that the
fixture wire bytes match a known intent.
In ../../code/prototools (the reference Python implementation), a richer
test infrastructure exists:
protocraft — a Python API for constructing protobuf wire bytes
programmatically, including non-canonical and malformed encodings
(overhanging varints, invalid wire types, mismatched group tags, truncated
fields, out-of-range field numbers, etc.).craft_a.py — 30+ named test cases built with protocraft, covering the
full range of edge cases exercised by prototext.prototext binary directly and verify:
wire → text → wire round-trips are bit-exact (with annotations).This spec describes porting protocraft to Rust and wiring it into a
comprehensive end-to-end test suite within the prototext crate.
protocraft to Rust as a test-only library inside the
prototext crate.craft_a.py — all 30+ named fixtures — as Rust fixture
definitions using the ported protocraft API.render_as_bytes(render_as_text(wire, annotations=true)) == wire
(lossless round-trip with annotations).render_as_text(wire, annotations=false) does not panic
(graceful degradation without annotations).render_as_bytes(render_as_text(wire, annotations=false)) == wire
for canonical fixtures (no anomalies) even without annotations..pb fixture files in prototext/fixtures/cases/ as the
authoritative golden outputs, but derive them from protocraft at test
time rather than committing pre-generated binaries.prototext implementation or its consistency tests
(Python vs Rust comparison) — those require the Python runtime.protoc --decode compatibility tests — out of scope.gen_fixtures regeneration tool (§4) — the committed .pb files are
validated by craft_a_matches_committed_fixtures; a separate generation
tool is not needed.Location: prototext/src/protocraft/ (compiled only under #[cfg(test)]
or as a [dev-dependency] module).
Alternatively, if the module grows large: a separate workspace crate
protocraft/ with publish = false.
/// A message builder accumulating wire bytes.
pub struct Message { ... }
/// An integer value with optional overhanging bytes.
pub struct Integer {
pub value: u64,
pub ohb: u8, // overhanging bytes (0 = canonical)
}
/// A wire tag with explicit field number, wire type, and optional ohb.
pub struct Tag {
pub field: u64,
pub wire_type: u8,
pub ohb: u8,
}
The Rust API mirrors the Python API structurally. Each builder function encodes a field into the current message's buffer.
impl Message {
/// Create a new root message.
pub fn new() -> Self;
/// Encode an int32 field (wire type 0, zigzag-free).
pub fn int32(&mut self, field: impl IntoTag, value: impl IntoInteger);
pub fn int64(&mut self, field: impl IntoTag, value: impl IntoInteger);
pub fn uint32(&mut self, field: impl IntoTag, value: impl IntoInteger);
pub fn uint64(&mut self, field: impl IntoTag, value: impl IntoInteger);
pub fn sint32(&mut self, field: impl IntoTag, value: i32);
pub fn sint64(&mut self, field: impl IntoTag, value: i64);
pub fn bool_(&mut self, field: impl IntoTag, value: impl IntoInteger);
pub fn enum_(&mut self, field: impl IntoTag, value: impl IntoInteger);
pub fn fixed32(&mut self, field: impl IntoTag, value: u32);
pub fn fixed64(&mut self, field: impl IntoTag, value: u64);
pub fn sfixed32(&mut self, field: impl IntoTag, value: i32);
pub fn sfixed64(&mut self, field: impl IntoTag, value: i64);
pub fn float_(&mut self, field: impl IntoTag, value: f32);
pub fn double_(&mut self, field: impl IntoTag, value: f64);
pub fn bytes_(&mut self, field: impl IntoTag, value: &[u8]);
pub fn string(&mut self, field: impl IntoTag, value: &str);
/// Append a nested message (length-delimited).
pub fn message(&mut self, field: impl IntoTag, nested: Message);
/// Append a group (start tag + contents + end tag).
pub fn group(&mut self, field: impl IntoTag, end_field: impl IntoTag, nested: Message);
/// Append raw bytes verbatim (no tag, no length prefix).
pub fn raw(&mut self, data: &[u8]);
/// Append an arbitrary field with a custom wire tag and raw value bytes.
pub fn custom(&mut self, tag_bytes: &[u8]);
/// Return the accumulated wire bytes.
pub fn build(self) -> Vec<u8>;
}
IntoTag is implemented for:
u64 — field number, wire type inferred from context (caller uses the
typed method which knows the wire type).Tag — explicit field number + wire type + ohb.IntoInteger is implemented for:
u64, i64, u32, i32 — canonical encoding.Integer — value + overhanging bytes./// Encode a varint with `ohb` extra continuation bytes appended.
/// ohb=0 produces canonical minimal encoding.
fn encode_varint_ohb(value: u64, ohb: u8) -> Vec<u8>;
Example: encode_varint_ohb(42, 3) →
[0xaa, 0x80, 0x80, 0x00] (4 bytes instead of canonical 1).
/// Encode a wire tag: (field_number << 3) | wire_type, as a varint with
/// optional overhanging bytes.
fn encode_tag(field: u64, wire_type: u8, ohb: u8) -> Vec<u8>;
craft_a.rs)Location: prototext/src/protocraft/craft_a.rs
A Rust port of all named fixtures from craft_a.py. Each fixture is a
fn returning Vec<u8>:
pub fn test_empty() -> Vec<u8> { Message::new().build() }
pub fn test_field_invalid() -> Vec<u8> {
let mut m = Message::new();
let mut inner = Message::new();
inner.custom(b"\x07"); // invalid wire type
m.message(/* messageRp */ 11, inner);
// ... etc.
m.build()
}
pub fn test_n_overhanging_bytes() -> Vec<u8> {
let mut m = Message::new();
let mut inner = Message::new();
inner.uint64(Tag { field: 1, wire_type: 0, ohb: 17 }, 0u64);
inner.uint64(Tag { field: /* uint64Rp */ 74, wire_type: 0, ohb: 2 }, 0u64);
m.message(11, inner);
m.build()
}
The complete list of fixtures to port (from craft_a.py):
test_emptytest_field_invalidtest_garbagetest_n_overhanging_bytestest_n_out_of_rangetest_wire_leveltest_proto2_leveltest_proto2_primitive_typestest_varint_optionaltest_varint_requiredtest_varint_repeatedtest_varint_packedtest_varint_overhanging_bytestest_bool_out_of_rangetest_neg_int32_truncatedtest_fixed64test_tititest_2, test_2b, test_2ctest_3test_doubly_nestedtest_message_no_schematest_group_no_schematest_group_with_overhangtest_open_ended_grouptest_groups_messages_known, test_groups_messages_unknown, test_groups_messages_mismatchtest_records_overhung_counttest_FIELD_INVALID, test_FIELD_INVALID_LENGTHtest_INVALID_FIXED32, test_INVALID_FIXED64test_INVALID_GROUP_ENDtest_INVALID_PACKED_RECORDStest_INVALID_STRINGtest_VARINT_INVALIDtest_wire_levelcanonical_empty, canonical_single, canonical_scalars, canonical_stringscanonical_repeated, canonical_nested, canonical_groups, canonical_mixedcanonical_unknownValidation: Each craft_a function output must match the corresponding
committed .pb file in prototext/fixtures/cases/. This is verified by a
dedicated test:
#[test]
fn craft_a_matches_committed_fixtures() {
for (name, func) in ALL_FIXTURES {
let generated = func();
let committed = load_case_bytes(name).expect("fixture file missing");
assert_eq!(generated, committed,
"craft_a::{name} output does not match fixtures/cases/{name}.pb");
}
}
This test acts as a contract: if craft_a diverges from the committed files,
the test fails and the developer must either fix craft_a or regenerate the
committed fixture.
Location: prototext/tests/roundtrip.rs (extend existing file) or a new
prototext/tests/e2e.rs.
For every fixture name in ALL_FIXTURES:
let wire = craft_a::name();
let schema = knife_schema();
let text = render_as_text(&wire, Some(&schema), opts(true)).unwrap();
let wire2 = render_as_bytes(&text, opts(true)).unwrap();
assert_eq!(wire2, wire, "{name}: round-trip with annotations must be bit-exact");
For every fixture:
let wire = craft_a::name();
let schema = knife_schema();
// Must not panic or return Err
let _ = render_as_text(&wire, Some(&schema), opts(false)).unwrap();
For canonical fixtures (name.starts_with("canonical_")):
let wire = craft_a::name();
let schema = knife_schema();
let text = render_as_text(&wire, Some(&schema), opts(false)).unwrap();
let wire2 = render_as_bytes(&text, opts(false)).unwrap();
assert_eq!(wire2, wire,
"{name}: canonical fixture must round-trip without annotations");
A binary target or test helper gen_fixtures that:
craft_a::* function.prototext/fixtures/cases/<name>.pb.This replaces the Python scripts/gen_prototext_fixtures.py script. It is
invoked manually when the fixture definitions change, not as part of the
normal test run.
The craft_a fixtures reference SwissArmyKnife fields by name in the
Python version. In the Rust port, field numbers are used directly (no
schema-aware name resolution in protocraft — that is a Python-only feature
since Rust has no runtime descriptor introspection in this context).
The knife.proto field number mapping must be documented in a comment at
the top of craft_a.rs for reference.
Message, Integer, Tag,
encode_varint_ohb, encode_tag).canonical_* fixtures first (simplest — no anomalies).craft_a_matches_committed_fixtures test; verify canonical fixtures
match committed .pb files.test_* fixtures incrementally, verifying each against
the committed .pb file.gen_fixtures regeneration helper.prototext/fixtures/cases/ — committed fixture binary filesprototext/fixtures/index.toml — fixture manifestprototext/fixtures/schemas/knife.proto — SwissArmyKnife schemaprototext/build.rs — compiles knife.proto to $OUT_DIR/knife.pb../../code/prototools/src/protocraft/builders.py — Python protocraft source../../code/prototools/src/protocraft/craft_a.py — Python fixture definitions../../code/prototools/tests/prototext/test_fixture_roundtrip_rust.py — Python e2e test reference