API ergonomics & consistency pass (pre-tag)#46
Conversation
…toipa carve-out Adds #[non_exhaustive] to ReadDataByIdentifierRequest and a derive_contract test (Eq assertion only). serde omitted: &[u16] has no borrowed Deserialize impl in serde. utoipa omitted: requires Dids (pub(crate)) to impl ToSchema. Both carve-outs documented inline matching ReadDTCInfoResponse precedent.
- DTCFormatIdentifier: add Eq (prerequisite for ReadDTCInfoResponse Eq) - ReadDTCInfoRequest: add Eq; make new() const - DTCFaultDetectionCounterRecord: add Eq - ReadDTCInfoResponse: add Eq, PartialEq, and serde/utoipa cfg-derives (serde(borrow) on each &'a [u8] field); all contained types had serde
… test, docs, fmt)
CI runs `clippy --all-targets --all-features -- -D warnings -D clippy::pedantic`, which denies pedantic lints in test code. The new response round-trip tests bound `(resp, rest)` / `(decoded, rest)`, tripping clippy::similar_names. Rename the slice binding to `remaining` (the existing convention in these files).
59d5c53 to
b9013ce
Compare
JustinKovacich
left a comment
There was a problem hiding this comment.
Nice, tightly-scoped consistency pass — I built the branch and the suite is green (163 + 3 doctests), no correctness or cross-file breakage, and encode output is byte-for-byte unchanged for conformant frames. The comments below are all about the newly-introduced serde story. Findings 1 and 3 are inline; finding 2 can't be a line comment because it's in Cargo.toml (outside this PR's diff), so noting it here:
serde_bytes is a declared dependency, force-enabled by the serde feature, but never used. Cargo.toml:25 (serde = ["dep:serde", "dep:serde_bytes"]) and :36 pull it in, but grep serde_bytes src/ and grep 'serde(with' src/ both return nothing. It's dead weight that also inflates the serde feature's build — and per the inline comment on read_data_by_identifier.rs, wiring it up wouldn't fix the JSON round-trip anyway (I tested it). Suggest either removing the dep or reconsidering the byte-field serde strategy it was presumably added for.
| #[non_exhaustive] | ||
| pub struct ReadDataByIdentifierResponse<'a> { | ||
| #[cfg_attr(feature = "serde", serde(borrow))] | ||
| records: &'a [u8], |
There was a problem hiding this comment.
Borrowed &[u8] types advertise Deserialize but can't be deserialized from JSON (applies to every #[serde(borrow)] &[u8] field added here: this one, transfer_data.rs req/resp data, security_access.rs request_data/security_seed, routine_control.rs option_record/status_record, request_download.rs max_number_of_block_length, write_data_by_identifier.rs data, and the read_dtc_information.rs raw_records variants).
Reproduced in a standalone crate: serde_json::to_string of ReadDataByIdentifierResponse emits {"records":[1,2,255]}, but serde_json::from_str of that exact output fails at runtime:
invalid type: sequence, expected a borrowed byte array
It compiles, so CI stays green — but the type publicly implements Deserialize, and this PR pairs these types with utoipa::ToSchema (an OpenAPI/JSON/HTTP story), so a client generated from the schema sends JSON the Rust side then can't read back.
Heads-up on the obvious fix: #[serde(with = "serde_bytes")] does not fix this — I tested it, and borrowed &[u8] still can't deserialize from a JSON array (inherent to zero-copy + self-describing text formats). It only round-trips in borrowing binary formats (bincode/postcard), where plain #[serde(borrow)] already works. Real options: (a) document these as binary-format-only and drop the JSON/utoipa expectation for byte fields; (b) deserialize into Cow<'a, [u8]>/Vec<u8>; or (c) add a custom deserializer that also accepts an integer array.
| /// Compile-time assertion that `T` round-trips serde (borrowed deserialize allowed). | ||
| #[cfg(feature = "serde")] | ||
| #[allow(dead_code)] | ||
| pub(crate) const fn assert_impl_serde<'de, T: serde::Serialize + serde::Deserialize<'de>>() {} |
There was a problem hiding this comment.
This assertion gives false confidence for the borrowed-serde types. assert_impl_serde::<T<'static>>() only checks that the Serialize + Deserialize<'de> bounds exist; it never instantiates a deserializer or performs a round-trip. That's exactly why the whole suite is green while JSON deserialize of the &[u8] types is broken (see the comment on read_data_by_identifier.rs). Consider a real round-trip assertion instead — e.g. a bincode serialize→deserialize round-trip, or a serde_json test that documents the expected failure — so the true capability is explicit rather than implied by a bound check.
Summary
A pre-tag consistency/ergonomics sweep of the public API. No behavioral change for conformant wire traffic — this pass touches derives, associated functions, two new named response wrappers, and one accessor rename. Encode/decode of valid frames is byte-for-byte identical.
Spec & plan:
docs/superpowers/specs/2026-07-01-api-consistency-pass-design.md,docs/superpowers/plans/2026-07-01-api-consistency-pass.md.What changed
Tier 1 — uniform derives on the zero-copy borrowed types. The older POD types already had the full derive treatment; the borrowed (
&'a [u8]-backed) types did not. Now every*Request/*Responsecarriesserde+utoipacfg-derives,Eq, and#[non_exhaustive](with#[serde(borrow)]on borrowed fields).ReadDTCInfoRequest::newis nowconst. Also addedEqtoDTCFormatIdentifier(a prerequisite that unblockedReadDTCInfoResponse: Eq).Tier 2 —
allowed_nack_codes()completed. Added to the 6 services that lacked it, so all 15/15 services now exposefn() -> &'static [NegativeResponseCode]uniformly.Tier 3a — response symmetry. Introduced named
ClearDiagnosticInfoResponseandReadDataByIdentifierResponse<'a>types, replacing the bareResponse::ClearDiagnosticInfounit variant and the inlineResponse::ReadDataByIdentifier(&[u8]). EveryResponsevariant now carries a named, exported, serde-able type.Tier 3b — accessor convention. Standardized scalar-newtype accessors on
const fn value()(renamed the lone outlierSecurityAccessLevel::get()).Intentional deviations (reviewed & documented)
ReadDataByIdentifierRequestdid not get serde/utoipa. Its privateDids::Native(&[u16])backing has no borrowed-Deserializeimpl in serde (only&[u8]/&strsupport zero-copy borrow). Carved out and documented inline. The response (wrapping&[u8]) got full serde.ClearDiagnosticInfodecode is now strict. A positive response with trailing bytes after the SID is now rejected (viadecode_exact), matching every other response arm. Conformant traffic ([0x54]) is unaffected. A test asserts both paths.Verification
cargo testgreen acrossdefault,--no-default-features,--no-default-features --features alloc,--all-features(163 unit + 3 doctests).cargo clippy --all-features -- -D warningsand--no-default-featuresclean.cargo fmt --checkclean;cargo doc --all-featuresclean (nomissing_docs).no_stdbuild preserved.fuzz_roundtrip/fuzz_request_decode/fuzz_response_decode) not run —cargo-fuzzisn't installed locally. Worth running before the tag since theResponseenum changed shape.Process
Executed task-by-task with a fresh implementer + independent spec/quality review per task, plus a final whole-branch review (no Critical findings). Every derive addition is guarded by a compile-time trait-bound assertion.
🤖 Generated with Claude Code