Rust
truewire generate rust renders a project's plan (docs/plan.md) as the modules of a Rust
crate with the same guarantees as the Python and TypeScript packages: typed requests and
responses, runtime validation with a raw escape hatch, and resumable pagination walkers.
The runtime it depends on is truewire-core (crates/truewire-core), the third after
truewire-core (Python) and @truewire/core (TypeScript). Both were designed from the
plan, the language-neutral IR every backend renders from, rather than by transliterating
either earlier runtime.
Status: the runtime exists and is tested (cargo test, cargo clippy -- -D warnings and
cargo fmt --check run in CI, job runtime-rust). The generator renders rpc endpoints
over HTTP, over a WebSocket and over both, stream endpoints (with a typed subscription
reply), composite cores, the type tree, routers, the root, dispatch.rs, and every
PaginatedResponse walker (page, offset, token, seek), and unary grpc endpoints
over prost stubs (see "gRPC endpoints" below). examples/github (HTTP,
paging) and examples/kraken (REST with HMAC-SHA512 signing, WebSocket commands and
streams over two sockets, a composite root) are generated, compiled and replayed against
truewire mock in CI (job examples-rust), with truewire-testing as their harness.
The crate is not on crates.io (the truewire crate name is reserved by
crates/truewire, and truewire-core will be published beside it). See the end of this
page for what is left.
Using it
# truewire.toml
[rust]
package = "github" # the modules live at src/github/, lib.rs at their top
src = "src"
name = "GitHub" # the root structtruewire generate rust # writes src/github/**/*.rs and .truewire/codegen/rust.json
truewire generate rust --check # CI: every owned file exists and is what the plan rendersThen write a Cargo.toml whose library is the generated crate root, and the core
module the root declares:
[lib]
path = "src/github/lib.rs"
[dependencies]
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
truewire-core = "0.2"use github::core::CoreOptions;
use github::repos::get::Request;
use github::{CallOptions, GitHub};
let client = GitHub::new(CoreOptions::default());
let repo = client.repos.get(Request { owner: "truewire-dev".into(), repo: "truewire".into(), ..Default::default() }, CallOptions::default()).await?;
repo.created_at // a TimestampIso, deref to DateTime<Utc>
let raw = client.repos.get_raw(request, CallOptions::default()).await?; // the serde_json::Value as it came
let commits = client.repos.list_commits_paged(request, CallOptions::default()).await?; // every rowexamples/github is the reference: its truewire.toml, Cargo.toml,
src/github/core/mod.rs and tests/ are what a project copies.
What is generated
One file per spec node, beside the Python and TypeScript packages when all are declared:
| file | contents |
|---|---|
types/mod.rs, types/<scope>.rs |
the shared schemas.json types, one module per scope |
<router>/<endpoint>.rs |
the endpoint's Request, its response types, the enums hoisted out of them, and a struct with the method, its _raw twin and the walker |
<router>/mod.rs |
a router struct delegating to its endpoints and holding its child routers as pub fields |
client.rs |
the root struct, GitHub::from_core(core) |
meta.rs |
one struct per [cores.<name>] with a meta schema (DefaultMeta) |
lib.rs |
the crate root: pub mod for every module above and for the hand-written core, pub use client::GitHub and CallOptions |
Names Truewire invents are snake_case/PascalCase of the function segment
(list_commits, list_commits_paged, struct ListCommits). Rust has one convention per
kind of identifier and rustc warns on every departure, so the wire's names are not kept
verbatim as TypeScript keeps them: a field is snake_case of the wire name and carries
#[serde(rename = "...")] with the wire name whenever the two differ (htmlUrl,
X-Rate, a keyword such as type becoming type_), so the struct still dumps to the
wire object and a recorded request.json decodes into it as it stands. The output is
printed by the generator itself; no formatter runs over it, but the printer reproduces
rustfmt's decisions (import order and packing, struct-literal, attribute and chain
widths, signature breaking) so cargo fmt --check passes on what it writes.
A record is a serde struct with the newtype for every narrowed scalar, an Option per
optional key, and a flattened map so an undocumented field never breaks a client (the
same rule the Python TypedDict and the TypeScript object codec follow):
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Label {
pub id: i64,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(with = "truewire_core::validation::double_option")]
pub description: Option<Option<String>>,
pub created_at: TimestampIso,
/// Keys the spec does not document, kept as they came.
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}Default is derived when every required field has one (a timestamp has no meaningful
zero), so a request is Request { owner, repo, ..Default::default() }. A literal of
strings is an enum with #[serde(rename)]s; a literal of numbers or booleans widens to
its scalar (serde renames only strings) with the values in its doc comment. A union is
an #[serde(untagged)] enum whose variants are tried in order, a list a Vec, a tuple
a Rust tuple, a dict a HashMap<String, _>, a nullable type an Option, a field both
optional and nullable an Option<Option<T>> with double_option. Rust needs a name for
every enum and the plan names only the types it lists, so an inline literal or union is
hoisted into a sibling type named after its position (Issue.state_reason becomes
IssueStateReason; the non-null half of an alias Response = A | B | null becomes
ResponseValue). A field whose type reaches back to its own record is boxed. A scalar
is rendered by base and format: string/integer/number/boolean/any to
String/i64/f64/bool/serde_json::Value, and each format in
truewire_core::types to its newtype. Every newtype derefs to the value inside and
converts From it, so a request built from a real DateTime<Utc> is .into() away from
its field; that is the Rust form of the converters' pass-through of an already-parsed
value, which Python's converters gained in core 0.2.1.
A generated method dumps the request, hands the core a call, and decodes the reply:
pub async fn get(&self, request: Request, options: CallOptions) -> Result<Repository> {
let raw = self.get_raw(request, options).await?;
decode(raw)
}
/// `get` without validation: the wire body as it came.
pub async fn get_raw(&self, request: Request, options: CallOptions) -> Result<serde_json::Value> {
let meta = DefaultMeta { public: Some(true) };
let call = HttpCall {
method: Some("GET"),
path: "/repos/{owner}/{repo}",
request: Some(dump(&request)?),
meta: &meta,
options,
};
self.core.request(call).await
}validate: false is a second method rather than a flag because Rust has no overload
returning a different type: the raw method returns the serde_json::Value the core
returned, and the typed method is decode on top of it. A method whose endpoint returns
nothing has no _raw twin. A request field the spec fixes to one value (a required
single-value enum, wire dispatch plumbing) is left out of Request and inserted into
the dumped object by the method. Every failure in decode is a ValidationError whose
path() is the JSON pointer of the offending value (/bids/1/1) and whose message is
serde's own (invalid type: integer \4`, expected a string at /bids/1/1`), the same
shape the TypeScript codecs report.
The core contract
The generated code never imports the project's core. Each generated struct holds its core
as an Arc<dyn HttpEndpoint<Meta>> (or CommandEndpoint/StreamEndpoint, or GrpcEndpoint for a gRPC endpoint, whose contract carries encoded
messages; see "gRPC endpoints"), the Rust half of truewire_core.contract and contract.ts
(ADR 0011):
pub struct HttpCall<'a, Meta> {
pub method: Option<&'a str>, // the wire HTTP method; None when the spec leaves it to the core
pub path: &'a str, // the wire path template; {name} placeholders are filled from request
pub request: Option<Value>, // the dumped request (wire keys, wire forms), or None
pub meta: &'a Meta, // the endpoint's declared meta, in the shape the core's schema states
pub options: CallOptions, // { timeout: Option<Duration> }
}
#[async_trait]
pub trait HttpEndpoint<Meta = ()>: Send + Sync {
async fn request(&self, call: HttpCall<'_, Meta>) -> Result<Value>;
}
#[async_trait]
pub trait CommandEndpoint<Meta = ()>: Send + Sync {
async fn request(&self, call: CommandCall<'_, Meta>) -> Result<Value>; // path is the wire method name
}
#[async_trait]
pub trait StreamEndpoint<Meta = ()>: Send + Sync {
async fn subscribe(&self, call: SubscribeCall<'_, Meta>) -> Result<Stream<Value>>;
}Two decisions differ from the other runtimes, both forced by what the plan carries and what Rust can type:
- The request reaches the core already dumped, and the reply leaves it undecoded. A
core sees
serde_json::Values on both sides: the wire keys and wire forms of the request (aTimestampMillisis already an integer), and the wire body of the reply, envelope unwrapped and errors mapped. Generated code decodes it, or hands it back raw. A core therefore never validates and never reads avalidateflag; that decision is the generated method's, after the call. In Python and TypeScript the core receives the request type or codec and validates itself; in Rust that would make every trait generic in two types the core does not care about, anddyndispatch impossible. - There is no
ClientRootorCompositetrait. The root is a struct the generator writes, and the hand-written code builds it:GitHub::from_core(core). A router whose endpoints all hold the same contract hands a clone of oneArc<dyn HttpEndpoint<Meta>>to every child; one whose subtree needs severalmetashapes is generic in the core,from_core<C>(core: C) where C: HttpEndpoint<DefaultMeta> + HttpEndpoint<FuturesMeta> + 'static, and theArc<C>it wraps coerces to each child's trait object. Nothing needs anew(...)protocol;paramsare what the hand-written core takes when it is built. - The root's constructor is
from_core, notnew, and it takes the core by value. Both halves are about the call site.newis left free for the hand-written core to define as an inherent impl on the generated type -- the Rust answer to the base class[python.cores.root]names -- so a client readsGitHub::new(CoreOptions::default())rather thanGitHub::new(Arc::new(Core::new(CoreOptions::default()))). And theArcis the generator's storage decision, not the caller's, so the root wraps what it is given;truewire-coreimplements the endpoint traits forArc<T>, so a core already shared between two clients satisfies the same bound and goes through the same door. A composite root takes one parameter per declared field and is namedfrom_cores.
A hand-written HTTP core does four things in request, as examples/github's
src/github/core/mod.rs does: fill the {placeholders} from the request object and send
the rest as the query (or as a JSON body for POST/PUT/PATCH) through HttpClient, add its
headers and signing, map a non-2xx reply to Error::Api with the status and decoded body
(Error::auth(msg).with_status(401).with_body(body); ApiKind::{BadRequest, Auth, RateLimited, Api}), and return response.json()? (or the envelope.payload inside it).
http::query_from renders a dumped request object as query pairs the way the example
cores do (null skipped, a nested value as JSON text). The crate root declares pub mod core; for it, the one module the generator does not write, as the Python package's
[python.cores.<name>].base names the class it does not write.
meta is the generated <package>/meta.rs struct for the core's [cores.<name>].meta
schema (DefaultMeta { public: Option<bool> } in the examples, a property required by the
schema being a plain field), or () for a core with no schema. Its properties map narrowly:
scalars, an enum of one scalar kind to that scalar, arrays of those, and
serde_json::Value for anything else.
gRPC endpoints
A kind: grpc endpoint (ADR 0017) is rendered over the prost messages truewire protos rust builds from spec/proto/. The command runs buf generate with protoc-gen-prost
(prost 0.14) over the stripped tree and writes <package>/protos/: one <proto package>.rs per package, descriptors.binpb (the tree's FileDescriptorSet) and a
mod.rs nesting one module per package segment around each file's include!, the way
prost-build names them, with FILE_DESCRIPTOR_SET. The stubs are checked in like the
generated modules; the crate needs no build.rs and no protoc. truewire protos rust --check compares them with a fresh build.
cargo install protoc-gen-prost --version 0.5.0 --locked # beside buf
truewire protos rust # src/<package>/protos/
truewire generate rust # the endpoints, and `pub mod protos;` in lib.rs[dependencies]
prost = "0.14"
prost-types = "0.14" # when a message uses a well-known type
truewire-core = { version = "0.2", features = ["grpc"] }Each endpoint module aliases its messages and calls one verb on its core:
pub const METHOD: &str = "/cosmos.bank.v1beta1.Query/AllBalances";
pub type Request = crate::protos::cosmos::bank::v1beta1::QueryAllBalancesRequest;
pub type Response = crate::protos::cosmos::bank::v1beta1::QueryAllBalancesResponse;
pub type Row = crate::protos::cosmos::base::v1beta1::Coin;
pub async fn all_balances(&self, request: Request, options: CallOptions) -> Result<Response> {
let call = GrpcCall { method: METHOD, request: request.encode_to_vec(), meta: &(), options };
let reply = self.core.invoke(call).await?;
decode_message("cosmos.bank.v1beta1.QueryAllBalancesResponse", &reply)
}
pub fn all_balances_paged(&self, request: Request, options: CallOptions) -> PaginatedResponse<Row, Vec<u8>>;The contract is the one trait that does not speak Value: GrpcEndpoint<Meta>::invoke(GrpcCall { method, request: Vec<u8>, meta, options }) -> Result<Vec<u8>>. Carrying encoded messages
keeps it object-safe for every message type and free of prost, and the generated method
does the typed half (grpc_codec.rs holds the one decode helper, a ValidationError when a
reply does not decode). truewire_core::grpc::GrpcClient (feature grpc, tonic)
implements it for every Meta, so a gRPC-only client is GrpcDemo::from_core(GrpcClient::new(url)?);
it maps statuses onto the error taxonomy (NOT_FOUND and INVALID_ARGUMENT are bad
requests, UNAVAILABLE a network error, and so on). There is no _raw twin. Walks clone
the request, set the driver path (request.pagination.get_or_insert_with(Default::default).key = state) and read rows, cursor and total straight off the fields; a token walk's state is
the cursor's Rust type (Vec<u8> for Cosmos next_key), a page walk's the index's
(u64). dispatch.rs gains call_grpc(function, request: &[u8], options) -> Result<Vec<u8>>,
which decodes the request into the method's Request and encodes what it returned.
Names follow prost-build (heck): a message is UpperCamelCase (QueryBTCRequest is
QueryBtcRequest), a nested type sits in a module named after its parent
(search_response::Hit), a field is snake_case with keywords raw (r#type), a singular
message field and a proto3 optional scalar are Options, an enum field an i32, and
the well-known types come from prost-types.
Testing: truewire-testing with feature grpc has GrpcMock, an in-process gRPC server
over tonic answering every recorded gRPC example (a request equal to a recorded one, as
a message, gets its response; anything else NOT_FOUND; a method with no recording
UNIMPLEMENTED), grpc_examples, and replay_grpc, which encodes each recorded request,
calls the generated call_grpc and compares the reply with the recording as messages. A
recorded google.protobuf.Any is read in proto JSON's @type form or betterproto's
type_url/value form; one whose @type names a message spec/proto/ does not declare
cannot be encoded, so that example is left out of the mock and reported skipped:
let protos = Arc::new(Protos::from_descriptor_set(dydx::protos::FILE_DESCRIPTOR_SET)?);
let mock = GrpcMock::start(env!("CARGO_MANIFEST_DIR"), protos.clone()).await?;
let client = GrpcDemo::from_core(GrpcClient::new(mock.url())?);
let examples = grpc_examples(env!("CARGO_MANIFEST_DIR"));
let client = &client;
replay_grpc(&examples, &protos, |function, request| async move {
client.call_grpc(&function, &request, CallOptions::default()).await
})
.await
.assert_passed();packages/truewire/test/test_codegen_rust_grpc.py does exactly that for the grpc_client
fixture, plus both walks, after cargo fmt --check and cargo clippy -- -D warnings; it
skips without cargo, buf or protoc-gen-prost.
Protobuf sources
A project with spec/proto/**/*.proto gets <package>/protos.rs: SOURCES, each file's
path under spec/proto/ beside its text (ADR 0016). A hand-written core compiles them once
with truewire_core::proto::Protos::from_sources(SOURCES) (feature proto; imports resolve
among the sources, and google/protobuf/*.proto resolve too), decodes a binary frame with
decode(envelope, bytes) and narrows it with narrow(&frame, field). A project with gRPC
endpoints gets no protos.rs: its protos module is the prost one above, and a core
compiles the same messages with Protos::from_descriptor_set(protos::FILE_DESCRIPTOR_SET).
A router whose endpoints are all hand-written (mexc's protobuf-framed spot streams) is still
rendered, holding the core those endpoints would hold as pub(crate) core, so the inherent
impl written beside it has a struct and a transport.
Recording
HttpClient::recording() is the Rust form of truewire_core.http.recording(): every
exchange the client makes until the Recording is stopped or dropped, in order, at the
wire level (method, URL with query, headers, body sent; status, headers, body received),
before the core unwraps an envelope or maps an error. It is what a future truewire capture reads through a generated Rust client. Recordings may overlap, and
HttpClientOptions::on_exchange is the permanent form.
Proxy
Packages clause P18: a proxy is given explicitly, since a sandbox or a library caller
cannot always set HTTPS_PROXY. The generated client takes its core ready-made
(from_core), so the core is where the proxy goes, one URL for both transports:
let options = HttpClientOptions::default().with_proxy("http://127.0.0.1:3128")?;
let http = HttpClient::new(options);
let socket = Socket::new(dialect, SocketOptions::new(url)).with_proxy("http://127.0.0.1:3128")?;with_proxy on HttpClientOptions builds the reqwest client (http:// or https://
proxy; an explicit one ignores the environment, NO_PROXY included). On Socket every
connection is a CONNECT tunnel through an http:// proxy, ws:// and wss:// alike,
credentials from the URL's userinfo. "" means no proxy. Without one, HTTP reads
HTTPS_PROXY/HTTP_PROXY/ALL_PROXY/NO_PROXY as reqwest always did; the socket
reads nothing. The github and kraken example cores take CoreOptions::proxy, and
try_new returns a bad one as an error where new panics.
WebSocket commands and dual transports
An rpc endpoint whose transports is ["ws"] renders like an HTTP one, holding an
Arc<dyn CommandEndpoint<Meta>> and handing it a CommandCall { path, request, meta, options } whose path is the wire method name. One declaring ["http", "ws"] holds a
core satisfying both and picks the transport per call from CallOptions::transport
(Transport::Http or Transport::Ws), defaulting to the one its spec lists first -- the
Rust form of the Python backend's transport= keyword:
let time = client.public.get_time(CallOptions::default().transport(Transport::Ws)).await?;A trait object names one trait, so a core that must be several at once (a dual-transport
endpoint, a router holding commands beside streams, a composite field handed to both) is
held as a combined trait the generator declares in contract.rs, with a blanket impl:
pub trait CommandStreamEndpoint: CommandEndpoint + StreamEndpoint {}. A core
implementing the parts implements the whole, and the value upcasts to each part where a
child holds only one.
Calling by function path
dispatch.rs adds call, call_raw, subscribe and subscribe_raw to the root: every
generated method by its dotted function path, with a wire request in and a wire value out
(call decodes the request into its Request, calls the typed method and dumps the
result; call_raw calls the _raw twin). It is the lookup Python gets from getattr and
TypeScript from indexing the client, written out because Rust has no reflection, and it is
what truewire-testing's replay and anything else driving a client from data use. An
unknown function is a LogicError.
Streams
A stream endpoint's generated method calls
subscribe with the channel template, the dumped parameters (or None for a
direct-channel or connect-only stream, where the method filled the template itself, as in
the other backends) and the endpoint's meta, and maps the core's Stream<Value> into
its message type:
pub async fn ticker(&self, parameters: Parameters, options: CallOptions) -> Result<Stream<TickerMessage>> {
let stream = self.core.subscribe(SubscribeCall { channel: "ticker", parameters: Some(dump(¶meters)?), meta: &(), options }).await?;
Ok(stream.map(decode))
}A stream whose parameters only fill its channel template (tickers.{symbol}, no
parameters object on the wire) takes a generated Parameters struct of those fields; the
method dumps it, fills each placeholder with the field's query text (a string verbatim, a
number as its digits) and subscribes with parameters: None, as the TypeScript and Go
methods do.
A stream declaring a reply schema (ADR 0014) returns Stream<Message, Reply>: the typed
method adds .map_reply(decode), so stream.reply is the acknowledgement's own type.
Stream<N> is a futures::Stream<Item = Result<N>> with the acknowledging reply and an
unsubscribe(); a dropped connection yields one NetworkError item and ends it. There is
no lazy Subscription and no await using: subscribe is an async fn, and a value that
is dropped without unsubscribe() forgets the subscription locally without sending a
frame.
The hand-written WebSocket core is a Dialect given to a Socket. Where @truewire/core
has Socket/Rpc/Streams/StreamsRpc/SerialReplies to subclass, the Rust runtime
owns all connection and correlation state in Socket and asks the core only for the wire
dialect: parse(frame) into Incoming::{Reply{id, reply}, Push{channel, notification}, Serial(reply), Ignore}, encode_request(id, request), subscribe/unsubscribe returning
Outgoing::{Rpc(request), Serial(frame), Nothing} (acknowledged by the correlated reply,
by the next serial acknowledgement, or by nothing), check_ack to refuse a subscription,
ping for an application-level ping frame, and on_open for a handshake on every fresh
connection (a Link to send on before the connection is current). Kraken's req_id
dialect is Outgoing::Rpc everywhere; the ws template's uncorrelated subscribed acks
are Incoming::Serial and Outgoing::Serial. Socket then serves rpc_request,
subscribe (with SubscribeOptions::request_channel/message_key for one wire channel
feeding several local ones), serial_request, wait and close; the connection opens on
first use, and one the peer dropped fails every caller on it and is reopened by the next
use. crates/truewire-core/tests/ws.rs holds three dialects (Rpc-only, Streams with
serial acks, StreamsRpc with a handshake) driven against an in-process server.
Pagination
<method>_paged is rendered from the plan's pagination decisions, as in TypeScript:
walker: paginated(apagewalk ended byshort_page,emptyortotal; atokenwalk ended byabsent_cursor; a plainseekwalk): a plain method returningPaginatedResponse<Row, State>:.await(every row, flattened; alsoall()),rows()(one non-empty page at a time),pages()(every page with the state before and after it, for checkpointing),resume(state)andvia(invoker). Itsnext(state)is pure instate, so a page can be retried and a walk resumed. The request type is the endpoint'sRequestwithout the driver parameter (ListPagedRequest), unless the cursor is required and seeds the walk, in which case it isRequestitself.- A
tokenwalk over a union payload (one page shape per product category) matches the payload's variant, reads its rows and its cursor there (a variant without the cursor ends the walk), and yields the union of the variants' row types as one untagged<Walker>PagedRequestRowenum, or the shared row type when every variant has the same one. - A page size or a page
totalthe wire sends as a string is parsed; a size that is not a number is unknown, and a total that is not one never ends the walk. - The terminator checks are the runtime's, not the emitter's:
exhausted(rows, size)forshort_page/empty,total_reached(...)for apagewalk ended bytotal,cursor_or_done(cursor)forabsent_cursor(a zero-valued cursor is absent, as the plan'shas_zero_valuesays), andTotalSeenfor the rule that a missingtotal, or one that disagrees with an earlier page of the same walk, is aLogicError.
The page-strategy body of GitHub's issues.list walker reads:
pub fn list_paged(&self, request: ListPagedRequest, options: CallOptions) -> PaginatedResponse<Issue, i64> {
let endpoint = self.clone();
let size = request.per_page.unwrap_or(30);
let size = Some(size as usize);
let next = move |page: i64| {
let endpoint = endpoint.clone();
let request = request.clone();
let options = options.clone();
async move {
let request = request.at(Some(page));
let response = endpoint.list(request, options).await?;
let rows = response;
if exhausted(rows.len(), size) {
return Ok((rows, None));
}
Ok((rows, Some(page + 1)))
}
};
PaginatedResponse::new(1, next)
}at(page) is a private method of ListPagedRequest building the single-call Request
for one page. Every read of the response (rows, a cursor, a total) is rendered through
the type tree, so a nullable page or an optional cursor is read through Option rather
than assumed present.
Seek and offset walks
A seek walk (ADR 0013) returns PaginatedResponse<Row, SeekState<Key, Row>>. Its request
is the single call's own Request (both bounds kept); init is the caller's moving bound,
each page is requested from the state's pos (and, with a span, up to the span edge as
the far bound) with an integer page size clamped once to at least 2 and at most the
schema's maximum (a page must hold one new row beside the one it re-reads; the clamped
size is also the cap), and the page is folded back through truewire_core::Seek, which
owns the whole algorithm: re-served boundary rows deduplicated by key (unique) or by
content, the moving bound set to a full page's extreme key, span chunks never past the
caller's far bound, and a LogicError for a full page stuck on one key or a carried row
the venue stopped serving. Key is the moving bound's type (an integer, a float, a string
id compared by equality, IntegerString, or any timestamp newtype); a row's key is read
through its wire form, so a numeral-string field walks a numeric bound. An offset walk's
state is the offset, stepped by the rows each page held. A total terminator only decides
when to stop: a missing or moving total is not an error, and no state is kept outside
next's argument.
Testing
crates/truewire-core/tests is at the granularity of packages/core-ts/test: errors.rs,
validation.rs (a generated-shaped struct through parse, dump, every format, every
container, every failure path), times.rs (the converters), http.rs (a loopback HTTP/1.1
listener: query, JSON and raw bodies, statuses, network failures, timeouts, recording),
paging.rs (the resumable contract and the terminator helpers) and ws.rs (three
dialects against an in-process tokio-tungstenite server: correlation out of order, lazy
open, drop and reconnect, close, refused and hanging connections, close frames, dialect
errors, pings, subscribe/push/unsubscribe, message_key routing, refused subscriptions,
map/filter, serial ordering, and an on_open handshake). No test touches the network.
crates/truewire-testing is the harness for a generated package: Mock::start(project)
runs truewire mock on free ports and reads the HTTP and WebSocket URLs it announces
(TRUEWIRE_BIN, else the nearest .venv/bin/truewire, else PATH); http_examples and
ws_examples discover every recording with its function path; replay runs a check over
all of them and fails once, naming every failure; Replayed::compare checks the typed
value dumps back to the raw one. examples/kraken/tests/replay.rs is a whole replay test
in thirty lines, through dispatch.rs.
examples/github/tests is the older pattern for a generated package. common/mod.rs spawns
truewire mock --http-port 0 --ws-port 0 and builds the client against its URL;
replay.rs walks spec/endpoints/**/examples/*.request.json, decodes each recorded
request into its Request struct, calls the method and its _raw twin, and checks the
typed value dumps back to exactly the body the wire sent; paging.rs walks the same
recorded multi-page captures as test/test_paging.py. CI (examples-rust) runs
truewire generate rust --check, cargo fmt --check, cargo clippy --all-targets -- -D warnings and cargo test. The backend's own tests (packages/truewire/test/ test_codegen_rust.py) render the fixture client and, when rustfmt is installed, prove
every file passes rustfmt --check.
Not generated yet
- Walks the resumable form does not cover (a
pageortokenwalk the plan markswalker: generator): the plain method is generated and the walker reported as skipped. - Streaming
grpcmethods, and a gRPC walk driven through aoneofmember or ended by anything butabsent_cursor/empty(token) ortotal/short_page/empty(page): the method is generated and the walker reported as skipped. - Publishing
truewire-coreto crates.io, and arelease/core-rustworkflow in the shape ofrelease-core-ts.yml. truewire docs checkfor ```rust blocks andtruewire surfacefor the snake_case rule.
Two gaps are the plan's rather than the backend's, recorded in docs/plan.md: every
integer renders as i64 (the plan carries no width), and a union renders
#[serde(untagged)] (the plan carries no discriminator). Two runtime limitations a
reader should know: a literal mixing strings with numbers widens to serde_json::Value,
and a DecimalString (the exact wire text over a bigdecimal::BigDecimal, any number of
digits) is Clone but not Copy.