rubo4e
Rust implementation of BO4E — Geschäftsobjekte für die Energiewirtschaft, the object model the German energy industry uses to exchange contracts, metering points, invoices, and the parties involved.
rubo4e generates the full object model from the official JSON Schema, then adds
what the schema cannot express: market identifiers that verify their own BDEW
check digits, enums you can parse strictly at an ingest boundary, and JSON that
reads what Python, Go, and .NET write — and writes what the reference Python
implementation does.
Independent implementation. Not affiliated with or endorsed by the BO4E project or BDEW; the reference implementation is BO4E-python.
Features
- Generated types from the official BO4E JSON Schema, generated from a committed snapshot (
v202607.1.0) so the codegen is reproducible - Strong domain identifiers — the complete BDEW identifier family (
MaloId,MeloId,NeloId,NebeId,CrId,SgId,SrId,TrId,PaketId,EicCode,ObisCode,MarktpartnerId, …) plus the SEPA pair (Iban,Bic), with spec-accurate check digits and domain helpers - Three-layer validation — constructor checks,
gardestruct rules, cross-field business logic - Strict enum parsing & introspection —
from_wire(reject out-of-schema values),VARIANTS/COUNT/iter_known,Display/AsRef<str>,is_unknown, unified by theBo4eEnumtrait — all without thestrumfeature - Recursive strict decoding —
Bo4eStrict::ensure_known_enums()rejects anyUnknownenum value anywhere in a deserialized payload, with JSON-paths — one call replaces hand-written per-field checks - Typed builders — readable, diffable construction via
typed-builder; setters accept bothTandOption<T>(note: BO4E BO fields are schema-optional, so AHB-mandatory contracts are enforced by your ingest layer, not the type system);LastgangandTarif, the two the schema marksrequired, get a feature-freenew(…) - Type-level BO facts —
T::BO_TYP,T::TYP_WIRE,T::SCHEMA_VERSION,T::SCHEMA_SERIESas associated constants, so generic code needs no value and noDefaultbound - German / snake_case / canonical JSON — BO4E wire format out of the box, with a hardened path for untrusted input
Eq+Hashon generated types without thejsonfeature, so a BO can key aHashMap; enums are alwaysEq + Ord + Hash- Ergonomic convenience API — extension traits, billing-period helpers, EDIFACT agency codes
- JSON Schema via
schemars, OpenAPI viautoipa, PostgreSQL viasqlx - Golden corpus, fuzz harnesses, and drift guards that fail the build when the committed codegen stops matching the pinned schema
Installation
That gives you the identifier types only. Add the features you need:
Quick Start
use *; // identifiers, BetragExt, MengeExt, PreisExt, Bo4eJsonExt
use ;
Feature Gates
| Feature | Default | Description |
|---|---|---|
identifiers |
✓ | Identifier types (MaloId, EicCode, ObisCode, …) + serde — zero schema overhead |
serde |
✓ | Serde derives + extension-data map |
json |
serde_json helpers (to_json_german(), …) |
|
time |
time crate — Date for date fields, OffsetDateTime for timestamps; also turns on utoipa/time |
|
decimal |
rust_decimal::Decimal for amounts and prices; also turns on schemars/rust_decimal1 and utoipa/decimal |
|
builder |
typed-builder derives on all BO/COM structs |
|
validate |
garde validation — constructor + cross-field rules |
|
schemars |
JSON Schema generation with patterns and examples | |
sqlx |
Type/Encode/Decode/PgHasArrayType for every identifier and every enum (PostgreSQL) |
|
utoipa |
ToSchema with pattern/example/description for OpenAPI |
|
strum |
Enum iteration and string conversion | |
versioned |
Versioned schema modules (v202607, current) |
|
tracing |
Structured diagnostics via the tracing crate |
|
metrics |
Counter export hooks (metrics ecosystem) |
Typical full setup:
JsonSchema/ToSchema for Decimal and the time types ride on decimal and
time, not on schemars/utoipa, so this crate does not become your
workspace's accidental sole provider of them. If you derive JsonSchema over a
Decimal of your own, declare schemars = { features = ["rust_decimal1"] }
yourself — see Ecosystem.
Schema Versions
| Module | Built from | Status |
|---|---|---|
v202607 |
v202607.1.0 | Current stable |
use Marktlokation; // the v202607 series
use Marktlokation; // whichever series is newest — moves with crate updates
Three spellings of a release. The Rust module is the series (v202607).
The git tag carries a v and the full triple (v202607.1.0). The _version
field inside a payload has the triple without the v (202607.1.0).
Bo4eObject::SCHEMA_VERSION is the wire spelling, SCHEMA_SERIES the series.
The BO facts are associated constants, so generic code needs no value and no
Default bound — which is what admits Lastgang and Tarif, the two types the
schema marks required:
use ;
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
Method forms (bo_type(), schema_version(), …) exist for when you have a
value. bo_type() reports what the value is, never the _typ a payload
claimed — read the public typ field for that.
Associated constants make a trait non-dyn-compatible, so use
AnyBo for a
heterogeneous collection.
Dispatch on the series, not the release. BO4E ships patch releases inside a
series, so a sender one patch ahead stamps a _version that an equality match
rejects — for a payload these types read perfectly:
match incoming_version.split.next
Versioning contract, stated honestly. The module path pins the series; the
rubo4e version pins the values. Enum membership can move inside a series
because BO4E moves it — v202607.1.0 removed Messgroesse::PREISE and dropped
two enums outright. Importing rubo4e::v202607::… rather than rubo4e::current::…
means you will not silently cross a format-version cutover, but it does not
freeze a variant set: for that, pin the crate version and upgrade deliberately.
Guard the rest structurally with T::VARIANTS / T::COUNT so a schema bump
fails in CI. Every release that changes schema-derived membership records it in
the CHANGELOG Schema deltas section, removals included. See
Schema Versioning for the full contract.
Enum Introspection & Strict Parsing
Every generated BO4E enum carries an Unknown forward-compatibility catch-all,
so the lenient serde / FromStr path never fails on an unrecognized wire value
— it maps to Unknown. That is the right default for forward-compatibility, but
the wrong default at an ingest boundary that must reject typos, legacy codes, or
values from a newer schema. Every enum therefore also exposes a uniform,
strum-free surface (also unified by the Bo4eEnum trait for generic use):
use ;
// Introspection without `strum` — drift-guard SQL CHECK lists & mappings:
assert_eq!;
for v in iter_known
// Strict parsing at the boundary — Err instead of a silent Unknown:
assert_eq!;
assert!; // legacy/typo rejected
assert!; // catch-all is not a real value
// Detect lenient-decode fall-through after a serde round-trip, in one call:
let z: Zaehlertyp = from_value.unwrap;
assert!;
| Member | Feature | Purpose |
|---|---|---|
T::VARIANTS |
none | &'static [T] of known variants (excludes Unknown) |
T::COUNT |
none | stable per-version variant count |
T::iter_known() |
none | iterator over known variants |
T::as_wire(&self) |
none | canonical BO4E wire string |
T::from_wire(s) |
none | strict parse → Result<T, UnknownVariant> |
T::is_known / is_unknown |
none | detect the Unknown catch-all |
Display, AsRef<str> |
none | canonical wire string, without strum |
Bo4eEnum trait |
versioned |
the above, generic over the enum type |
Display,AsRef<str>,as_wire,from_wire,VARIANTS,COUNT, anditer_knownare all feature-independent. Thestrumfeature adds onlyFromStr,EnumIter, andInto<&'static str>.
Strict decoding of whole payloads (Bo4eStrict)
Per-enum from_wire is strict at the field level. But the common pattern is a
lenient whole-object decode (serde_json::from_value::<Rechnung>()) used as a
schema gate — and that decode silently turns every unrecognized enum value into
Unknown, anywhere in the tree. Bo4eStrict closes that gap: one call finds
every out-of-schema enum value in a nested value and reports its JSON-path.
use ;
let nelo: Netzlokation = from_value?; // lenient decode (never fails on enums)
nelo.ensure_known_enums?; // Err lists e.g. ["zaehler[1].zaehlertyp"]
ensure_known_enums() returns StrictError with the dotted, index-bracketed
paths of every Unknown enum value; unknown_enum_paths() returns them directly.
Implemented for every BO, COM, enum, and AnyBo. This replaces the hand-written
record.field == T::Unknown re-checks a strict ingest boundary would otherwise
need. Unlike Bo4eObject/Bo4eEnum, Bo4eStrict is not sealed, so you can
implement it on your own domain wrappers to extend the recursive check.
Identifiers
All domain identifiers validate their format at construction time. There are no panicking constructors.
| Type | Format / Rule |
|---|---|
MaloId |
11 digits, first 1–9, BDEW §8.1 check digit — Marktlokation / Tranche |
NeloId |
Codetyp 'E' + 9 [A-Z0-9] + §8.2 check digit — Netzlokation (BK6-22-128) |
NebeId |
Codetyp 'F' + 9 [A-Z0-9] + §8.2 check digit — Netzbereich (BK6-22-300) |
CrId |
Codetyp 'A' + 9 [A-Z0-9] + §8.2 check digit — Cluster Ressource |
SgId |
Codetyp 'B' + 9 [A-Z0-9] + §8.2 check digit — Steuergruppe |
SrId |
Codetyp 'C' + 9 [A-Z0-9] + §8.2 check digit — Steuerbare Ressource |
TrId |
Codetyp 'D' + 9 [A-Z0-9] + §8.2 check digit — Technische Ressource |
PaketId |
Codetyp 'P9' + 8 [A-Z0-9] + §8.2 check digit — Netzbetreiberwechsel |
MeloId |
33 chars: 2-char ISO country code + 31 alphanumeric |
EicCode |
16-char EIC with ENTSO-E check character and object type |
BilanzkreisId |
16-char EIC restricted to object type 'X' (Party) — Bilanzkreis, MaBiS / GaBi Gas |
BilanzierungsgebietId |
16-char EIC restricted to object type 'Y' (Area) — Bilanzierungsgebiet, MaBiS |
ObisCode |
[A-B:]C.D[.E][*F], value groups are octets; C=0 permitted (IEC 62056-61 general metering group) |
MarktpartnerId |
13 decimal digits — BDEW (99), DVGW (98), or GS1 GLN; check digit opt-in |
AkivId |
1–36 printable ASCII chars — Aktivierungsidentifikator Redispatch 2.0 (BK6-24-174) |
TranchennummerId |
1–6 decimal digits, no leading zeros — MABIS Bilanzkreisabrechnung (PID 13003) |
Section numbers refer to the BDEW Anwendungshilfe "Identifikatoren in der
Marktkommunikation" v1.2 (7 February 2025). Chapter 8 defines a single
check-digit arithmetic — sum the mapped values at odd positions, add twice the sum
at even positions, take the difference to the next multiple of 10 — in two flavours:
§8.1 for numeric IDs and §8.2 (the "ASCII-Verfahren", where A–Z map to their
ASCII codes) for alphanumeric ones. Both are implemented once and pinned to the
worked examples printed in the specification.
// Build from base — the check digit is computed, never typed by hand.
let malo = from_base?; // → "41373559241" (BDEW §8.1 example)
let c = check_digit?; // → 1u8
assert_eq!;
// Every §8.2 identifier shares the same API and enforces its own Codetyp.
let nelo = from_base?; // → "E0000000019"
let tr = from_base?; // → "D0000000010"
let paket = from_base?; // → "P9000000010"
assert!; // Codetyp mismatch — that is a TrId
// Country code extraction (MeloId)
let melo = new?;
assert_eq!;
assert!;
// EDIFACT agency codes (MarktpartnerId) — eliminates duplicate mapping tables
let mp = new?;
assert_eq!;
assert_eq!; // EDIFACT NAD DE3055
assert_eq!; // EDIFACT UNB DE0007
// MP-IDs carry either a BDEW (§8.1) or a GS1/EAN-13 check digit; opt in explicitly.
assert!;
assert!;
// Integer round-trip for legacy systems
assert_eq!;
// Serde as integer (opt-in, field-level)
pub partner_id: MarktpartnerId,
EIC codes and object types
Position 3 of an EIC carries the ENTSO-E object type, and the German market
leans on it: a Bilanzkreis is a market party (11X…), while a Bilanzierungsgebiet
is an area (11Y…). EicType exposes all seven types, and the two restricted
newtypes make the roles unswappable at a call site.
use ;
let area = new?; // TenneT control area
assert_eq!; // 'Y'
let party = new?; // a Bilanzkreis
assert_eq!; // 'X'
// The restricted types pin position 3, so the two cannot be confused.
let bk = new?;
assert!;
let bg = new?;
assert!;
// The check character is derived, never typed by hand.
assert_eq!;
// Widening is infallible; narrowing is checked.
let eic: EicCode = bk.into;
assert!;
OBIS codes (EDIFACT support)
ObisCode parses once at construction and stores a canonical form, so two
spellings of the same code are equal and hash alike. Value groups are single
octets, as IEC 62056-61 specifies.
// Standard OBIS codes
let obis = new?; // active energy total
let obis = new?; // C=0 — general metering group (IEC 62056-61)
// Canonicalisation: `&` becomes `*`, and leading zeros are dropped.
assert_eq!;
assert_eq!;
assert_eq!;
// Value groups are octets — 256 is not an OBIS value.
assert!;
// Components are stored, so this neither re-parses nor allocates.
let parts = new?.components;
assert_eq!;
// PIA item-number form drops the F component.
assert_eq!;
IBAN and BIC
Zahlungsinformation.iban and .bic are the two fields on a BO4E invoice that
money moves against, and the schema declares both as bare strings. An IBAN's
ISO 7064 MOD-97-10 check digits catch every single-character error and
every adjacent transposition.
// Grouping spaces and lowercase normalise away, so a value pasted from a bank
// statement parses; `as_ref()` always returns the compact wire form.
let iban = new?;
assert_eq!;
assert_eq!;
assert_eq!;
assert!; // transposed digits
let bic = new?;
assert_eq!;
assert!; // location code ending in 1, per ISO 9362
The generated Zahlungsinformation keeps both fields as String, deliberately:
it hangs off Rechnung and nothing else, so a masked IBAN
(DE89 **** **** 3000, routine on an invoice) would take the whole invoice down
with it. Run the check on demand instead — the error costs you the field, not the
invoice:
match zahlungsinformation.iban_checked
Multi-version Dispatch
When a storage layer (e.g. PostgreSQL JSONB) writes a bo4e_version column alongside
BO4E JSON, the idiomatic dispatch is a plain match — on the series, not the
exact release:
use ;
Matching the full _version string instead would reject a payload from a sender
one BO4E patch ahead of you — "202607.2.0" against a "202607.1.0" arm — even
though the v202607 types read it perfectly. schema_series() returns exactly
the value the match keys on, so a test can assert the two agree.
This pattern:
- Requires no new rubo4e API —
schema_series()is already on every BO type viaBo4eObject - Is trivially extensible: each new schema series is one
matcharm, and patches inside a series need none - Localises migration to the storage layer; business logic only handles the series it was written for
- Avoids over-engineering (
traitobjects,Any*enums) for a straightforward branch
See Schema Versioning for the full upgrade workflow.
Convenience API
Extension traits — flatten Option<Com> to Option<Decimal>
use *; // brings BetragExt, MengeExt, PreisExt into scope
// Before (v0.3 — two levels of unwrap):
let net = pos.gesamtpreis.as_ref.and_then;
// After (v0.4):
let net = pos.gesamtpreis.wert_decimal; // Option<Decimal> via BetragExt
let qty = pos.positions_menge.wert_decimal; // Option<Decimal> via MengeExt
let unit = pos.einzelpreis.wert_decimal; // Option<Decimal> via PreisExt
Billing and validity helpers
use ;
use date;
// Rechnung — closed billing period, as a RangeInclusive<Date>
if let Some = rechnung.billing_period
// Navigate rechnungsperiode fields directly
let start: = rechnung.period_start;
let end: = rechnung.period_end;
// Iterate line items
for pos in rechnung.positions
// Decimal totals — direct access
let net = rechnung.gesamtnetto_decimal; // Option<Decimal>
let tax = rechnung.gesamtsteuer_decimal; // Option<Decimal>
let gross = rechnung.gesamtbrutto_decimal; // Option<Decimal>
let pay = rechnung.zu_zahlen_decimal; // Option<Decimal> — final amount due
let disc = rechnung.rabatt_netto_decimal; // Option<Decimal> — net discount
let next = rechnung.zukuenftiger_abschlag_decimal; // Option<Decimal>
let adv = rechnung.vorauszahlungen_summe; // Option<Decimal> — sum of advance payments
// Invoice flags — unwrap_or(false), no Option juggling
if rechnung.is_storno
if rechnung.is_original
// Date fields
let due: = rechnung.faelligkeitsdatum_date;
// Rechnungsposition — delivery period from embedded Zeitraum
let von: = pos.lieferung_von_date; // reads lieferungszeitraum.startdatum
let bis: = pos.lieferung_bis_date; // reads lieferungszeitraum.enddatum
let in_period: bool = pos.lieferungszeitraum_contains;
// PreisblattNetznutzung — point-in-time validity check
let valid = preisblatt.is_valid_at;
// Zeitraum — BO4E declares *both* dates inclusive: the period is [start, end]
let range = z.as_inclusive_range; // Option<RangeInclusive<Date>>
let bounds = z.bounds; // (Option<Date>, Option<Date>)
let days = z.whole_days; // Option<i64> — January is 31
let contains = z.contains; // bool — the end date is inside
let dauer = z.duration; // Option<Result<time::Duration, _>>
let start = z.startuhrzeit_parsed; // Option<Result<(Time, Option<UtcOffset>), _>>
Interval conventions are not uniform in BO4E, and this is the trap:
| Kind | Interval |
|---|---|
date-time pairs (vertragsbeginn/vertragsende, von/bis) |
[start, end) |
Zeitraum's date pair |
[start, end] |
Zeitraum's time pair (startuhrzeit/enduhrzeit) |
[start, end) |
price-tier bounds (staffelgrenzeVon/Bis) |
[von, bis], plus a gap rule |
enddatum is inclusive — "Enddatum des betrachteten Zeitraums ist
inklusiv", with '2025-01-01' given as the example for both date fields,
so start == end is a valid one-day period. Reading it exclusively drops a day
from every period. as_inclusive_range returns a RangeInclusive so the
convention travels with the value.
tests/interval_conventions.rs reads each
convention out of the committed schema and checks it against the code.
Three Zeitraum values have no time type that holds them, so they keep the
wire string and an accessor parses on demand: dauer is an ISO 8601 duration
(duration() refuses P1Y/P1M rather than guessing their length), and the two
*uhrzeit fields are times of day with a UTC offset.
PreisstaffelSliceExt::select_for picks a price tier, including BO4E's rule that
a value between two tiers "rutscht in die obere Zone" — which a plain
von <= x <= bis scan misses entirely.
JSON Handling
use Bo4eJsonExt;
use Marktlokation;
let malo: Marktlokation = todo!;
// Serialize
let german = malo.to_json_german?; // {"marktlokationsId":"…","sparte":"…",…}
let snake_case = malo.to_json_snake_case?; // {"marktlokations_id":"…","sparte":"…",…}
let canonical = malo.to_json_canonical?; // sorted keys, stable for hashing/signing
// Deserialize
let restored = from_json_german?;
Unknown JSON fields are preserved through round-trips via the _additional
extension-data map (requires json feature). This allows forward-compatible
handling of new BO4E fields without library updates. Keys and values come back
unchanged, and the top-level ones keep their arrival order; key order inside a
nested extension object does not survive, because serde_json::Value stores an
object in a sorted map.
Decimal amounts serialize as JSON strings ("wert": "119.00"), matching
BO4E-python. Deserialization accepts JSON numbers too, the way go-bo4e writes
them — but only the string spelling is exact. A JSON number has already passed
through f64 before any Rust deserializer sees it, so 119.00 arrives as 119
(scale lost) and anything past ~15 significant digits is rounded. Nothing in the
German energy market comes near that many digits, so this is a fidelity question
rather than a correctness one; decimal_serde::decimal_from_json_number_count()
counts every such read so you can tell which spelling your producers use. See
Serialization.
The snake_case mapping is an exact table emitted by the generator, not a runtime
heuristic, so from_json_snake_case(to_json_snake_case(x)) == x holds for every
generated type — which a heuristic cannot: hoechstpreisHT, kundengruppeKA, and
Sigmoidparameter's A/B/C/D all invert to a different camelCase name.
BO4E metadata keys (_typ, _version, _id) pass through byte-for-byte, and so
does extension data including everything nested under it — the transform
switches off at the edge of the schema, so a vendor blob holding {"a": 3} is not
rewritten to {"A": 3}. One ambiguity it cannot resolve: a top-level extension
key that is a field's own snake spelling is indistinguishable from that field, so
prefer the German mode whenever extension data is in play. See
Serialization.
Parsing untrusted input
Preserving unknown fields is a memory-growth surface, so every deserialization path — hardened or not — caps extension fields at 128 per struct and extension keys at 256 bytes, and rejects documents nested deeper than 128 levels.
For payloads from outside your trust boundary, the _hardened entry points add
four opt-in budgets on top:
use ;
let malo = from_json_german_hardened?;
// …or narrowed, where you know your own payloads:
let strict = untrusted_defaults
.with_max_payload_bytes
.with_max_extension_field_count; // reject any unknown field
max_payload_bytes is checked before a byte is parsed; the other three are
enforced during the single parse pass, at every nesting level — extension
data buried in a nested COM is charged to the same budget as extension data on
the root. Every limit that fires bumps a process-wide counter readable via
json_limit_hit_counters(), exported to the metrics ecosystem when the
metrics feature is on.
These bound what a payload retains, not what parsing it allocates:
#[serde(flatten)] buffers a struct's unrecognised fields before the extension
map sees them, so max_payload_bytes is the cap that bounds peak memory — set it
first. Nor do any of them bound the object graph: [{},{},{}…] is three wire
bytes and a full struct per element, so size max_payload_bytes against the
expanded cost and keep a concurrency limit in front of the endpoint.
See Serialization for the exact scope of each limit.
Validation
use Validate as _;
use Validated;
use Marktlokation;
// Direct validation — returns garde::Report on failure
let malo: Marktlokation = todo!;
malo.validate?;
// Type-safe wrapper — only constructible via validation
let validated = new?; // Err(garde::Report) if invalid
let inner: &Marktlokation = &validated; // Deref to inner type
// …and it validates on the way *in*, so a request body cannot skip the check:
let malo: = from_str?;
.validate() is recursive: it checks the value's own rules and descends into
every nested BO, COM, and identifier, reporting each failure at its path
(rechnungsperiode, kostenbloecke[0].kostenpositionen[0]). One call covers the
tree.
Cross-field rules run automatically via #[garde(custom(...))] attributes on the
generated types:
| Type | Rule |
|---|---|
Marktlokation, Messlokation |
at most one of lokationsadresse(messadresse) / geoadresse / katasterinformation |
Vertrag |
vertragsbeginn strictly before vertragsende |
Bilanzierung |
bilanzierungsbeginn ≤ bilanzierungsende |
Zeitraum |
at least one temporal field; startdatum on or before enddatum (both bounds inclusive) |
Rechnung |
one currency throughout; gesamtnetto + gesamtsteuer == gesamtbrutto; steuerbetraege sum to gesamtsteuer |
Kostenposition |
einzelpreis × menge rounds to betrag_kostenposition at its own scale |
Every rule traces to a sentence in the BO4E schema, and only those do, so
.validate() answers "does this conform to BO4E" — a claim you can make about
a counterparty's document. This crate's own judgements live in
validation::current::quality and are called by name:
use quality;
rechnung.validate?; // conformance
rechnung_totals_are_complete?; // opt-in house rule
At most one Ortsangabe, not exactly one: BO4E states mutual exclusivity, not
presence. And it has no reference type, so a location referenced from a
Rechnung or a Vertrag is a full Marktlokation carrying little more than its
ID — which makes the empty case the common one.
Not asserted: presence (BO4E marks almost every field optional, so a
Validated<T> does not prove your AHB's mandatory fields are there) and
zuZahlen (its equation names a rabattBrutto field v202607 does not ship).
Import from rubo4e::validation::current, the counterpart of rubo4e::current,
so no file has to name a schema version.
See Validation.
OpenAPI / JSON Schema
// schemars — JSON Schema (requires `schemars` feature)
let schema = schema_for!;
// utoipa — OpenAPI 3.1 (requires `utoipa` feature)
// All identifier types emit pattern, description, and example values:
// MaloId → { type: string, pattern: "^[1-9][0-9]{10}$", example: "41373559241" }
// (the leading digit is the Vergabestelle, and 0 is not assigned — see §3.2)
SQLx Integration
// Requires the `sqlx` feature — implements Type, Encode, Decode and
// PgHasArrayType for every identifier and every generated enum.
// No `json` feature needed: everything round-trips through &str.
// Bind directly as a typed identifier
query
.bind
.execute.await?;
// Decode directly — runs the same validation as new()
let id: MaloId = row.try_get?;
// Vec<Id> binds to a TEXT[] column
query
.bind
.fetch_all.await?;
// Works in FromRow structs too
Identifiers reject invalid values on decode. Enums decode leniently —
an out-of-schema string becomes Unknown, mirroring the serde path — so use
from_wire on a String column where that must be an error instead.
Documentation
hupe1980.github.io/rubo4e — guides and design notes. docs.rs/rubo4e — per-item API reference. CHANGELOG — release history and upgrade notes.
| Guide | Covers |
|---|---|
| Architecture | Workspace layout, module tree, feature-gate reference |
| Identifiers | Every identifier type, its validation rules, and the BDEW check-digit procedures |
| Serialization | JSON output modes, extension data, hardened parsing |
| Validation | The three validation layers and Validated<T> |
| Schema Versioning | Version modules, current, and the upgrade workflow |
| Ecosystem | sqlx, schemars, utoipa, strum integrations |
| Code Generator | How generation works and how to re-run it |
| Testing | The seven testing layers and how to run each |
The site sources live in site/ and are built with Zola.
MSRV
The minimum supported Rust version is 1.88, declared as rust-version in
Cargo.toml and verified in CI on every push. MSRV advances only when the
current floor is two stable releases behind, and a bump is a minor version
change, never a patch.
The floor is set by the dependency tree rather than by this crate's own source:
time and home (via sqlx) both require 1.88. Because Cargo's
default resolver picks the newest semver-compatible dependency without regard to
rust-version, a toolchain below the floor fails at resolution time with
rustc 1.87.0 is not supported by the following packages rather than at compile
time. On an older toolchain, either pin those dependencies back with
cargo update <crate> --precise <version> or enable Cargo's MSRV-aware resolver.
License
Licensed under either of Apache License 2.0 or MIT License, at your option.